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
@@ -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')}
+22
View File
@@ -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,