mirror of
https://github.com/immich-app/immich.git
synced 2025-10-31 18:47:09 -04:00
fix: navigate to time action (#20928)
* fix: navigate to time action * change-date -> DateSelectionModal; use luxon; use handle* for callback fn name * refactor change-date dialogs * Review comments * chore: clean up --------- Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
parent
d0eae97037
commit
2919ee4c65
@ -1364,6 +1364,8 @@
|
|||||||
"my_albums": "My albums",
|
"my_albums": "My albums",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"name_or_nickname": "Name or nickname",
|
"name_or_nickname": "Name or nickname",
|
||||||
|
"navigate": "Navigate",
|
||||||
|
"navigate_to_time": "Navigate to Time",
|
||||||
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
||||||
"network_requirement_videos_upload": "Use cellular data to backup videos",
|
"network_requirement_videos_upload": "Use cellular data to backup videos",
|
||||||
"network_requirements": "Network Requirements",
|
"network_requirements": "Network Requirements",
|
||||||
@ -1373,6 +1375,7 @@
|
|||||||
"never": "Never",
|
"never": "Never",
|
||||||
"new_album": "New Album",
|
"new_album": "New Album",
|
||||||
"new_api_key": "New API Key",
|
"new_api_key": "New API Key",
|
||||||
|
"new_date_range": "New date range",
|
||||||
"new_password": "New password",
|
"new_password": "New password",
|
||||||
"new_person": "New person",
|
"new_person": "New person",
|
||||||
"new_pin_code": "New PIN code",
|
"new_pin_code": "New PIN code",
|
||||||
|
|||||||
@ -5,12 +5,9 @@
|
|||||||
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte';
|
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte';
|
||||||
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
|
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
|
||||||
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
|
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
|
||||||
import ChangeDate, {
|
|
||||||
type AbsoluteResult,
|
|
||||||
type RelativeResult,
|
|
||||||
} from '$lib/components/shared-components/change-date.svelte';
|
|
||||||
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
|
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
@ -19,12 +16,11 @@
|
|||||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { delay, getDimensions } from '$lib/utils/asset-utils';
|
import { delay, getDimensions } from '$lib/utils/asset-utils';
|
||||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||||
import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
|
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { getParentPath } from '$lib/utils/tree-utils';
|
import { getParentPath } from '$lib/utils/tree-utils';
|
||||||
import { AssetMediaSize, getAssetInfo, updateAsset, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
|
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiCalendar,
|
mdiCalendar,
|
||||||
mdiCameraIris,
|
mdiCameraIris,
|
||||||
@ -59,7 +55,7 @@
|
|||||||
let people = $derived(asset.people || []);
|
let people = $derived(asset.people || []);
|
||||||
let unassignedFaces = $derived(asset.unassignedFaces || []);
|
let unassignedFaces = $derived(asset.unassignedFaces || []);
|
||||||
let showingHiddenPeople = $state(false);
|
let showingHiddenPeople = $state(false);
|
||||||
let timeZone = $derived(asset.exifInfo?.timeZone);
|
let timeZone = $derived(asset.exifInfo?.timeZone ?? undefined);
|
||||||
let dateTime = $derived(
|
let dateTime = $derived(
|
||||||
timeZone && asset.exifInfo?.dateTimeOriginal
|
timeZone && asset.exifInfo?.dateTimeOriginal
|
||||||
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
|
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
|
||||||
@ -112,18 +108,13 @@
|
|||||||
|
|
||||||
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
|
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
|
||||||
|
|
||||||
let isShowChangeDate = $state(false);
|
const handleChangeDate = async () => {
|
||||||
|
if (!isOwner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleConfirmChangeDate(result: AbsoluteResult | RelativeResult) {
|
await modalManager.show(AssetChangeDateModal, { asset: toTimelineAsset(asset), initialDate: dateTime });
|
||||||
isShowChangeDate = false;
|
};
|
||||||
try {
|
|
||||||
if (result.mode === 'absolute') {
|
|
||||||
await updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal: result.date } });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_change_date'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="relative p-2">
|
<section class="relative p-2">
|
||||||
@ -280,7 +271,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
|
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
|
||||||
onclick={() => (isOwner ? (isShowChangeDate = true) : null)}
|
onclick={handleChangeDate}
|
||||||
title={isOwner ? $t('edit_date') : ''}
|
title={isOwner ? $t('edit_date') : ''}
|
||||||
class:hover:text-primary={isOwner}
|
class:hover:text-primary={isOwner}
|
||||||
>
|
>
|
||||||
@ -336,16 +327,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isShowChangeDate}
|
|
||||||
<ChangeDate
|
|
||||||
initialDate={dateTime}
|
|
||||||
initialTimeZone={timeZone ?? ''}
|
|
||||||
withDuration={false}
|
|
||||||
onConfirm={handleConfirmChangeDate}
|
|
||||||
onCancel={() => (isShowChangeDate = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex gap-4 py-4">
|
<div class="flex gap-4 py-4">
|
||||||
<div><Icon icon={mdiImageOutline} size="24" /></div>
|
<div><Icon icon={mdiImageOutline} size="24" /></div>
|
||||||
|
|
||||||
|
|||||||
@ -1,284 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import DateInput from '$lib/elements/DateInput.svelte';
|
|
||||||
import DurationInput from '$lib/elements/DurationInput.svelte';
|
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
|
||||||
import { getDateTimeOffsetLocaleString } from '$lib/utils/timeline-util.js';
|
|
||||||
import { ConfirmModal, Field, Switch } from '@immich/ui';
|
|
||||||
import { mdiCalendarEditOutline } from '@mdi/js';
|
|
||||||
import { DateTime, Duration } from 'luxon';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import Combobox, { type ComboBoxOption } from './combobox.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title?: string;
|
|
||||||
initialDate?: DateTime;
|
|
||||||
initialTimeZone?: string;
|
|
||||||
timezoneInput?: boolean;
|
|
||||||
withDuration?: boolean;
|
|
||||||
currentInterval?: { start: DateTime; end: DateTime };
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: (result: AbsoluteResult | RelativeResult) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
initialDate = DateTime.now(),
|
|
||||||
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
|
|
||||||
*
|
|
||||||
* e.g. Asia/Jerusalem (+03:00)
|
|
||||||
*/
|
|
||||||
label: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Timezone name
|
|
||||||
*
|
|
||||||
* e.g. Asia/Jerusalem
|
|
||||||
*/
|
|
||||||
value: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Timezone offset in minutes
|
|
||||||
*
|
|
||||||
* e.g. 300
|
|
||||||
*/
|
|
||||||
offsetMinutes: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True iff the date is valid
|
|
||||||
*
|
|
||||||
* Dates may be invalid for various reasons, for example setting a day that does not exist (30 Feb 2024).
|
|
||||||
* Due to daylight saving time, 2:30am is invalid for Europe/Berlin on Mar 31 2024.The two following local times
|
|
||||||
* are one second apart:
|
|
||||||
*
|
|
||||||
* - Mar 31 2024 01:59:59 (GMT+0100, unix timestamp 1725058799)
|
|
||||||
* - Mar 31 2024 03:00:00 (GMT+0200, unix timestamp 1711846800)
|
|
||||||
*
|
|
||||||
* Mar 31 2024 02:30:00 does not exist in Europe/Berlin, this is an invalid date/time/time zone combination.
|
|
||||||
*/
|
|
||||||
valid: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
let showRelative = $state(false);
|
|
||||||
|
|
||||||
let selectedDuration = $state(0);
|
|
||||||
|
|
||||||
const knownTimezones = Intl.supportedValuesOf('timeZone');
|
|
||||||
|
|
||||||
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
||||||
|
|
||||||
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
|
|
||||||
// Use a fixed modern date to calculate stable timezone offsets for the list
|
|
||||||
// This ensures that the offsets shown in the combobox are always current,
|
|
||||||
// regardless of the historical date selected by the user.
|
|
||||||
let timezones: ZoneOption[] = knownTimezones
|
|
||||||
.map((zone) => zoneOptionForDate(zone, selectedDate))
|
|
||||||
.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 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);
|
|
||||||
// For validity, we still need to check if the exact date/time exists in the *original* timezone (for gaps/overlaps).
|
|
||||||
const dateForValidity = DateTime.fromISO(date, { zone });
|
|
||||||
const valid = dateForValidity.isValid && date === dateForValidity.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
|
|
||||||
return {
|
|
||||||
value: zone,
|
|
||||||
offsetMinutes,
|
|
||||||
label: zone + ' (' + zoneOffsetAtDate + ')' + (valid ? '' : ' [invalid date!]'),
|
|
||||||
valid,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* If the time zone is not given, find the timezone to select for a given time, date, and offset (e.g. +02:00).
|
|
||||||
*
|
|
||||||
* This is done so that the list shown to the user includes more helpful names like "Europe/Berlin (+02:00)"
|
|
||||||
* instead of just the raw offset or something like "UTC+02:00".
|
|
||||||
*
|
|
||||||
* The provided information (initialDate, from some asset) includes the offset (e.g. +02:00), but no information about
|
|
||||||
* the actual time zone. As several countries/regions may share the same offset, for example Berlin (Germany) and
|
|
||||||
* Blantyre (Malawi) sharing +02:00 in summer, we have to guess and somehow pick a suitable time zone.
|
|
||||||
*
|
|
||||||
* If the time zone configured by the user (in the browser) provides the same offset for the given date (accounting
|
|
||||||
* for daylight saving time and other weirdness), we prefer to show it. This way, for German users, we might be able
|
|
||||||
* to show "Europe/Berlin" instead of the lexicographically first entry "Africa/Blantyre".
|
|
||||||
*/
|
|
||||||
function getPreferredTimeZone(
|
|
||||||
userTimeZone: string,
|
|
||||||
timezones: ZoneOption[],
|
|
||||||
date?: DateTime,
|
|
||||||
selectedOption?: ZoneOption,
|
|
||||||
) {
|
|
||||||
const offset = date?.offset;
|
|
||||||
const previousSelection = timezones.find((item) => item.value === selectedOption?.value);
|
|
||||||
const fromInitialTimeZone = timezones.find((item) => item.value === initialTimeZone);
|
|
||||||
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,
|
|
||||||
value: 'UTC',
|
|
||||||
valid: true,
|
|
||||||
};
|
|
||||||
return previousSelection ?? fromInitialTimeZone ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModernOffsetForZoneAndDate(
|
|
||||||
zone: string,
|
|
||||||
dateString: string,
|
|
||||||
): { offsetMinutes: number; offsetFormat: string } {
|
|
||||||
const dt = DateTime.fromISO(dateString, { zone });
|
|
||||||
|
|
||||||
// we determine the *modern* offset for this zone based on its current rules.
|
|
||||||
// To do this, we "move" the date to the current year, keeping the local time components.
|
|
||||||
// This allows Luxon to apply current-year DST rules.
|
|
||||||
const modernYearDt = dt.set({ year: DateTime.now().year });
|
|
||||||
|
|
||||||
// Calculate the offset at that modern year's date.
|
|
||||||
const modernOffsetMinutes = modernYearDt.setZone(zone, { keepLocalTime: true }).offset;
|
|
||||||
const modernOffsetFormat = modernYearDt.setZone(zone, { keepLocalTime: true }).toFormat('ZZ');
|
|
||||||
|
|
||||||
return { offsetMinutes: modernOffsetMinutes, offsetFormat: modernOffsetFormat };
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) {
|
|
||||||
let offsetDifference = zoneA.offsetMinutes - zoneB.offsetMinutes;
|
|
||||||
if (offsetDifference != 0) {
|
|
||||||
return offsetDifference;
|
|
||||||
}
|
|
||||||
return zoneA.value.localeCompare(zoneB.value, undefined, { sensitivity: 'base' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
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(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')}`;
|
|
||||||
|
|
||||||
// Create a DateTime object in this fixed-offset zone, preserving the local time.
|
|
||||||
const finalDateTime = DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone });
|
|
||||||
|
|
||||||
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 (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: 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
|
|
||||||
confirmColor="primary"
|
|
||||||
{title}
|
|
||||||
icon={mdiCalendarEditOutline}
|
|
||||||
prompt="Please select a new date:"
|
|
||||||
disabled={!date.isValid}
|
|
||||||
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
|
||||||
>
|
|
||||||
{#snippet promptSnippet()}
|
|
||||||
{#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}
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
@ -1,18 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ChangeDate, {
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
type AbsoluteResult,
|
|
||||||
type RelativeResult,
|
|
||||||
} from '$lib/components/shared-components/change-date.svelte';
|
|
||||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import AssetSelectionChangeDateModal from '$lib/modals/AssetSelectionChangeDateModal.svelte';
|
||||||
import { getSelectedAssets } from '$lib/utils/asset-utils';
|
import { modalManager } from '@immich/ui';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util.js';
|
|
||||||
import { updateAssets } from '@immich/sdk';
|
|
||||||
import { mdiCalendarEditOutline } from '@mdi/js';
|
import { mdiCalendarEditOutline } from '@mdi/js';
|
||||||
import { DateTime, Duration } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
|
||||||
interface Props {
|
interface Props {
|
||||||
menuItem?: boolean;
|
menuItem?: boolean;
|
||||||
}
|
}
|
||||||
@ -20,66 +13,17 @@
|
|||||||
let { menuItem = false }: Props = $props();
|
let { menuItem = false }: Props = $props();
|
||||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||||
|
|
||||||
let isShowChangeDate = $state(false);
|
const handleChangeDate = async () => {
|
||||||
|
const success = await modalManager.show(AssetSelectionChangeDateModal, {
|
||||||
let currentInterval = $derived.by(() => {
|
initialDate: DateTime.now(),
|
||||||
if (isShowChangeDate) {
|
assets: getOwnedAssets(),
|
||||||
const ids = getSelectedAssets(getOwnedAssets(), $user);
|
|
||||||
const assets = getOwnedAssets().filter((asset) => ids.includes(asset.id));
|
|
||||||
const imageTimestamps = assets.map((asset) => {
|
|
||||||
let localDateTime = fromTimelinePlainDateTime(asset.localDateTime);
|
|
||||||
let fileCreatedAt = fromTimelinePlainDateTime(asset.fileCreatedAt);
|
|
||||||
let offsetMinutes = localDateTime.diff(fileCreatedAt, 'minutes').shiftTo('minutes').minutes;
|
|
||||||
const timeZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`;
|
|
||||||
return fileCreatedAt.setZone('utc', { keepLocalTime: true }).setZone(timeZone);
|
|
||||||
});
|
});
|
||||||
let minTimestamp = imageTimestamps[0];
|
if (success) {
|
||||||
let maxTimestamp = imageTimestamps[0];
|
|
||||||
for (let current of imageTimestamps) {
|
|
||||||
if (current < minTimestamp) {
|
|
||||||
minTimestamp = current;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current > maxTimestamp) {
|
|
||||||
maxTimestamp = current;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { start: minTimestamp, end: maxTimestamp };
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleConfirm = async (result: AbsoluteResult | RelativeResult) => {
|
|
||||||
isShowChangeDate = false;
|
|
||||||
const ids = getSelectedAssets(getOwnedAssets(), $user);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (result.mode === 'absolute') {
|
|
||||||
await updateAssets({ assetBulkUpdateDto: { ids, dateTimeOriginal: result.date } });
|
|
||||||
} else if (result.mode === 'relative') {
|
|
||||||
await updateAssets({
|
|
||||||
assetBulkUpdateDto: {
|
|
||||||
ids,
|
|
||||||
dateTimeRelative: result.duration,
|
|
||||||
timeZone: result.timeZone,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_change_date'));
|
|
||||||
}
|
|
||||||
clearSelect();
|
clearSelect();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if menuItem}
|
{#if menuItem}
|
||||||
<MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={() => (isShowChangeDate = true)} />
|
<MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={handleChangeDate} />
|
||||||
{/if}
|
|
||||||
{#if isShowChangeDate}
|
|
||||||
<ChangeDate
|
|
||||||
initialDate={DateTime.now()}
|
|
||||||
{currentInterval}
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={() => (isShowChangeDate = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||||
import AssetUpdateDescriptionConfirmModal from '$lib/modals/AssetUpdateDescriptionConfirmModal.svelte';
|
import AssetUpdateDescriptionConfirmModal from '$lib/modals/AssetUpdateDescriptionConfirmModal.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { getSelectedAssets } from '$lib/utils/asset-utils';
|
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { updateAssets } from '@immich/sdk';
|
import { updateAssets } from '@immich/sdk';
|
||||||
import { modalManager } from '@immich/ui';
|
import { modalManager } from '@immich/ui';
|
||||||
@ -20,7 +20,7 @@
|
|||||||
const handleUpdateDescription = async () => {
|
const handleUpdateDescription = async () => {
|
||||||
const description = await modalManager.show(AssetUpdateDescriptionConfirmModal);
|
const description = await modalManager.show(AssetUpdateDescriptionConfirmModal);
|
||||||
if (description) {
|
if (description) {
|
||||||
const ids = getSelectedAssets(getOwnedAssets(), $user);
|
const ids = getOwnedAssetsWithWarning(getOwnedAssets(), $user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateAssets({ assetBulkUpdateDto: { ids, description } });
|
await updateAssets({ assetBulkUpdateDto: { ids, description } });
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
|
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
|
||||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { getSelectedAssets } from '$lib/utils/asset-utils';
|
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { updateAssets } from '@immich/sdk';
|
import { updateAssets } from '@immich/sdk';
|
||||||
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
|
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
|
||||||
@ -25,7 +25,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ids = getSelectedAssets(getOwnedAssets(), $user);
|
const ids = getOwnedAssetsWithWarning(getOwnedAssets(), $user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } });
|
await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } });
|
||||||
|
|||||||
@ -2,10 +2,6 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||||
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
||||||
import ChangeDate, {
|
|
||||||
type AbsoluteResult,
|
|
||||||
type RelativeResult,
|
|
||||||
} from '$lib/components/shared-components/change-date.svelte';
|
|
||||||
import {
|
import {
|
||||||
setFocusToAsset as setFocusAssetInit,
|
setFocusToAsset as setFocusAssetInit,
|
||||||
setFocusTo as setFocusToInit,
|
setFocusTo as setFocusToInit,
|
||||||
@ -13,6 +9,7 @@
|
|||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
|
import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte';
|
||||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
@ -24,8 +21,6 @@
|
|||||||
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
||||||
import { AssetVisibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
import { modalManager } from '@immich/ui';
|
import { modalManager } from '@immich/ui';
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
timelineManager: TimelineManager;
|
timelineManager: TimelineManager;
|
||||||
@ -43,7 +38,7 @@
|
|||||||
scrollToAsset,
|
scrollToAsset,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let isShowSelectDate = $state(false);
|
const { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
|
|
||||||
const trashOrDelete = async (force: boolean = false) => {
|
const trashOrDelete = async (force: boolean = false) => {
|
||||||
isShowDeleteConfirmation = false;
|
isShowDeleteConfirmation = false;
|
||||||
@ -150,6 +145,13 @@
|
|||||||
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
|
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
|
||||||
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
|
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
|
||||||
|
|
||||||
|
const handleOpenDateModal = async () => {
|
||||||
|
const asset = await modalManager.show(NavigateToDateModal, { timelineManager });
|
||||||
|
if (asset) {
|
||||||
|
setFocusAsset(asset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let shortcutList = $derived(
|
let shortcutList = $derived(
|
||||||
(() => {
|
(() => {
|
||||||
if (searchStore.isSearchEnabled || $showAssetViewer) {
|
if (searchStore.isSearchEnabled || $showAssetViewer) {
|
||||||
@ -168,7 +170,7 @@
|
|||||||
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
|
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
|
||||||
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
|
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
|
||||||
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
|
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
|
||||||
{ shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) },
|
{ shortcut: { key: 'G' }, onShortcut: handleOpenDateModal },
|
||||||
];
|
];
|
||||||
if (onEscape) {
|
if (onEscape) {
|
||||||
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
|
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
|
||||||
@ -198,24 +200,3 @@
|
|||||||
onConfirm={() => handlePromiseError(trashOrDelete(true))}
|
onConfirm={() => handlePromiseError(trashOrDelete(true))}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isShowSelectDate}
|
|
||||||
<ChangeDate
|
|
||||||
withDuration={false}
|
|
||||||
title="Navigate to Time"
|
|
||||||
initialDate={DateTime.now()}
|
|
||||||
timezoneInput={false}
|
|
||||||
onConfirm={async (dateString: AbsoluteResult | RelativeResult) => {
|
|
||||||
isShowSelectDate = false;
|
|
||||||
if (dateString.mode == 'absolute') {
|
|
||||||
const asset = await timelineManager.getClosestAssetToDate(
|
|
||||||
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
|
|
||||||
);
|
|
||||||
if (asset) {
|
|
||||||
setFocusAsset(asset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCancel={() => (isShowSelectDate = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
interface Props extends HTMLInputAttributes {
|
||||||
type: 'date' | 'datetime-local';
|
type: 'date' | 'datetime-local';
|
||||||
value?: string;
|
value?: string;
|
||||||
min?: string;
|
min?: string;
|
||||||
|
|||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { MonthGroup } from '../month-group.svelte';
|
||||||
|
import { findClosestGroupForDate } from './search-support.svelte';
|
||||||
|
|
||||||
|
function createMockMonthGroup(year: number, month: number): MonthGroup {
|
||||||
|
return {
|
||||||
|
yearMonth: { year, month },
|
||||||
|
} as MonthGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('findClosestGroupForDate', () => {
|
||||||
|
it('should return undefined for empty months array', () => {
|
||||||
|
const result = findClosestGroupForDate([], { year: 2024, month: 1 });
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the only month when there is only one month', () => {
|
||||||
|
const months = [createMockMonthGroup(2024, 6)];
|
||||||
|
const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
|
||||||
|
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return exact match when available', () => {
|
||||||
|
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
|
||||||
|
const result = findClosestGroupForDate(months, { year: 2024, month: 6 });
|
||||||
|
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find closest month when target is between two months', () => {
|
||||||
|
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
|
||||||
|
const result = findClosestGroupForDate(months, { year: 2024, month: 4 });
|
||||||
|
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle year boundaries correctly (2023-12 vs 2024-01)', () => {
|
||||||
|
const months = [createMockMonthGroup(2023, 12), createMockMonthGroup(2024, 2)];
|
||||||
|
const result = findClosestGroupForDate(months, { year: 2024, month: 1 });
|
||||||
|
// 2024-01 is 1 month from 2023-12 and 1 month from 2024-02
|
||||||
|
// Should return first encountered with min distance (2023-12)
|
||||||
|
expect(result?.yearMonth).toEqual({ year: 2023, month: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly calculate distance across years', () => {
|
||||||
|
const months = [createMockMonthGroup(2022, 6), createMockMonthGroup(2024, 6)];
|
||||||
|
const result = findClosestGroupForDate(months, { year: 2023, month: 6 });
|
||||||
|
// Both are exactly 12 months away, should return first encountered
|
||||||
|
expect(result?.yearMonth).toEqual({ year: 2022, month: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle target before all months', () => {
|
||||||
|
const months = [createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
|
||||||
|
const result = findClosestGroupForDate(months, { year: 2024, month: 1 });
|
||||||
|
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle target after all months', () => {
|
||||||
|
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6)];
|
||||||
|
const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
|
||||||
|
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple years correctly', () => {
|
||||||
|
const months = [createMockMonthGroup(2020, 1), createMockMonthGroup(2022, 1), createMockMonthGroup(2024, 1)];
|
||||||
|
const result = findClosestGroupForDate(months, { year: 2023, month: 1 });
|
||||||
|
// 2023-01 is 12 months from 2022-01 and 12 months from 2024-01
|
||||||
|
expect(result?.yearMonth).toEqual({ year: 2022, month: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer closer month when one is clearly closer', () => {
|
||||||
|
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 10)];
|
||||||
|
const result = findClosestGroupForDate(months, { year: 2024, month: 11 });
|
||||||
|
// 2024-11 is 1 month from 2024-10 and 10 months from 2024-01
|
||||||
|
expect(result?.yearMonth).toEqual({ year: 2024, month: 10 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||||
import { AssetOrder } from '@immich/sdk';
|
import { AssetOrder } from '@immich/sdk';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import type { MonthGroup } from '../month-group.svelte';
|
import type { MonthGroup } from '../month-group.svelte';
|
||||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||||
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
|
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
|
||||||
@ -143,3 +144,22 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findClosestGroupForDate(months: MonthGroup[], targetYearMonth: TimelineYearMonth) {
|
||||||
|
const targetDate = DateTime.fromObject({ year: targetYearMonth.year, month: targetYearMonth.month });
|
||||||
|
|
||||||
|
let closestMonth: MonthGroup | undefined;
|
||||||
|
let minDifference = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
for (const month of months) {
|
||||||
|
const monthDate = DateTime.fromObject({ year: month.yearMonth.year, month: month.yearMonth.month });
|
||||||
|
const totalDiff = Math.abs(monthDate.diff(targetDate, 'months').months);
|
||||||
|
|
||||||
|
if (totalDiff < minDifference) {
|
||||||
|
minDifference = totalDiff;
|
||||||
|
closestMonth = month;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closestMonth;
|
||||||
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
runAssetOperation,
|
runAssetOperation,
|
||||||
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
|
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
|
||||||
import {
|
import {
|
||||||
|
findClosestGroupForDate,
|
||||||
findMonthGroupForAsset as findMonthGroupForAssetUtil,
|
findMonthGroupForAsset as findMonthGroupForAssetUtil,
|
||||||
findMonthGroupForDate,
|
findMonthGroupForDate,
|
||||||
getAssetWithOffset,
|
getAssetWithOffset,
|
||||||
@ -584,10 +585,14 @@ export class TimelineManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getClosestAssetToDate(dateTime: TimelineDateTime) {
|
async getClosestAssetToDate(dateTime: TimelineDateTime) {
|
||||||
const monthGroup = findMonthGroupForDate(this, dateTime);
|
let monthGroup = findMonthGroupForDate(this, dateTime);
|
||||||
|
if (!monthGroup) {
|
||||||
|
// if exact match not found, find closest
|
||||||
|
monthGroup = findClosestGroupForDate(this.months, dateTime);
|
||||||
if (!monthGroup) {
|
if (!monthGroup) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
await this.loadMonthGroup(dateTime, { cancelable: false });
|
await this.loadMonthGroup(dateTime, { cancelable: false });
|
||||||
const asset = monthGroup.findClosest(dateTime);
|
const asset = monthGroup.findClosest(dateTime);
|
||||||
if (asset) {
|
if (asset) {
|
||||||
|
|||||||
84
web/src/lib/modals/AssetChangeDateModal.svelte
Normal file
84
web/src/lib/modals/AssetChangeDateModal.svelte
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Combobox from '$lib/components/shared-components/combobox.svelte';
|
||||||
|
import DateInput from '$lib/elements/DateInput.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { getPreferredTimeZone, getTimezones, toIsoDate } from '$lib/modals/timezone-utils';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { updateAsset } from '@immich/sdk';
|
||||||
|
import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui';
|
||||||
|
import { mdiCalendarEdit } from '@mdi/js';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialDate?: DateTime;
|
||||||
|
initialTimeZone?: string;
|
||||||
|
timezoneInput?: boolean;
|
||||||
|
asset: TimelineAsset;
|
||||||
|
onClose: (success: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { initialDate = DateTime.now(), initialTimeZone, timezoneInput = true, asset, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
|
||||||
|
const timezones = $derived(getTimezones(selectedDate));
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let lastSelectedTimezone = $state(getPreferredTimeZone(initialDate, initialTimeZone, timezones));
|
||||||
|
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
|
||||||
|
let selectedOption = $derived(getPreferredTimeZone(initialDate, initialTimeZone, timezones, lastSelectedTimezone));
|
||||||
|
|
||||||
|
const handleClose = async () => {
|
||||||
|
if (!date.isValid || !selectedOption) {
|
||||||
|
onClose(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the local date/time components from the selected string using neutral timezone
|
||||||
|
const isoDate = toIsoDate(selectedDate, selectedOption);
|
||||||
|
try {
|
||||||
|
await updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal: isoDate } });
|
||||||
|
onClose(true);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.unable_to_change_date'));
|
||||||
|
onClose(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
|
||||||
|
const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title={$t('edit_date_and_time')} icon={mdiCalendarEdit} onClose={() => onClose(false)} size="small">
|
||||||
|
<ModalBody>
|
||||||
|
<VStack fullWidth>
|
||||||
|
<HStack fullWidth>
|
||||||
|
<label class="immich-form-label" for="datetime">{$t('date_and_time')}</label>
|
||||||
|
</HStack>
|
||||||
|
<HStack fullWidth>
|
||||||
|
<DateInput
|
||||||
|
class="immich-form-input text-gray-700 w-full"
|
||||||
|
id="datetime"
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={selectedDate}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
{#if timezoneInput}
|
||||||
|
<div class="w-full">
|
||||||
|
<Combobox
|
||||||
|
bind:selectedOption
|
||||||
|
label={$t('timezone')}
|
||||||
|
options={timezones}
|
||||||
|
placeholder={$t('search_timezone')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</VStack>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<HStack fullWidth>
|
||||||
|
<Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}>{$t('cancel')}</Button>
|
||||||
|
<Button shape="round" type="submit" fullWidth onclick={handleClose}>{$t('confirm')}</Button>
|
||||||
|
</HStack>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
@ -1,33 +1,30 @@
|
|||||||
|
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
|
||||||
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
||||||
|
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||||
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
|
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
|
||||||
|
import { calcNewDate } from '$lib/modals/timezone-utils';
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import ChangeDate from './change-date.svelte';
|
import AssetSelectionChangeDateModal from './AssetSelectionChangeDateModal.svelte';
|
||||||
|
|
||||||
describe('ChangeDate component', () => {
|
describe('DateSelectionModal component', () => {
|
||||||
const initialDate = DateTime.fromISO('2024-01-01');
|
const initialDate = DateTime.fromISO('2024-01-01');
|
||||||
const initialTimeZone = 'Europe/Berlin';
|
const initialTimeZone = 'Europe/Berlin';
|
||||||
const currentInterval = {
|
|
||||||
start: DateTime.fromISO('2000-02-01T14:00:00+01:00'),
|
const onClose = vi.fn();
|
||||||
end: DateTime.fromISO('2001-02-01T14:00:00+01:00'),
|
|
||||||
};
|
|
||||||
const onCancel = vi.fn();
|
|
||||||
const onConfirm = vi.fn();
|
|
||||||
|
|
||||||
const getRelativeInputToggle = () => screen.getByTestId('edit-by-offset-switch');
|
const getRelativeInputToggle = () => screen.getByTestId('edit-by-offset-switch');
|
||||||
const getDateInput = () => screen.getByLabelText('date_and_time') as HTMLInputElement;
|
const getDateInput = () => screen.getByLabelText('date_and_time') as HTMLInputElement;
|
||||||
const getTimeZoneInput = () => screen.getByLabelText('timezone') as HTMLInputElement;
|
const getTimeZoneInput = () => screen.getByLabelText('timezone') as HTMLInputElement;
|
||||||
const getCancelButton = () => screen.getByText('Cancel');
|
const getCancelButton = () => screen.getByText('cancel');
|
||||||
const getConfirmButton = () => screen.getByText('Confirm');
|
const getConfirmButton = () => screen.getByText('confirm');
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
||||||
vi.stubGlobal('visualViewport', getVisualViewportMock());
|
vi.stubGlobal('visualViewport', getVisualViewportMock());
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
Element.prototype.animate = getAnimateMock();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -38,54 +35,75 @@ describe('ChangeDate component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should render correct values', () => {
|
test('should render correct values', () => {
|
||||||
render(ChangeDate, { initialDate, initialTimeZone, onCancel, onConfirm });
|
render(AssetSelectionChangeDateModal, {
|
||||||
|
initialDate,
|
||||||
|
initialTimeZone,
|
||||||
|
assets: [],
|
||||||
|
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
expect(getDateInput().value).toBe('2024-01-01T00:00');
|
expect(getDateInput().value).toBe('2024-01-01T00:00');
|
||||||
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
|
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('calls onConfirm with correct date on confirm', async () => {
|
test('calls onConfirm with correct date on confirm', async () => {
|
||||||
render(ChangeDate, {
|
render(AssetSelectionChangeDateModal, {
|
||||||
props: { initialDate, initialTimeZone, onCancel, onConfirm },
|
props: { initialDate, initialTimeZone, assets: [], onClose },
|
||||||
});
|
});
|
||||||
|
|
||||||
await fireEvent.click(getConfirmButton());
|
await fireEvent.click(getConfirmButton());
|
||||||
|
|
||||||
expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-01-01T00:00:00.000+01:00' });
|
expect(sdkMock.updateAssets).toHaveBeenCalledWith({
|
||||||
|
assetBulkUpdateDto: {
|
||||||
|
ids: [],
|
||||||
|
dateTimeOriginal: '2024-01-01T00:00:00.000+01:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('calls onCancel on cancel', async () => {
|
test('calls onCancel on cancel', async () => {
|
||||||
render(ChangeDate, {
|
render(AssetSelectionChangeDateModal, {
|
||||||
props: { initialDate, initialTimeZone, onCancel, onConfirm },
|
props: { initialDate, initialTimeZone, assets: [], onClose },
|
||||||
});
|
});
|
||||||
|
|
||||||
await fireEvent.click(getCancelButton());
|
await fireEvent.click(getCancelButton());
|
||||||
|
|
||||||
expect(onCancel).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when date is in daylight saving time', () => {
|
describe('when date is in daylight saving time', () => {
|
||||||
const dstDate = DateTime.fromISO('2024-07-01');
|
const dstDate = DateTime.fromISO('2024-07-01');
|
||||||
|
|
||||||
test('should render correct timezone with offset', () => {
|
test('should render correct timezone with offset', () => {
|
||||||
render(ChangeDate, { initialDate: dstDate, initialTimeZone, onCancel, onConfirm });
|
render(AssetSelectionChangeDateModal, {
|
||||||
|
initialDate: dstDate,
|
||||||
|
initialTimeZone,
|
||||||
|
assets: [],
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+02:00)');
|
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+02:00)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('calls onConfirm with correct date on confirm', async () => {
|
test('calls onConfirm with correct date on confirm', async () => {
|
||||||
render(ChangeDate, {
|
render(AssetSelectionChangeDateModal, {
|
||||||
props: { initialDate: dstDate, initialTimeZone, onCancel, onConfirm },
|
props: { initialDate: dstDate, initialTimeZone, assets: [], onClose },
|
||||||
});
|
});
|
||||||
|
|
||||||
await fireEvent.click(getConfirmButton());
|
await fireEvent.click(getConfirmButton());
|
||||||
|
|
||||||
expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-07-01T00:00:00.000+02:00' });
|
expect(sdkMock.updateAssets).toHaveBeenCalledWith({
|
||||||
|
assetBulkUpdateDto: {
|
||||||
|
ids: [],
|
||||||
|
dateTimeOriginal: '2024-07-01T00:00:00.000+02:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('calls onConfirm with correct offset in relative mode', async () => {
|
test('calls onConfirm with correct offset in relative mode', async () => {
|
||||||
render(ChangeDate, {
|
render(AssetSelectionChangeDateModal, {
|
||||||
props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm },
|
props: { initialDate, initialTimeZone, assets: [], onClose },
|
||||||
});
|
});
|
||||||
|
|
||||||
await fireEvent.click(getRelativeInputToggle());
|
await fireEvent.click(getRelativeInputToggle());
|
||||||
@ -104,17 +122,19 @@ describe('ChangeDate component', () => {
|
|||||||
|
|
||||||
await fireEvent.click(getConfirmButton());
|
await fireEvent.click(getConfirmButton());
|
||||||
|
|
||||||
expect(onConfirm).toHaveBeenCalledWith({
|
expect(sdkMock.updateAssets).toHaveBeenCalledWith({
|
||||||
mode: 'relative',
|
assetBulkUpdateDto: {
|
||||||
duration: days * 60 * 24 + hours * 60 + minutes,
|
ids: [],
|
||||||
timeZone: undefined,
|
dateTimeRelative: days * 60 * 24 + hours * 60 + minutes,
|
||||||
|
timeZone: 'Europe/Berlin',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('calls onConfirm with correct timeZone in relative mode', async () => {
|
test('calls onConfirm with correct timeZone in relative mode', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(ChangeDate, {
|
render(AssetSelectionChangeDateModal, {
|
||||||
props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm },
|
props: { initialDate, initialTimeZone, assets: [], onClose },
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(getRelativeInputToggle());
|
await user.click(getRelativeInputToggle());
|
||||||
@ -123,10 +143,13 @@ describe('ChangeDate component', () => {
|
|||||||
await user.keyboard('{Enter}');
|
await user.keyboard('{Enter}');
|
||||||
|
|
||||||
await user.click(getConfirmButton());
|
await user.click(getConfirmButton());
|
||||||
expect(onConfirm).toHaveBeenCalledWith({
|
|
||||||
mode: 'relative',
|
expect(sdkMock.updateAssets).toHaveBeenCalledWith({
|
||||||
duration: 0,
|
assetBulkUpdateDto: {
|
||||||
timeZone: initialTimeZone,
|
ids: [],
|
||||||
|
dateTimeRelative: 0,
|
||||||
|
timeZone: 'Europe/Berlin',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -136,55 +159,50 @@ describe('ChangeDate component', () => {
|
|||||||
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
|
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
|
||||||
duration: 0,
|
duration: 0,
|
||||||
timezone: undefined,
|
timezone: undefined,
|
||||||
expectedResult: 'Jan 1, 2024, 12:00 AM GMT+01:00',
|
expectedResult: '2024-01-01T00:00:00.000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamp: DateTime.fromISO('2024-01-01T04:00:00.000+05:00', { setZone: true }),
|
timestamp: DateTime.fromISO('2024-01-01T04:00:00.000+05:00', { setZone: true }),
|
||||||
duration: 0,
|
duration: 0,
|
||||||
timezone: undefined,
|
timezone: undefined,
|
||||||
expectedResult: 'Jan 1, 2024, 4:00 AM GMT+05:00',
|
expectedResult: '2024-01-01T04:00:00.000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+00:00', { setZone: true }),
|
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+00:00', { setZone: true }),
|
||||||
duration: 0,
|
duration: 0,
|
||||||
timezone: 'Europe/Berlin',
|
timezone: 'Europe/Berlin',
|
||||||
expectedResult: 'Jan 1, 2024, 1:00 AM GMT+01:00',
|
expectedResult: '2024-01-01T01:00:00.000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamp: DateTime.fromISO('2024-07-01T00:00:00.000+00:00', { setZone: true }),
|
timestamp: DateTime.fromISO('2024-07-01T00:00:00.000+00:00', { setZone: true }),
|
||||||
duration: 0,
|
duration: 0,
|
||||||
timezone: 'Europe/Berlin',
|
timezone: 'Europe/Berlin',
|
||||||
expectedResult: 'Jul 1, 2024, 2:00 AM GMT+02:00',
|
expectedResult: '2024-07-01T02:00:00.000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
|
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
|
||||||
duration: 1440,
|
duration: 1440,
|
||||||
timezone: undefined,
|
timezone: undefined,
|
||||||
expectedResult: 'Jan 2, 2024, 12:00 AM GMT+01:00',
|
expectedResult: '2024-01-02T00:00:00.000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
|
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
|
||||||
duration: -1440,
|
duration: -1440,
|
||||||
timezone: undefined,
|
timezone: undefined,
|
||||||
expectedResult: 'Dec 31, 2023, 12:00 AM GMT+01:00',
|
expectedResult: '2023-12-31T00:00:00.000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000-01:00', { setZone: true }),
|
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000-01:00', { setZone: true }),
|
||||||
duration: -1440,
|
duration: -1440,
|
||||||
timezone: 'America/Anchorage',
|
timezone: 'America/Anchorage',
|
||||||
expectedResult: 'Dec 30, 2023, 4:00 PM GMT-09:00',
|
expectedResult: '2023-12-30T16:00:00.000',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const component = render(ChangeDate, {
|
|
||||||
props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const testCase of testCases) {
|
for (const testCase of testCases) {
|
||||||
expect(
|
expect(calcNewDate(testCase.timestamp, testCase.duration, testCase.timezone), JSON.stringify(testCase)).toBe(
|
||||||
component.component.calcNewDate(testCase.timestamp, testCase.duration, testCase.timezone),
|
testCase.expectedResult,
|
||||||
JSON.stringify(testCase),
|
);
|
||||||
).toBe(testCase.expectedResult);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
136
web/src/lib/modals/AssetSelectionChangeDateModal.svelte
Normal file
136
web/src/lib/modals/AssetSelectionChangeDateModal.svelte
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Combobox from '$lib/components/shared-components/combobox.svelte';
|
||||||
|
import DateInput from '$lib/elements/DateInput.svelte';
|
||||||
|
import DurationInput from '$lib/elements/DurationInput.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { getPreferredTimeZone, getTimezones, toIsoDate, type ZoneOption } from '$lib/modals/timezone-utils';
|
||||||
|
import { user } from '$lib/stores/user.store';
|
||||||
|
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { updateAssets } from '@immich/sdk';
|
||||||
|
import { Button, Field, HStack, Modal, ModalBody, ModalFooter, Switch, VStack } from '@immich/ui';
|
||||||
|
import { mdiCalendarEdit } from '@mdi/js';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialDate?: DateTime;
|
||||||
|
initialTimeZone?: string;
|
||||||
|
assets: TimelineAsset[];
|
||||||
|
onClose: (success: boolean) => void;
|
||||||
|
}
|
||||||
|
let { initialDate = DateTime.now(), initialTimeZone, assets, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let showRelative = $state(false);
|
||||||
|
let selectedDuration = $state(0);
|
||||||
|
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
|
||||||
|
const timezones = $derived(getTimezones(selectedDate));
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let lastSelectedTimezone = $state(getPreferredTimeZone(initialDate, initialTimeZone, timezones));
|
||||||
|
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
|
||||||
|
let selectedOption = $derived(getPreferredTimeZone(initialDate, initialTimeZone, timezones, lastSelectedTimezone));
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
const ids = getOwnedAssetsWithWarning(assets, $user);
|
||||||
|
try {
|
||||||
|
if (showRelative && (selectedDuration || selectedOption)) {
|
||||||
|
await updateAssets({
|
||||||
|
assetBulkUpdateDto: {
|
||||||
|
ids,
|
||||||
|
dateTimeRelative: selectedDuration,
|
||||||
|
timeZone: selectedOption?.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onClose(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isoDate = toIsoDate(selectedDate, selectedOption);
|
||||||
|
await updateAssets({ assetBulkUpdateDto: { ids, dateTimeOriginal: isoDate } });
|
||||||
|
onClose(true);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.unable_to_change_date'));
|
||||||
|
onClose(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// let before = $derived(DateTime.fromObject(assets[0].localDateTime).toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
|
||||||
|
|
||||||
|
// let after = $derived(
|
||||||
|
// currentInterval ? calcNewDate(currentInterval.end, selectedDuration, selectedOption?.value) : undefined,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
|
||||||
|
const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title={$t('edit_date_and_time')} icon={mdiCalendarEdit} onClose={() => onClose(false)} size="small">
|
||||||
|
<ModalBody>
|
||||||
|
<VStack fullWidth>
|
||||||
|
<HStack fullWidth>
|
||||||
|
<Field label={$t('edit_date_and_time_by_offset')}>
|
||||||
|
<Switch data-testid="edit-by-offset-switch" bind:checked={showRelative} />
|
||||||
|
</Field>
|
||||||
|
</HStack>
|
||||||
|
{#if showRelative}
|
||||||
|
<HStack fullWidth>
|
||||||
|
<label class="immich-form-label" for="relativedatetime">{$t('offset')}</label>
|
||||||
|
</HStack>
|
||||||
|
<HStack fullWidth>
|
||||||
|
<DurationInput class="immich-form-input text-gray-700" id="relativedatetime" bind:value={selectedDuration} />
|
||||||
|
</HStack>
|
||||||
|
{:else}
|
||||||
|
<HStack fullWidth>
|
||||||
|
<label class="immich-form-label" for="datetime">{$t('date_and_time')}</label>
|
||||||
|
</HStack>
|
||||||
|
<HStack fullWidth>
|
||||||
|
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
|
||||||
|
</HStack>
|
||||||
|
{/if}
|
||||||
|
<div class="w-full">
|
||||||
|
<Combobox
|
||||||
|
bind:selectedOption
|
||||||
|
label={$t('timezone')}
|
||||||
|
options={timezones}
|
||||||
|
placeholder={$t('search_timezone')}
|
||||||
|
onSelect={(option) => (lastSelectedTimezone = option as ZoneOption)}
|
||||||
|
></Combobox>
|
||||||
|
</div>
|
||||||
|
<!-- <Card color="secondary" class={!showRelative || !currentInterval ? 'invisible' : ''}>
|
||||||
|
<CardBody class="p-2">
|
||||||
|
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 items-center">
|
||||||
|
<div class="col-span-2 immich-form-label" data-testid="interval-preview">Preview</div>
|
||||||
|
<Text size="small" class="-mt-2 immich-form-label col-span-2"
|
||||||
|
>Showing changes for first selected asset only</Text
|
||||||
|
>
|
||||||
|
<label class="immich-form-label" for="from">Before</label>
|
||||||
|
<DateInput
|
||||||
|
class="dark:text-gray-300 text-gray-700 text-base"
|
||||||
|
id="from"
|
||||||
|
type="datetime-local"
|
||||||
|
readonly
|
||||||
|
bind:value={before}
|
||||||
|
/>
|
||||||
|
<label class="immich-form-label" for="to">After</label>
|
||||||
|
<DateInput
|
||||||
|
class="dark:text-gray-300 text-gray-700 text-base"
|
||||||
|
id="to"
|
||||||
|
type="datetime-local"
|
||||||
|
readonly
|
||||||
|
bind:value={after}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card> -->
|
||||||
|
</VStack>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<HStack fullWidth>
|
||||||
|
<Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}>
|
||||||
|
{$t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button shape="round" color="primary" fullWidth onclick={handleConfirm} disabled={!date.isValid}>
|
||||||
|
{$t('confirm')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
61
web/src/lib/modals/NavigateToDateModal.svelte
Normal file
61
web/src/lib/modals/NavigateToDateModal.svelte
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import DateInput from '$lib/elements/DateInput.svelte';
|
||||||
|
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { getPreferredTimeZone, getTimezones, toDatetime, type ZoneOption } from '$lib/modals/timezone-utils';
|
||||||
|
import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui';
|
||||||
|
import { mdiNavigationVariantOutline } from '@mdi/js';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
interface Props {
|
||||||
|
timelineManager: TimelineManager;
|
||||||
|
onClose: (asset?: TimelineAsset) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { timelineManager, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const initialDate = DateTime.now();
|
||||||
|
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
|
||||||
|
const timezones = $derived(getTimezones(selectedDate));
|
||||||
|
// 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 = $derived(getPreferredTimeZone(initialDate, undefined, timezones));
|
||||||
|
|
||||||
|
const handleClose = async () => {
|
||||||
|
if (!date.isValid || !selectedOption) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the local date/time components from the selected string using neutral timezone
|
||||||
|
const dateTime = toDatetime(selectedDate, selectedOption) as DateTime<true>;
|
||||||
|
const asset = await timelineManager.getClosestAssetToDate(dateTime.toObject());
|
||||||
|
onClose(asset);
|
||||||
|
};
|
||||||
|
|
||||||
|
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
|
||||||
|
const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title={$t('navigate_to_time')} icon={mdiNavigationVariantOutline} onClose={() => onClose()}>
|
||||||
|
<ModalBody>
|
||||||
|
<VStack fullWidth>
|
||||||
|
<HStack fullWidth>
|
||||||
|
<label class="immich-form-label" for="datetime">{$t('date_and_time')}</label>
|
||||||
|
</HStack>
|
||||||
|
<HStack fullWidth>
|
||||||
|
<DateInput
|
||||||
|
class="immich-form-input text-gray-700 w-full"
|
||||||
|
id="datetime"
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={selectedDate}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<HStack fullWidth>
|
||||||
|
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||||
|
<Button shape="round" type="submit" fullWidth onclick={handleClose}>{$t('confirm')}</Button>
|
||||||
|
</HStack>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
@ -27,6 +27,7 @@
|
|||||||
{ key: ['D', 'd'], action: $t('previous_or_next_day') },
|
{ key: ['D', 'd'], action: $t('previous_or_next_day') },
|
||||||
{ key: ['M', 'm'], action: $t('previous_or_next_month') },
|
{ key: ['M', 'm'], action: $t('previous_or_next_month') },
|
||||||
{ key: ['Y', 'y'], action: $t('previous_or_next_year') },
|
{ key: ['Y', 'y'], action: $t('previous_or_next_year') },
|
||||||
|
{ key: ['g'], action: $t('navigate_to_time') },
|
||||||
{ key: ['x'], action: $t('select') },
|
{ key: ['x'], action: $t('select') },
|
||||||
{ key: ['Esc'], action: $t('back_close_deselect') },
|
{ key: ['Esc'], action: $t('back_close_deselect') },
|
||||||
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') },
|
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') },
|
||||||
|
|||||||
149
web/src/lib/modals/timezone-utils.ts
Normal file
149
web/src/lib/modals/timezone-utils.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { DateTime, Duration } from 'luxon';
|
||||||
|
|
||||||
|
export type ZoneOption = {
|
||||||
|
/**
|
||||||
|
* Timezone name with offset
|
||||||
|
*
|
||||||
|
* e.g. Asia/Jerusalem (+03:00)
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timezone name
|
||||||
|
*
|
||||||
|
* e.g. Asia/Jerusalem
|
||||||
|
*/
|
||||||
|
value: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timezone offset in minutes
|
||||||
|
*
|
||||||
|
* e.g. 300
|
||||||
|
*/
|
||||||
|
offsetMinutes: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True iff the date is valid
|
||||||
|
*
|
||||||
|
* Dates may be invalid for various reasons, for example setting a day that does not exist (30 Feb 2024).
|
||||||
|
* Due to daylight saving time, 2:30am is invalid for Europe/Berlin on Mar 31 2024.The two following local times
|
||||||
|
* are one second apart:
|
||||||
|
*
|
||||||
|
* - Mar 31 2024 01:59:59 (GMT+0100, unix timestamp 1725058799)
|
||||||
|
* - Mar 31 2024 03:00:00 (GMT+0200, unix timestamp 1711846800)
|
||||||
|
*
|
||||||
|
* Mar 31 2024 02:30:00 does not exist in Europe/Berlin, this is an invalid date/time/time zone combination.
|
||||||
|
*/
|
||||||
|
valid: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const knownTimezones = Intl.supportedValuesOf('timeZone');
|
||||||
|
|
||||||
|
export function getTimezones(selectedDate: string) {
|
||||||
|
// Use a fixed modern date to calculate stable timezone offsets for the list
|
||||||
|
// This ensures that the offsets shown in the combobox are always current,
|
||||||
|
// regardless of the historical date selected by the user.
|
||||||
|
return knownTimezones
|
||||||
|
.map((zone) => zoneOptionForDate(zone, selectedDate))
|
||||||
|
.filter((zone) => zone.valid)
|
||||||
|
.sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModernOffsetForZoneAndDate(
|
||||||
|
zone: string,
|
||||||
|
dateString: string,
|
||||||
|
): { offsetMinutes: number; offsetFormat: string } {
|
||||||
|
const dt = DateTime.fromISO(dateString, { zone });
|
||||||
|
|
||||||
|
// we determine the *modern* offset for this zone based on its current rules.
|
||||||
|
// To do this, we "move" the date to the current year, keeping the local time components.
|
||||||
|
// This allows Luxon to apply current-year DST rules.
|
||||||
|
const modernYearDt = dt.set({ year: DateTime.now().year });
|
||||||
|
|
||||||
|
// Calculate the offset at that modern year's date.
|
||||||
|
const modernOffsetMinutes = modernYearDt.setZone(zone, { keepLocalTime: true }).offset;
|
||||||
|
const modernOffsetFormat = modernYearDt.setZone(zone, { keepLocalTime: true }).toFormat('ZZ');
|
||||||
|
|
||||||
|
return { offsetMinutes: modernOffsetMinutes, offsetFormat: modernOffsetFormat };
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoneOptionForDate(zone: string, date: string) {
|
||||||
|
const { offsetMinutes, offsetFormat: zoneOffsetAtDate } = getModernOffsetForZoneAndDate(zone, date);
|
||||||
|
// For validity, we still need to check if the exact date/time exists in the *original* timezone (for gaps/overlaps).
|
||||||
|
const dateForValidity = DateTime.fromISO(date, { zone });
|
||||||
|
const valid = dateForValidity.isValid && date === dateForValidity.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
|
||||||
|
return {
|
||||||
|
value: zone,
|
||||||
|
offsetMinutes,
|
||||||
|
label: zone + ' (' + zoneOffsetAtDate + ')' + (valid ? '' : ' [invalid date!]'),
|
||||||
|
valid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) {
|
||||||
|
const offsetDifference = zoneA.offsetMinutes - zoneB.offsetMinutes;
|
||||||
|
if (offsetDifference != 0) {
|
||||||
|
return offsetDifference;
|
||||||
|
}
|
||||||
|
return zoneA.value.localeCompare(zoneB.value, undefined, { sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If the time zone is not given, find the timezone to select for a given time, date, and offset (e.g. +02:00).
|
||||||
|
*
|
||||||
|
* This is done so that the list shown to the user includes more helpful names like "Europe/Berlin (+02:00)"
|
||||||
|
* instead of just the raw offset or something like "UTC+02:00".
|
||||||
|
*
|
||||||
|
* The provided information (initialDate, from some asset) includes the offset (e.g. +02:00), but no information about
|
||||||
|
* the actual time zone. As several countries/regions may share the same offset, for example Berlin (Germany) and
|
||||||
|
* Blantyre (Malawi) sharing +02:00 in summer, we have to guess and somehow pick a suitable time zone.
|
||||||
|
*
|
||||||
|
* If the time zone configured by the user (in the browser) provides the same offset for the given date (accounting
|
||||||
|
* for daylight saving time and other weirdness), we prefer to show it. This way, for German users, we might be able
|
||||||
|
* to show "Europe/Berlin" instead of the lexicographically first entry "Africa/Blantyre".
|
||||||
|
*/
|
||||||
|
export function getPreferredTimeZone(
|
||||||
|
date: DateTime,
|
||||||
|
initialTimeZone: string | undefined,
|
||||||
|
timezones: ZoneOption[],
|
||||||
|
selectedOption?: ZoneOption,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
const utcFallback = {
|
||||||
|
label: 'UTC (+00:00)',
|
||||||
|
offsetMinutes: 0,
|
||||||
|
value: 'UTC',
|
||||||
|
valid: true,
|
||||||
|
};
|
||||||
|
return previousSelection ?? fromInitialTimeZone ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDatetime(selectedDate: string, selectedZone: ZoneOption) {
|
||||||
|
const dtComponents = DateTime.fromISO(selectedDate, { zone: 'utc' });
|
||||||
|
|
||||||
|
// Determine the modern, DST-aware offset for the selected IANA zone
|
||||||
|
const { offsetMinutes } = getModernOffsetForZoneAndDate(selectedZone.value, selectedDate);
|
||||||
|
|
||||||
|
// Construct the final ISO string with a fixed-offset zone.
|
||||||
|
const fixedOffsetZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`;
|
||||||
|
|
||||||
|
// Create a DateTime object in this fixed-offset zone, preserving the local time.
|
||||||
|
return DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toIsoDate(selectedDate: string, selectedZone: ZoneOption) {
|
||||||
|
return toDatetime(selectedDate, selectedZone).toISO({ includeOffset: true })!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calcNewDate = (timestamp: DateTime, selectedDuration: number, timezone?: string) => {
|
||||||
|
let newDateTime = timestamp.plus({ minutes: selectedDuration });
|
||||||
|
if (timezone) {
|
||||||
|
newDateTime = newDateTime.setZone(timezone);
|
||||||
|
}
|
||||||
|
return newDateTime.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
|
||||||
|
};
|
||||||
@ -405,7 +405,7 @@ export const getAssetType = (type: AssetTypeEnum) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSelectedAssets = (assets: TimelineAsset[], user: UserResponseDto | null): string[] => {
|
export const getOwnedAssetsWithWarning = (assets: TimelineAsset[], user: UserResponseDto | null): string[] => {
|
||||||
const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id);
|
const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id);
|
||||||
|
|
||||||
const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length;
|
const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length;
|
||||||
|
|||||||
@ -154,12 +154,6 @@ export function formatGroupTitle(_date: DateTime): string {
|
|||||||
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
|
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
|
||||||
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
|
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
|
||||||
|
|
||||||
export const getDateTimeOffsetLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
|
|
||||||
date.toLocaleString(
|
|
||||||
{ year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'longOffset' },
|
|
||||||
opts,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => {
|
export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => {
|
||||||
if (isTimelineAsset(unknownAsset)) {
|
if (isTimelineAsset(unknownAsset)) {
|
||||||
return unknownAsset;
|
return unknownAsset;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user