diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ff9d3a039..b1398f858 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 4788159a2..cf9d145ed 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -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 } : {}), }, }); diff --git a/src/renderer/features/sharing/components/share-item-context-modal.tsx b/src/renderer/features/sharing/components/share-item-context-modal.tsx index afbb510fe..97ee7d6c9 100644 --- a/src/renderer/features/sharing/components/share-item-context-modal.tsx +++ b/src/renderer/features/sharing/components/share-item-context-modal.tsx @@ -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.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) => { + 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 (
- + + + + {expirationPresets.map((preset) => ( + + ))} + + + + + + {t('setting.shareExpiration')} + + + + { + updateShareExpiration({ + useServerDefault: e.currentTarget.checked, + }); + }} + /> + {!shareExpiration.useServerDefault && ( + + {t('setting.shareExpiration')} + + {t('setting.shareExpiration', { + context: 'description', + })} + + + { + const amount = Math.max( + 1, + Math.floor(Number(e.currentTarget.value)) || + 1, + ); + updateShareExpiration({ amount }); + }} + value={shareExpiration.amount} + width={90} + /> +