feat: add an option to configure default share expiration (#2283)

* feat: add an option to configure default share expiration
This commit is contained in:
Yunus
2026-08-03 22:26:45 -04:00
committed by GitHub
parent a8bec1ad75
commit 71fc632501
6 changed files with 244 additions and 19 deletions
+14
View File
@@ -303,6 +303,13 @@
"explicitStatus": "$t(common.explicitStatus)" "explicitStatus": "$t(common.explicitStatus)"
}, },
"datetime": { "datetime": {
"secondLong": "Second",
"minuteLong": "Minute",
"hourLong": "Hour",
"dayLong": "Day",
"weekLong": "Week",
"monthLong": "Month",
"yearLong": "Year",
"minuteShort": "m", "minuteShort": "m",
"secondShort": "s", "secondShort": "s",
"hourShort": "h", "hourShort": "h",
@@ -422,6 +429,8 @@
"copyToClipboard": "Copy to clipboard: Ctrl+C, enter", "copyToClipboard": "Copy to clipboard: Ctrl+C, enter",
"description": "Description", "description": "Description",
"setExpiration": "Set expiration", "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)", "success": "Share link copied to clipboard (or click here to open)",
"successMustClick": "Share created successfully. Click here to open", "successMustClick": "Share created successfully. Click here to open",
"expireInvalid": "Expiration must be in the future", "expireInvalid": "Expiration must be in the future",
@@ -680,6 +689,7 @@
"remote": "Remote", "remote": "Remote",
"exportImport": "Import/export", "exportImport": "Import/export",
"scrobble": "Scrobble", "scrobble": "Scrobble",
"sharing": "Sharing",
"audio": "Audio", "audio": "Audio",
"lyrics": "Lyrics", "lyrics": "Lyrics",
"lyricsDisplay": "Lyrics Display", "lyricsDisplay": "Lyrics Display",
@@ -1164,6 +1174,10 @@
"savePlayQueue": "Save play queue", "savePlayQueue": "Save play queue",
"scrobble_description": "Scrobble plays to your media server", "scrobble_description": "Scrobble plays to your media server",
"scrobble": "Scrobble", "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_description": "Show or hide the skip buttons on the player bar",
"showSkipButton": "Show skip buttons", "showSkipButton": "Show skip buttons",
"showSkipButtons_description": "Show or hide the skip buttons on the player bar", "showSkipButtons_description": "Show or hide the skip buttons on the player bar",
@@ -1173,9 +1173,9 @@ export const NavidromeController: InternalControllerEndpoint = {
body: { body: {
description: body.description, description: body.description,
downloadable: body.downloadable, downloadable: body.downloadable,
expires: body.expires,
resourceIds: body.resourceIds, resourceIds: body.resourceIds,
resourceType: body.resourceType, resourceType: body.resourceType,
...(body.expires !== undefined ? { expires: body.expires } : {}),
}, },
}); });
@@ -1,19 +1,55 @@
import { closeModal, ContextModalProps } from '@mantine/modals'; 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 { useTranslation } from 'react-i18next';
import { useShareItem } from '/@/renderer/features/sharing/mutations/share-item-mutation'; 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 { 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 { DateTimePicker } from '/@/shared/components/date-time-picker/date-time-picker';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { ModalButton } from '/@/shared/components/modal/model-shared'; 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 { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text';
import { Textarea } from '/@/shared/components/textarea/textarea'; import { Textarea } from '/@/shared/components/textarea/textarea';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { useForm } from '/@/shared/hooks/use-form'; 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 = ({ export const ShareItemContextModal = ({
id, id,
innerProps, innerProps,
@@ -24,11 +60,12 @@ export const ShareItemContextModal = ({
const { t } = useTranslation(); const { t } = useTranslation();
const { itemIds, resourceType } = innerProps; const { itemIds, resourceType } = innerProps;
const server = useCurrentServer(); const server = useCurrentServer();
const { setSettings } = useSettingsStoreActions();
const shareItemMutation = useShareItem({}); const shareItemMutation = useShareItem({});
// Uses the same default as Navidrome: 1 year const { shareExpiration } = useGeneralSettings();
const defaultDate = dayjs().add(1, 'year').format('YYYY-MM-DD HH:mm:ss'); const defaultDate = getShareExpirationDate(shareExpiration);
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
@@ -37,11 +74,83 @@ export const ShareItemContextModal = ({
expires: defaultDate, expires: defaultDate,
}, },
validate: { validate: {
expires: (value) => expires: (value) => {
dayjs(value).isAfter(dayjs()) ? null : t('form.shareItem.expireInvalid'), 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 handleSubmit = form.onSubmit(async (values) => {
const canUseClipboard = Boolean(navigator.clipboard) && window.isSecureContext; const canUseClipboard = Boolean(navigator.clipboard) && window.isSecureContext;
@@ -59,7 +168,7 @@ export const ShareItemContextModal = ({
body: { body: {
description: values.description, description: values.description,
downloadable: values.allowDownloading, downloadable: values.allowDownloading,
expires: dayjs(values.expires).valueOf(), ...(values.expires ? { expires: dayjs(values.expires).valueOf() } : {}),
resourceIds: itemIds.join(), resourceIds: itemIds.join(),
resourceType, resourceType,
}, },
@@ -125,15 +234,95 @@ export const ShareItemContextModal = ({
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Stack> <Stack>
<DateTimePicker <Stack gap="xs">
clearable <DateTimePicker
label={t('form.shareItem.setExpiration')} clearable
minDate={new Date()} description={t('form.shareItem.setExpiration', { context: 'description' })}
placeholder={defaultDate} label={t('form.shareItem.setExpiration')}
popoverProps={{ withinPortal: true }} minDate={new Date()}
valueFormat="MM/DD/YYYY HH:mm" placeholder={
{...form.getInputProps('expires')} 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 <Textarea
autosize autosize
label={t('form.shareItem.description')} label={t('form.shareItem.description')}
+22
View File
@@ -471,6 +471,22 @@ export enum HomeFeatureStyle {
SINGLE = 'single', 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({ const AutoSaveSchema = z.object({
count: z.number().min(0), count: z.number().min(0),
enabled: z.boolean(), enabled: z.boolean(),
@@ -536,6 +552,7 @@ export const GeneralSettingsSchema = z.object({
primaryShade: z.number().min(0).max(9), primaryShade: z.number().min(0).max(9),
qobuz: z.boolean(), qobuz: z.boolean(),
resume: z.boolean(), resume: z.boolean(),
shareExpiration: ShareExpirationSchema,
showFavorites: z.boolean(), showFavorites: z.boolean(),
showLyricsInSidebar: z.boolean(), showLyricsInSidebar: z.boolean(),
showQueueInSidebar: z.boolean(), showQueueInSidebar: z.boolean(),
@@ -1315,6 +1332,11 @@ const initialState: SettingsState = {
primaryShade: 6, primaryShade: 6,
qobuz: true, qobuz: true,
resume: true, resume: true,
shareExpiration: {
amount: 1,
unit: ShareExpirationUnit.YEAR,
useServerDefault: false,
},
showFavorites: true, showFavorites: true,
showLyricsInSidebar: true, showLyricsInSidebar: true,
showQueueInSidebar: true, showQueueInSidebar: true,
+1 -1
View File
@@ -734,7 +734,7 @@ const shareItem = z.object({
const shareItemParameters = z.object({ const shareItemParameters = z.object({
description: z.string(), description: z.string(),
downloadable: z.boolean(), downloadable: z.boolean(),
expires: z.number(), expires: z.number().optional(),
resourceIds: z.string(), resourceIds: z.string(),
resourceType: z.string(), resourceType: z.string(),
}); });
+1 -1
View File
@@ -1116,7 +1116,7 @@ export type ShareItemArgs = BaseEndpointArgs & { body: ShareItemBody };
export type ShareItemBody = { export type ShareItemBody = {
description: string; description: string;
downloadable: boolean; downloadable: boolean;
expires: number; expires?: number;
resourceIds: string; resourceIds: string;
resourceType: string; resourceType: string;
}; };