mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat: batch change date and time relatively (#17717)
Co-authored-by: marcel.kuehne <> Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
parent
df2525ee08
commit
011a667314
19 changed files with 574 additions and 52 deletions
|
|
@ -5,14 +5,21 @@
|
|||
import { t } from 'svelte-i18n';
|
||||
import DateInput from '../elements/date-input.svelte';
|
||||
import Combobox, { type ComboBoxOption } from './combobox.svelte';
|
||||
import DurationInput from '../elements/duration-input.svelte';
|
||||
import { Field, Switch } from '@immich/ui';
|
||||
import { getDateTimeOffsetLocaleString } from '$lib/utils/timeline-util.js';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
initialDate?: DateTime;
|
||||
initialTimeZone?: string;
|
||||
timezoneInput?: boolean;
|
||||
withDuration?: boolean;
|
||||
currentInterval?: { start: DateTime; end: DateTime };
|
||||
onCancel: () => void;
|
||||
onConfirm: (date: string) => void;
|
||||
onConfirm: (result: AbsoluteResult | RelativeResult) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -20,10 +27,23 @@
|
|||
initialTimeZone = '',
|
||||
title = $t('edit_date_and_time'),
|
||||
timezoneInput = true,
|
||||
withDuration = true,
|
||||
currentInterval = undefined,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: Props = $props();
|
||||
|
||||
export type AbsoluteResult = {
|
||||
mode: 'absolute';
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type RelativeResult = {
|
||||
mode: 'relative';
|
||||
duration?: number;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
type ZoneOption = {
|
||||
/**
|
||||
* Timezone name with offset
|
||||
|
|
@ -61,6 +81,10 @@
|
|||
valid: boolean;
|
||||
};
|
||||
|
||||
let showRelative = $state(false);
|
||||
|
||||
let selectedDuration = $state(0);
|
||||
|
||||
const knownTimezones = Intl.supportedValuesOf('timeZone');
|
||||
|
||||
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
|
@ -74,7 +98,10 @@
|
|||
.filter((zone) => zone.valid)
|
||||
.sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB));
|
||||
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
|
||||
let selectedOption: ZoneOption | undefined = $state(getPreferredTimeZone(initialDate, userTimeZone, timezones));
|
||||
let selectedAbsoluteOption: ZoneOption | undefined = $state(
|
||||
getPreferredTimeZone(userTimeZone, timezones, initialDate),
|
||||
);
|
||||
let selectedRelativeOption: ZoneOption | undefined = $state(undefined);
|
||||
|
||||
function zoneOptionForDate(zone: string, date: string) {
|
||||
const { offsetMinutes, offsetFormat: zoneOffsetAtDate } = getModernOffsetForZoneAndDate(zone, date);
|
||||
|
|
@ -104,16 +131,20 @@
|
|||
* to show "Europe/Berlin" instead of the lexicographically first entry "Africa/Blantyre".
|
||||
*/
|
||||
function getPreferredTimeZone(
|
||||
date: DateTime,
|
||||
userTimeZone: string,
|
||||
timezones: ZoneOption[],
|
||||
date?: DateTime,
|
||||
selectedOption?: ZoneOption,
|
||||
) {
|
||||
const offset = date.offset;
|
||||
const offset = date?.offset;
|
||||
const previousSelection = timezones.find((item) => item.value === selectedOption?.value);
|
||||
const fromInitialTimeZone = timezones.find((item) => item.value === initialTimeZone);
|
||||
const sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone);
|
||||
const firstWithSameOffset = timezones.find((item) => item.offsetMinutes === offset);
|
||||
let sameAsUserTimeZone;
|
||||
let firstWithSameOffset;
|
||||
if (offset !== undefined) {
|
||||
sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone);
|
||||
firstWithSameOffset = timezones.find((item) => item.offsetMinutes === offset);
|
||||
}
|
||||
const utcFallback = {
|
||||
label: 'UTC (+00:00)',
|
||||
offsetMinutes: 0,
|
||||
|
|
@ -150,12 +181,12 @@
|
|||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (date.isValid && selectedOption) {
|
||||
if (!showRelative && date.isValid && selectedAbsoluteOption) {
|
||||
// Get the local date/time components from the selected string using neutral timezone
|
||||
const dtComponents = DateTime.fromISO(selectedDate, { zone: 'utc' });
|
||||
|
||||
// Determine the modern, DST-aware offset for the selected IANA zone
|
||||
const { offsetMinutes } = getModernOffsetForZoneAndDate(selectedOption.value, selectedDate);
|
||||
const { offsetMinutes } = getModernOffsetForZoneAndDate(selectedAbsoluteOption.value, selectedDate);
|
||||
|
||||
// Construct the final ISO string with a fixed-offset zone.
|
||||
const fixedOffsetZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`;
|
||||
|
|
@ -163,17 +194,45 @@
|
|||
// Create a DateTime object in this fixed-offset zone, preserving the local time.
|
||||
const finalDateTime = DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone });
|
||||
|
||||
onConfirm(finalDateTime.toISO({ includeOffset: true })!);
|
||||
onConfirm({ mode: 'absolute', date: finalDateTime.toISO({ includeOffset: true })! });
|
||||
}
|
||||
|
||||
if (showRelative && (selectedDuration || selectedRelativeOption)) {
|
||||
onConfirm({ mode: 'relative', duration: selectedDuration, timeZone: selectedRelativeOption?.value });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnSelect = (option?: ComboBoxOption) => {
|
||||
if (option) {
|
||||
selectedOption = getPreferredTimeZone(initialDate, userTimeZone, timezones, option as ZoneOption);
|
||||
if (showRelative) {
|
||||
selectedRelativeOption = option
|
||||
? getPreferredTimeZone(userTimeZone, timezones, undefined, option as ZoneOption)
|
||||
: undefined;
|
||||
} else {
|
||||
if (option) {
|
||||
selectedAbsoluteOption = getPreferredTimeZone(userTimeZone, timezones, initialDate, option as ZoneOption);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let selectedOption = $derived(showRelative ? selectedRelativeOption : selectedAbsoluteOption);
|
||||
|
||||
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
|
||||
let date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
|
||||
let date = $derived(DateTime.fromISO(selectedDate, { zone: selectedAbsoluteOption?.value, setZone: true }));
|
||||
|
||||
export function calcNewDate(timestamp: DateTime, selectedDuration: number, timezone?: string) {
|
||||
timestamp = timestamp.plus({ minutes: selectedDuration });
|
||||
if (timezone) {
|
||||
timestamp = timestamp.setZone(timezone);
|
||||
}
|
||||
return getDateTimeOffsetLocaleString(timestamp, { locale: get(locale) });
|
||||
}
|
||||
|
||||
let intervalFrom = $derived.by(() =>
|
||||
currentInterval ? calcNewDate(currentInterval.start, selectedDuration, selectedRelativeOption?.value) : undefined,
|
||||
);
|
||||
let intervalTo = $derived.by(() =>
|
||||
currentInterval ? calcNewDate(currentInterval.end, selectedDuration, selectedRelativeOption?.value) : undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<ConfirmModal
|
||||
|
|
@ -185,22 +244,42 @@
|
|||
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
||||
>
|
||||
{#snippet promptSnippet()}
|
||||
<div class="flex flex-col text-start gap-2">
|
||||
<div class="flex flex-col">
|
||||
<label for="datetime">{$t('date_and_time')}</label>
|
||||
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
|
||||
{#if withDuration}
|
||||
<div class="mb-5">
|
||||
<Field label={$t('edit_date_and_time_by_offset')}>
|
||||
<Switch data-testid="edit-by-offset-switch" bind:checked={showRelative} />
|
||||
</Field>
|
||||
</div>
|
||||
{#if timezoneInput}
|
||||
<div>
|
||||
<Combobox
|
||||
bind:selectedOption
|
||||
label={$t('timezone')}
|
||||
options={timezones}
|
||||
placeholder={$t('search_timezone')}
|
||||
onSelect={(option) => handleOnSelect(option)}
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex flex-col text-start min-h-[140px]">
|
||||
<div>
|
||||
<div class="flex flex-col" style="display: {showRelative ? 'none' : 'flex'}">
|
||||
<label for="datetime">{$t('date_and_time')}</label>
|
||||
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col" style="display: {showRelative ? 'flex' : 'none'}">
|
||||
<div class="flex flex-col">
|
||||
<label for="relativedatetime">{$t('offset')}</label>
|
||||
<DurationInput class="immich-form-input" id="relativedatetime" bind:value={selectedDuration} />
|
||||
</div>
|
||||
</div>
|
||||
{#if timezoneInput}
|
||||
<div>
|
||||
<Combobox
|
||||
bind:selectedOption
|
||||
label={$t('timezone')}
|
||||
options={timezones}
|
||||
placeholder={$t('search_timezone')}
|
||||
onSelect={(option) => handleOnSelect(option)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col" style="display: {showRelative && currentInterval ? 'flex' : 'none'}">
|
||||
<span data-testid="interval-preview"
|
||||
>{$t('edit_date_and_time_by_offset_interval', { values: { from: intervalFrom, to: intervalTo } })}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue