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:
Min Idzelis 2025-10-16 13:44:09 -04:00 committed by GitHub
parent d0eae97037
commit 2919ee4c65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 647 additions and 477 deletions

View file

@ -5,12 +5,9 @@
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.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 ChangeDate, {
type AbsoluteResult,
type RelativeResult,
} from '$lib/components/shared-components/change-date.svelte';
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store';
@ -19,12 +16,11 @@
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
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 { AssetMediaSize, getAssetInfo, updateAsset, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui';
import {
mdiCalendar,
mdiCameraIris,
@ -59,7 +55,7 @@
let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
let timeZone = $derived(asset.exifInfo?.timeZone);
let timeZone = $derived(asset.exifInfo?.timeZone ?? undefined);
let dateTime = $derived(
timeZone && asset.exifInfo?.dateTimeOriginal
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
@ -112,18 +108,13 @@
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
let isShowChangeDate = $state(false);
async function handleConfirmChangeDate(result: AbsoluteResult | RelativeResult) {
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'));
const handleChangeDate = async () => {
if (!isOwner) {
return;
}
}
await modalManager.show(AssetChangeDateModal, { asset: toTimelineAsset(asset), initialDate: dateTime });
};
</script>
<section class="relative p-2">
@ -280,7 +271,7 @@
<button
type="button"
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') : ''}
class:hover:text-primary={isOwner}
>
@ -336,16 +327,6 @@
</div>
{/if}
{#if isShowChangeDate}
<ChangeDate
initialDate={dateTime}
initialTimeZone={timeZone ?? ''}
withDuration={false}
onConfirm={handleConfirmChangeDate}
onCancel={() => (isShowChangeDate = false)}
/>
{/if}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiImageOutline} size="24" /></div>

View file

@ -1,190 +0,0 @@
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { DateTime } from 'luxon';
import ChangeDate from './change-date.svelte';
describe('ChangeDate component', () => {
const initialDate = DateTime.fromISO('2024-01-01');
const initialTimeZone = 'Europe/Berlin';
const currentInterval = {
start: DateTime.fromISO('2000-02-01T14:00:00+01:00'),
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 getDateInput = () => screen.getByLabelText('date_and_time') as HTMLInputElement;
const getTimeZoneInput = () => screen.getByLabelText('timezone') as HTMLInputElement;
const getCancelButton = () => screen.getByText('Cancel');
const getConfirmButton = () => screen.getByText('Confirm');
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.stubGlobal('visualViewport', getVisualViewportMock());
});
afterEach(() => {
vi.resetAllMocks();
});
afterAll(async () => {
await waitFor(() => {
// check that bits-ui body scroll-lock class is gone
expect(document.body.style.pointerEvents).not.toBe('none');
});
});
test('should render correct values', () => {
render(ChangeDate, { initialDate, initialTimeZone, onCancel, onConfirm });
expect(getDateInput().value).toBe('2024-01-01T00:00');
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
});
test('calls onConfirm with correct date on confirm', async () => {
render(ChangeDate, {
props: { initialDate, initialTimeZone, onCancel, onConfirm },
});
await fireEvent.click(getConfirmButton());
expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-01-01T00:00:00.000+01:00' });
});
test('calls onCancel on cancel', async () => {
render(ChangeDate, {
props: { initialDate, initialTimeZone, onCancel, onConfirm },
});
await fireEvent.click(getCancelButton());
expect(onCancel).toHaveBeenCalled();
});
describe('when date is in daylight saving time', () => {
const dstDate = DateTime.fromISO('2024-07-01');
test('should render correct timezone with offset', () => {
render(ChangeDate, { initialDate: dstDate, initialTimeZone, onCancel, onConfirm });
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+02:00)');
});
test('calls onConfirm with correct date on confirm', async () => {
render(ChangeDate, {
props: { initialDate: dstDate, initialTimeZone, onCancel, onConfirm },
});
await fireEvent.click(getConfirmButton());
expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-07-01T00:00:00.000+02:00' });
});
});
test('calls onConfirm with correct offset in relative mode', async () => {
render(ChangeDate, {
props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm },
});
await fireEvent.click(getRelativeInputToggle());
const dayInput = screen.getByPlaceholderText('days');
const hoursInput = screen.getByPlaceholderText('hours');
const minutesInput = screen.getByPlaceholderText('minutes');
const days = 5;
const hours = 4;
const minutes = 3;
await fireEvent.input(dayInput, { target: { value: days } });
await fireEvent.input(hoursInput, { target: { value: hours } });
await fireEvent.input(minutesInput, { target: { value: minutes } });
await fireEvent.click(getConfirmButton());
expect(onConfirm).toHaveBeenCalledWith({
mode: 'relative',
duration: days * 60 * 24 + hours * 60 + minutes,
timeZone: undefined,
});
});
test('calls onConfirm with correct timeZone in relative mode', async () => {
const user = userEvent.setup();
render(ChangeDate, {
props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm },
});
await user.click(getRelativeInputToggle());
await user.type(getTimeZoneInput(), initialTimeZone);
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
await user.click(getConfirmButton());
expect(onConfirm).toHaveBeenCalledWith({
mode: 'relative',
duration: 0,
timeZone: initialTimeZone,
});
});
test('correctly handles date preview', () => {
const testCases = [
{
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
duration: 0,
timezone: undefined,
expectedResult: 'Jan 1, 2024, 12:00 AM GMT+01:00',
},
{
timestamp: DateTime.fromISO('2024-01-01T04:00:00.000+05:00', { setZone: true }),
duration: 0,
timezone: undefined,
expectedResult: 'Jan 1, 2024, 4:00 AM GMT+05:00',
},
{
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+00:00', { setZone: true }),
duration: 0,
timezone: 'Europe/Berlin',
expectedResult: 'Jan 1, 2024, 1:00 AM GMT+01:00',
},
{
timestamp: DateTime.fromISO('2024-07-01T00:00:00.000+00:00', { setZone: true }),
duration: 0,
timezone: 'Europe/Berlin',
expectedResult: 'Jul 1, 2024, 2:00 AM GMT+02:00',
},
{
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
duration: 1440,
timezone: undefined,
expectedResult: 'Jan 2, 2024, 12:00 AM GMT+01:00',
},
{
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
duration: -1440,
timezone: undefined,
expectedResult: 'Dec 31, 2023, 12:00 AM GMT+01:00',
},
{
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000-01:00', { setZone: true }),
duration: -1440,
timezone: 'America/Anchorage',
expectedResult: 'Dec 30, 2023, 4:00 PM GMT-09:00',
},
];
const component = render(ChangeDate, {
props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm },
});
for (const testCase of testCases) {
expect(
component.component.calcNewDate(testCase.timestamp, testCase.duration, testCase.timezone),
JSON.stringify(testCase),
).toBe(testCase.expectedResult);
}
});
});

