mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-08 05:12:57 +02:00
feat: add an option to configure default share expiration (#2283)
* feat: add an option to configure default share expiration
This commit is contained in:
@@ -303,6 +303,13 @@
|
||||
"explicitStatus": "$t(common.explicitStatus)"
|
||||
},
|
||||
"datetime": {
|
||||
"secondLong": "Second",
|
||||
"minuteLong": "Minute",
|
||||
"hourLong": "Hour",
|
||||
"dayLong": "Day",
|
||||
"weekLong": "Week",
|
||||
"monthLong": "Month",
|
||||
"yearLong": "Year",
|
||||
"minuteShort": "m",
|
||||
"secondShort": "s",
|
||||
"hourShort": "h",
|
||||
@@ -422,6 +429,8 @@
|
||||
"copyToClipboard": "Copy to clipboard: Ctrl+C, enter",
|
||||
"description": "Description",
|
||||
"setExpiration": "Set expiration",
|
||||
"setExpiration_description": "Leave empty to use the server default expiration",
|
||||
"setExpiration_serverDefault": "Server default",
|
||||
"success": "Share link copied to clipboard (or click here to open)",
|
||||
"successMustClick": "Share created successfully. Click here to open",
|
||||
"expireInvalid": "Expiration must be in the future",
|
||||
@@ -680,6 +689,7 @@
|
||||
"remote": "Remote",
|
||||
"exportImport": "Import/export",
|
||||
"scrobble": "Scrobble",
|
||||
"sharing": "Sharing",
|
||||
"audio": "Audio",
|
||||
"lyrics": "Lyrics",
|
||||
"lyricsDisplay": "Lyrics Display",
|
||||
@@ -1164,6 +1174,10 @@
|
||||
"savePlayQueue": "Save play queue",
|
||||
"scrobble_description": "Scrobble plays to your media server",
|
||||
"scrobble": "Scrobble",
|
||||
"shareExpiration_description": "How far in the future new share links expire by default. Clear the date in the share dialog to use the server default instead",
|
||||
"shareExpiration": "Default share expiration",
|
||||
"shareExpirationUseServerDefault_description": "Omit an expiration when creating shares so the server can apply its configured default (e.g. Navidrome DefaultShareExpiration)",
|
||||
"shareExpirationUseServerDefault": "Use server default expiration",
|
||||
"showSkipButton_description": "Show or hide the skip buttons on the player bar",
|
||||
"showSkipButton": "Show skip buttons",
|
||||
"showSkipButtons_description": "Show or hide the skip buttons on the player bar",
|
||||
|
||||
@@ -1173,9 +1173,9 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
body: {
|
||||
description: body.description,
|
||||
downloadable: body.downloadable,
|
||||
expires: body.expires,
|
||||
resourceIds: body.resourceIds,
|
||||
resourceType: body.resourceType,
|
||||
...(body.expires !== undefined ? { expires: body.expires } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,55 @@
|
||||
import { closeModal, ContextModalProps } from '@mantine/modals';
|
||||
import dayjs from 'dayjs';
|
||||
import dayjs, { type ManipulateType } from 'dayjs';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useShareItem } from '/@/renderer/features/sharing/mutations/share-item-mutation';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store';
|
||||
import {
|
||||
type SettingsState,
|
||||
ShareExpirationUnit,
|
||||
useSettingsStoreActions,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
||||
import { Accordion } from '/@/shared/components/accordion/accordion';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { DateTimePicker } from '/@/shared/components/date-time-picker/date-time-picker';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { ModalButton } from '/@/shared/components/modal/model-shared';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Textarea } from '/@/shared/components/textarea/textarea';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { useForm } from '/@/shared/hooks/use-form';
|
||||
|
||||
const EXPIRES_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
const unitToDayjs: Record<ShareExpirationUnit, ManipulateType> = {
|
||||
[ShareExpirationUnit.DAY]: 'day',
|
||||
[ShareExpirationUnit.HOUR]: 'hour',
|
||||
[ShareExpirationUnit.MINUTE]: 'minute',
|
||||
[ShareExpirationUnit.MONTH]: 'month',
|
||||
[ShareExpirationUnit.SECOND]: 'second',
|
||||
[ShareExpirationUnit.WEEK]: 'week',
|
||||
[ShareExpirationUnit.YEAR]: 'year',
|
||||
};
|
||||
|
||||
type ShareExpirationSettings = SettingsState['general']['shareExpiration'];
|
||||
const getShareExpirationDate = (settings: ShareExpirationSettings): null | string => {
|
||||
if (settings.useServerDefault) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const amount = Math.max(1, Math.floor(settings.amount) || 1);
|
||||
return dayjs().add(amount, unitToDayjs[settings.unit]).format(EXPIRES_FORMAT);
|
||||
};
|
||||
|
||||
const addExpiration = (amount: number, unit: ManipulateType): string =>
|
||||
dayjs().add(amount, unit).format(EXPIRES_FORMAT);
|
||||
|
||||
export const ShareItemContextModal = ({
|
||||
id,
|
||||
innerProps,
|
||||
@@ -24,11 +60,12 @@ export const ShareItemContextModal = ({
|
||||
const { t } = useTranslation();
|
||||
const { itemIds, resourceType } = innerProps;
|
||||
const server = useCurrentServer();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
const shareItemMutation = useShareItem({});
|
||||
|
||||
// Uses the same default as Navidrome: 1 year
|
||||
const defaultDate = dayjs().add(1, 'year').format('YYYY-MM-DD HH:mm:ss');
|
||||
const { shareExpiration } = useGeneralSettings();
|
||||
const defaultDate = getShareExpirationDate(shareExpiration);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
@@ -37,11 +74,83 @@ export const ShareItemContextModal = ({
|
||||
expires: defaultDate,
|
||||
},
|
||||
validate: {
|
||||
expires: (value) =>
|
||||
dayjs(value).isAfter(dayjs()) ? null : t('form.shareItem.expireInvalid'),
|
||||
expires: (value) => {
|
||||
if (!value) return null;
|
||||
return dayjs(value).isAfter(dayjs()) ? null : t('form.shareItem.expireInvalid');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const unitOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t('datetime.secondLong'),
|
||||
value: ShareExpirationUnit.SECOND,
|
||||
},
|
||||
{
|
||||
label: t('datetime.minuteLong'),
|
||||
value: ShareExpirationUnit.MINUTE,
|
||||
},
|
||||
{
|
||||
label: t('datetime.hourLong'),
|
||||
value: ShareExpirationUnit.HOUR,
|
||||
},
|
||||
{
|
||||
label: t('datetime.dayLong'),
|
||||
value: ShareExpirationUnit.DAY,
|
||||
},
|
||||
{
|
||||
label: t('datetime.weekLong'),
|
||||
value: ShareExpirationUnit.WEEK,
|
||||
},
|
||||
{
|
||||
label: t('datetime.monthLong'),
|
||||
value: ShareExpirationUnit.MONTH,
|
||||
},
|
||||
{
|
||||
label: t('datetime.yearLong'),
|
||||
value: ShareExpirationUnit.YEAR,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const expirationPresets = useMemo(
|
||||
() => [
|
||||
{
|
||||
getValue: (): null | string => null,
|
||||
label: t('form.shareItem.setExpiration', { context: 'serverDefault' }),
|
||||
},
|
||||
{
|
||||
getValue: (): null | string => addExpiration(1, 'day'),
|
||||
label: `1 ${t('datetime.dayLong')}`,
|
||||
},
|
||||
{
|
||||
getValue: (): null | string => addExpiration(1, 'week'),
|
||||
label: `1 ${t('datetime.weekLong')}`,
|
||||
},
|
||||
{
|
||||
getValue: (): null | string => addExpiration(1, 'month'),
|
||||
label: `1 ${t('datetime.monthLong')}`,
|
||||
},
|
||||
{
|
||||
getValue: (): null | string => addExpiration(1, 'year'),
|
||||
label: `1 ${t('datetime.yearLong')}`,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const updateShareExpiration = (updates: Partial<ShareExpirationSettings>) => {
|
||||
const next = { ...shareExpiration, ...updates };
|
||||
setSettings({
|
||||
general: {
|
||||
shareExpiration: next,
|
||||
},
|
||||
});
|
||||
form.setFieldValue('expires', getShareExpirationDate(next));
|
||||
};
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
const canUseClipboard = Boolean(navigator.clipboard) && window.isSecureContext;
|
||||
|
||||
@@ -59,7 +168,7 @@ export const ShareItemContextModal = ({
|
||||
body: {
|
||||
description: values.description,
|
||||
downloadable: values.allowDownloading,
|
||||
expires: dayjs(values.expires).valueOf(),
|
||||
...(values.expires ? { expires: dayjs(values.expires).valueOf() } : {}),
|
||||
resourceIds: itemIds.join(),
|
||||
resourceType,
|
||||
},
|
||||
@@ -125,15 +234,95 @@ export const ShareItemContextModal = ({
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<DateTimePicker
|
||||
clearable
|
||||
label={t('form.shareItem.setExpiration')}
|
||||
minDate={new Date()}
|
||||
placeholder={defaultDate}
|
||||
popoverProps={{ withinPortal: true }}
|
||||
valueFormat="MM/DD/YYYY HH:mm"
|
||||
{...form.getInputProps('expires')}
|
||||
/>
|
||||
<Stack gap="xs">
|
||||
<DateTimePicker
|
||||
clearable
|
||||
description={t('form.shareItem.setExpiration', { context: 'description' })}
|
||||
label={t('form.shareItem.setExpiration')}
|
||||
minDate={new Date()}
|
||||
placeholder={
|
||||
defaultDate ??
|
||||
t('form.shareItem.setExpiration', { context: 'serverDefault' })
|
||||
}
|
||||
popoverProps={{ withinPortal: true }}
|
||||
valueFormat="MM/DD/YYYY HH:mm"
|
||||
{...form.getInputProps('expires')}
|
||||
/>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{expirationPresets.map((preset) => (
|
||||
<Button
|
||||
key={preset.label}
|
||||
onClick={() => {
|
||||
form.setFieldValue('expires', preset.getValue());
|
||||
}}
|
||||
size="compact-xs"
|
||||
type="button"
|
||||
variant="default"
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
<Accordion variant="separated">
|
||||
<Accordion.Item value="share-expiration-defaults">
|
||||
<Accordion.Control>
|
||||
<Text>{t('setting.shareExpiration')}</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="md">
|
||||
<Switch
|
||||
checked={shareExpiration.useServerDefault}
|
||||
description={t('setting.shareExpirationUseServerDefault', {
|
||||
context: 'description',
|
||||
})}
|
||||
label={t('setting.shareExpirationUseServerDefault')}
|
||||
onChange={(e) => {
|
||||
updateShareExpiration({
|
||||
useServerDefault: e.currentTarget.checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!shareExpiration.useServerDefault && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{t('setting.shareExpiration')}</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t('setting.shareExpiration', {
|
||||
context: 'description',
|
||||
})}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<NumberInput
|
||||
min={1}
|
||||
onBlur={(e) => {
|
||||
const amount = Math.max(
|
||||
1,
|
||||
Math.floor(Number(e.currentTarget.value)) ||
|
||||
1,
|
||||
);
|
||||
updateShareExpiration({ amount });
|
||||
}}
|
||||
value={shareExpiration.amount}
|
||||
width={90}
|
||||
/>
|
||||
<Select
|
||||
data={unitOptions}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
updateShareExpiration({
|
||||
unit: value as ShareExpirationUnit,
|
||||
});
|
||||
}}
|
||||
value={shareExpiration.unit}
|
||||
w={120}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<Textarea
|
||||
autosize
|
||||
label={t('form.shareItem.description')}
|
||||
|
||||
@@ -471,6 +471,22 @@ export enum HomeFeatureStyle {
|
||||
SINGLE = 'single',
|
||||
}
|
||||
|
||||
export enum ShareExpirationUnit {
|
||||
DAY = 'day',
|
||||
HOUR = 'hour',
|
||||
MINUTE = 'minute',
|
||||
MONTH = 'month',
|
||||
SECOND = 'second',
|
||||
WEEK = 'week',
|
||||
YEAR = 'year',
|
||||
}
|
||||
|
||||
const ShareExpirationSchema = z.object({
|
||||
amount: z.number().int().min(1),
|
||||
unit: z.nativeEnum(ShareExpirationUnit),
|
||||
useServerDefault: z.boolean(),
|
||||
});
|
||||
|
||||
const AutoSaveSchema = z.object({
|
||||
count: z.number().min(0),
|
||||
enabled: z.boolean(),
|
||||
@@ -536,6 +552,7 @@ export const GeneralSettingsSchema = z.object({
|
||||
primaryShade: z.number().min(0).max(9),
|
||||
qobuz: z.boolean(),
|
||||
resume: z.boolean(),
|
||||
shareExpiration: ShareExpirationSchema,
|
||||
showFavorites: z.boolean(),
|
||||
showLyricsInSidebar: z.boolean(),
|
||||
showQueueInSidebar: z.boolean(),
|
||||
@@ -1315,6 +1332,11 @@ const initialState: SettingsState = {
|
||||
primaryShade: 6,
|
||||
qobuz: true,
|
||||
resume: true,
|
||||
shareExpiration: {
|
||||
amount: 1,
|
||||
unit: ShareExpirationUnit.YEAR,
|
||||
useServerDefault: false,
|
||||
},
|
||||
showFavorites: true,
|
||||
showLyricsInSidebar: true,
|
||||
showQueueInSidebar: true,
|
||||
|
||||
@@ -734,7 +734,7 @@ const shareItem = z.object({
|
||||
const shareItemParameters = z.object({
|
||||
description: z.string(),
|
||||
downloadable: z.boolean(),
|
||||
expires: z.number(),
|
||||
expires: z.number().optional(),
|
||||
resourceIds: z.string(),
|
||||
resourceType: z.string(),
|
||||
});
|
||||
|
||||
@@ -1116,7 +1116,7 @@ export type ShareItemArgs = BaseEndpointArgs & { body: ShareItemBody };
|
||||
export type ShareItemBody = {
|
||||
description: string;
|
||||
downloadable: boolean;
|
||||
expires: number;
|
||||
expires?: number;
|
||||
resourceIds: string;
|
||||
resourceType: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user