mirror of
https://github.com/immich-app/immich.git
synced 2026-03-06 18:17:27 +03:00
feat(web): change link expiration logic & presets (#26064)
* feat(web): link expiration presets * refactor: implement suggestions * chore: remove createdAt prop * fix: tests * fix: button keys
This commit is contained in:
@@ -1,21 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import { Button, DatePicker, Field } from '@immich/ui';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { minBy, uniqBy } from 'lodash-es';
|
|
||||||
import { DateTime, Duration } from 'luxon';
|
import { DateTime, Duration } from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
createdAt?: string;
|
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { createdAt = DateTime.now().toISO(), expiresAt = $bindable() }: Props = $props();
|
let { expiresAt = $bindable() }: Props = $props();
|
||||||
|
|
||||||
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
|
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||||
[30, 'minutes'],
|
|
||||||
[1, 'hour'],
|
|
||||||
[6, 'hours'],
|
|
||||||
[1, 'day'],
|
[1, 'day'],
|
||||||
[7, 'days'],
|
[7, 'days'],
|
||||||
[30, 'days'],
|
[30, 'days'],
|
||||||
@@ -26,50 +21,52 @@
|
|||||||
const relativeTime = $derived(new Intl.RelativeTimeFormat($locale));
|
const relativeTime = $derived(new Intl.RelativeTimeFormat($locale));
|
||||||
const expiredDateOptions = $derived([
|
const expiredDateOptions = $derived([
|
||||||
{ text: $t('never'), value: 0 },
|
{ text: $t('never'), value: 0 },
|
||||||
...expirationOptions
|
...expirationOptions.map(([value, unit]) => ({
|
||||||
.map(([value, unit]) => ({
|
text: relativeTime.format(value, unit),
|
||||||
text: relativeTime.format(value, unit),
|
value: Duration.fromObject({ [unit]: value }).toMillis(),
|
||||||
value: Duration.fromObject({ [unit]: value }).toMillis(),
|
})),
|
||||||
}))
|
|
||||||
.filter(({ value: millis }) => DateTime.fromISO(createdAt).plus(millis) > DateTime.now()),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const getExpirationOption = (createdAt: string, expiresAt: string | null) => {
|
let selectedPresetValue = $state<number | null>(null);
|
||||||
if (!expiresAt) {
|
|
||||||
return expiredDateOptions[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = DateTime.fromISO(expiresAt).diff(DateTime.fromISO(createdAt)).toMillis();
|
const getSelectedDate = (): DateTime | undefined => {
|
||||||
const closestOption = minBy(expiredDateOptions, ({ value }) => Math.abs(delta - value));
|
return expiresAt ? DateTime.fromISO(expiresAt) : undefined;
|
||||||
|
|
||||||
if (!closestOption) {
|
|
||||||
return expiredDateOptions[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// allow a generous epsilon to compensate for potential API delays
|
|
||||||
if (Math.abs(closestOption.value - delta) > 10_000) {
|
|
||||||
const interval = DateTime.fromMillis(closestOption.value) as DateTime<true>;
|
|
||||||
return { text: interval.toRelative({ locale: $locale }), value: closestOption.value };
|
|
||||||
}
|
|
||||||
|
|
||||||
return closestOption;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSelect = (option: number | string) => {
|
const setSelectedDate = (value: DateTime | undefined) => {
|
||||||
const expirationOption = Number(option);
|
selectedPresetValue = null; // Clear preset when manually setting date
|
||||||
|
expiresAt = value ? value.toISO() : null;
|
||||||
expiresAt = expirationOption === 0 ? null : DateTime.fromISO(createdAt).plus(expirationOption).toISO();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let expirationOption = $derived(getExpirationOption(createdAt, expiresAt).value);
|
const selectPreset = (value: number) => {
|
||||||
|
selectedPresetValue = value;
|
||||||
|
if (value === 0) {
|
||||||
|
expiresAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newDate = DateTime.now().plus(value);
|
||||||
|
expiresAt = newDate.toISO();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelected = (value: number) => {
|
||||||
|
return selectedPresetValue === value;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<SettingSelect
|
<Field label={$t('expire_after')}>
|
||||||
bind:value={expirationOption}
|
<DatePicker bind:value={getSelectedDate, setSelectedDate} />
|
||||||
{onSelect}
|
</Field>
|
||||||
options={uniqBy([...expiredDateOptions, getExpirationOption(createdAt, expiresAt)], 'value')}
|
|
||||||
label={$t('expire_after')}
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
number={true}
|
{#each expiredDateOptions as option (option.value)}
|
||||||
/>
|
<Button
|
||||||
|
size="tiny"
|
||||||
|
variant={isSelected(option.value) ? 'filled' : 'outline'}
|
||||||
|
onclick={() => selectPreset(option.value)}
|
||||||
|
>
|
||||||
|
{option.text}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render } from '@testing-library/svelte';
|
import { renderWithTooltips } from '$tests/helpers';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import SharedLinkFormFields from './SharedLinkFormFields.svelte';
|
import SharedLinkFormFields from './SharedLinkFormFields.svelte';
|
||||||
|
|
||||||
@@ -7,16 +7,14 @@ describe('SharedLinkFormFields component', () => {
|
|||||||
element instanceof HTMLInputElement ? element.checked : element.getAttribute('aria-checked') === 'true';
|
element instanceof HTMLInputElement ? element.checked : element.getAttribute('aria-checked') === 'true';
|
||||||
|
|
||||||
it('turns downloads off when metadata is disabled', async () => {
|
it('turns downloads off when metadata is disabled', async () => {
|
||||||
const { container } = render(SharedLinkFormFields, {
|
const { container } = renderWithTooltips(SharedLinkFormFields, {
|
||||||
props: {
|
slug: '',
|
||||||
slug: '',
|
password: '',
|
||||||
password: '',
|
description: '',
|
||||||
description: '',
|
allowDownload: true,
|
||||||
allowDownload: true,
|
allowUpload: false,
|
||||||
allowUpload: false,
|
showMetadata: true,
|
||||||
showMetadata: true,
|
expiresAt: null,
|
||||||
expiresAt: null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
allowUpload: boolean;
|
allowUpload: boolean;
|
||||||
showMetadata: boolean;
|
showMetadata: boolean;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
createdAt?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -22,7 +21,6 @@
|
|||||||
allowUpload = $bindable(),
|
allowUpload = $bindable(),
|
||||||
showMetadata = $bindable(),
|
showMetadata = $bindable(),
|
||||||
expiresAt = $bindable(),
|
expiresAt = $bindable(),
|
||||||
createdAt,
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -50,7 +48,7 @@
|
|||||||
<Input bind:value={description} autocomplete="off" />
|
<Input bind:value={description} autocomplete="off" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<SharedLinkExpiration {createdAt} bind:expiresAt />
|
<SharedLinkExpiration bind:expiresAt />
|
||||||
<Field label={$t('show_metadata')}>
|
<Field label={$t('show_metadata')}>
|
||||||
<Switch bind:checked={showMetadata} />
|
<Switch bind:checked={showMetadata} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -69,6 +69,5 @@
|
|||||||
bind:allowUpload
|
bind:allowUpload
|
||||||
bind:showMetadata
|
bind:showMetadata
|
||||||
bind:expiresAt
|
bind:expiresAt
|
||||||
createdAt={sharedLink.createdAt}
|
|
||||||
/>
|
/>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|||||||
Reference in New Issue
Block a user