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)"
|
"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>
|
||||||
|
<Stack gap="xs">
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
clearable
|
clearable
|
||||||
|
description={t('form.shareItem.setExpiration', { context: 'description' })}
|
||||||
label={t('form.shareItem.setExpiration')}
|
label={t('form.shareItem.setExpiration')}
|
||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
placeholder={defaultDate}
|
placeholder={
|
||||||
|
defaultDate ??
|
||||||
|
t('form.shareItem.setExpiration', { context: 'serverDefault' })
|
||||||
|
}
|
||||||
popoverProps={{ withinPortal: true }}
|
popoverProps={{ withinPortal: true }}
|
||||||
valueFormat="MM/DD/YYYY HH:mm"
|
valueFormat="MM/DD/YYYY HH:mm"
|
||||||
{...form.getInputProps('expires')}
|
{...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')}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user