View file

@ -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>

View file

@ -1,18 +1,11 @@
<script lang="ts">
import ChangeDate, {
type AbsoluteResult,
type RelativeResult,
} from '$lib/components/shared-components/change-date.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { user } from '$lib/stores/user.store';
import { getSelectedAssets } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util.js';
import { updateAssets } from '@immich/sdk';
import AssetSelectionChangeDateModal from '$lib/modals/AssetSelectionChangeDateModal.svelte';
import { modalManager } from '@immich/ui';
import { mdiCalendarEditOutline } from '@mdi/js';
import { DateTime, Duration } from 'luxon';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
menuItem?: boolean;
}
@ -20,66 +13,17 @@
let { menuItem = false }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
let isShowChangeDate = $state(false);
let currentInterval = $derived.by(() => {
if (isShowChangeDate) {
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];
let maxTimestamp = imageTimestamps[0];
for (let current of imageTimestamps) {
if (current < minTimestamp) {
minTimestamp = current;
}
if (current > maxTimestamp) {
maxTimestamp = current;
}
}
return { start: minTimestamp, end: maxTimestamp };
const handleChangeDate = async () => {
const success = await modalManager.show(AssetSelectionChangeDateModal, {
initialDate: DateTime.now(),
assets: getOwnedAssets(),
});
if (success) {
clearSelect();
}
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();
};
</script>
{#if menuItem}
<MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={() => (isShowChangeDate = true)} />
{/if}
{#if isShowChangeDate}
<ChangeDate
initialDate={DateTime.now()}
{currentInterval}
onConfirm={handleConfirm}
onCancel={() => (isShowChangeDate = false)}
/>
<MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={handleChangeDate} />
{/if}

View file

@ -2,7 +2,7 @@
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import AssetUpdateDescriptionConfirmModal from '$lib/modals/AssetUpdateDescriptionConfirmModal.svelte';
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 { updateAssets } from '@immich/sdk';
import { modalManager } from '@immich/ui';
@ -20,7 +20,7 @@
const handleUpdateDescription = async () => {
const description = await modalManager.show(AssetUpdateDescriptionConfirmModal);
if (description) {
const ids = getSelectedAssets(getOwnedAssets(), $user);
const ids = getOwnedAssetsWithWarning(getOwnedAssets(), $user);
try {
await updateAssets({ assetBulkUpdateDto: { ids, description } });

View file

@ -2,7 +2,7 @@
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
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 { updateAssets } from '@immich/sdk';
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
@ -25,7 +25,7 @@
return;
}
const ids = getSelectedAssets(getOwnedAssets(), $user);
const ids = getOwnedAssetsWithWarning(getOwnedAssets(), $user);
try {
await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } });

View file

@ -2,10 +2,6 @@
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
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 {
setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit,
@ -13,6 +9,7 @@
import { AppRoute } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@ -24,8 +21,6 @@
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { AssetVisibility } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { DateTime } from 'luxon';
let { isViewing: showAssetViewer } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
@ -43,7 +38,7 @@
scrollToAsset,
}: Props = $props();
let isShowSelectDate = $state(false);
const { isViewing: showAssetViewer } = assetViewingStore;
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
@ -150,6 +145,13 @@
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
const handleOpenDateModal = async () => {
const asset = await modalManager.show(NavigateToDateModal, { timelineManager });
if (asset) {
setFocusAsset(asset);
}
};
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
@ -168,7 +170,7 @@
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) },
{ shortcut: { key: 'G' }, onShortcut: handleOpenDateModal },
];
if (onEscape) {
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
@ -198,24 +200,3 @@
onConfirm={() => handlePromiseError(trashOrDelete(true))}
/>
{/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}