Merge branch 'main' into feat/undo-archive

This commit is contained in:
Yaros
2026-03-26 19:11:36 +01:00
committed by GitHub
166 changed files with 3235 additions and 8662 deletions
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.6.1",
"version": "2.6.3",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.65.3",
"@immich/ui": "^0.69.0",
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
@@ -72,10 +72,10 @@
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.10.0",
"@sveltejs/enhanced-img": "^0.10.4",
"@sveltejs/kit": "^2.27.1",
"@sveltejs/vite-plugin-svelte": "6.2.4",
"@tailwindcss/vite": "^4.1.7",
"@sveltejs/vite-plugin-svelte": "7.0.0",
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.8",
"@testing-library/user-event": "^14.5.2",
@@ -100,16 +100,16 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.53.7",
"svelte": "5.54.1",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",
"tailwindcss": "^4.2.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.2",
"vite": "^8.0.0",
"vitest": "^4.0.0"
},
"volta": {
"node": "24.13.1"
"node": "24.14.0"
}
}
-33
View File
@@ -1,33 +0,0 @@
<script lang="ts">
import { Button, ToastContainer, ToastContent, type Color, type IconLike } from '@immich/ui';
type Props = {
onClose?: () => void;
color?: Color;
title: string;
icon?: IconLike | false;
description: string;
button?: {
text: string;
color?: Color;
onClick: () => void;
};
};
const { onClose, title, description, color, icon, button }: Props = $props();
const onClick = () => {
button?.onClick();
onClose?.();
};
</script>
<ToastContainer {color}>
<ToastContent {color} {title} {description} {onClose} {icon}>
{#if button}
<div class="flex justify-end gap-2 px-2 pb-2 me-3 mt-2">
<Button color={button.color ?? 'secondary'} size="small" onclick={onClick}>{button.text}</Button>
</div>
{/if}
</ToastContent>
</ToastContainer>
@@ -1,7 +1,8 @@
<script lang="ts">
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import MapModal from '$lib/modals/MapModal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { navigate } from '$lib/utils/navigation';
import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
import { IconButton, modalManager } from '@immich/ui';
import { mdiMapOutline } from '@mdi/js';
@@ -14,8 +15,8 @@
let { album }: Props = $props();
let abortController: AbortController;
let { setAssetId } = assetViewingStore;
let returnToMap = $state(false);
let mapMarkers: MapMarkerResponseDto[] = $state([]);
onMount(async () => {
@@ -24,7 +25,14 @@
onDestroy(() => {
abortController?.abort();
assetViewingStore.showAssetViewer(false);
assetViewerManager.showAssetViewer(false);
});
$effect(() => {
if (!assetViewerManager.isViewing && returnToMap) {
returnToMap = false;
void onClick();
}
});
async function loadMapMarkers() {
@@ -52,13 +60,15 @@
return markers;
}
async function openMap() {
const onClick = async () => {
const assetIds = await modalManager.show(MapModal, { mapMarkers });
if (assetIds) {
await setAssetId(assetIds[0]);
await navigate({ targetRoute: 'current', assetId: assetIds[0] });
returnToMap = true;
} else {
returnToMap = false;
}
}
};
</script>
<IconButton
@@ -66,6 +76,6 @@
shape="round"
color="secondary"
icon={mdiMapOutline}
onclick={openMap}
onclick={onClick}
aria-label={$t('map')}
/>
@@ -5,12 +5,12 @@
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { handleDownloadAlbum } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
@@ -34,7 +34,6 @@
const album = sharedLink.album as AlbumResponseDto;
let { isViewing: showAssetViewer, setAssetId } = assetViewingStore;
let { slideshowState, slideshowNavigation } = slideshowStore;
const options = $derived({ albumId: album.id, order: album.order });
@@ -55,7 +54,9 @@
? await timelineManager.getRandomAsset()
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
if (asset) {
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
handlePromiseError(
assetViewerManager.setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)),
);
}
};
@@ -66,7 +67,7 @@
use:shortcut={{
shortcut: { key: 'Escape' },
onShortcut: () => {
if (!$showAssetViewer && assetInteraction.selectionActive) {
if (!assetViewerManager.isViewing && assetInteraction.selectionActive) {
cancelMultiselect(assetInteraction);
}
},
@@ -8,6 +8,7 @@
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte';
import RemoveFromAlbumAction from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte';
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte';
@@ -15,8 +16,10 @@
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import LoadingDots from '$lib/components/LoadingDots.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { Route } from '$lib/route';
@@ -36,8 +39,6 @@
type StackResponseDto,
} from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui';
import LoadingDots from '$lib/components/LoadingDots.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import {
mdiArrowLeft,
mdiArrowRight,
@@ -60,6 +61,7 @@
onUndoDelete?: OnUndoDelete;
onPlaySlideshow: () => void;
onClose?: () => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
playOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void;
}
@@ -75,11 +77,13 @@
onUndoDelete = undefined,
onPlaySlideshow,
onClose,
onRemoveFromAlbum,
playOriginalVideo = false,
setPlayOriginalVideo,
}: Props = $props();
const isOwner = $derived($user && asset.ownerId === $user?.id);
const isAlbumOwner = $derived($user && album?.ownerId === $user?.id);
const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
@@ -120,10 +124,10 @@
<ActionButton action={Cast} />
<ActionButton action={Actions.Share} />
<ActionButton action={Actions.Offline} />
<ActionButton action={Actions.PlayMotionPhoto} />
<ActionButton action={Actions.StopMotionPhoto} />
<ActionButton action={Actions.ZoomIn} />
<ActionButton action={Actions.ZoomOut} />
<ActionButton action={Actions.PlayMotionPhoto} />
<ActionButton action={Actions.StopMotionPhoto} />
<ActionButton action={Actions.Copy} />
<ActionButton action={Actions.SharedLinkDownload} />
<ActionButton action={Actions.Info} />
@@ -154,6 +158,9 @@
{/if}
<ActionMenuItem action={Actions.AddToAlbum} />
{#if album && (isOwner || isAlbumOwner)}
<RemoveFromAlbumAction {album} onRemove={onRemoveFromAlbum} assetIds={[asset.id]} menuItem />
{/if}
{#if isOwner}
<AddToStackAction {asset} {stack} {onAction} />
@@ -1,5 +1,6 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
import { renderWithTooltips } from '$tests/helpers';
import { updateAsset } from '@immich/sdk';
@@ -41,6 +42,7 @@ describe('AssetViewer', () => {
});
afterEach(() => {
slideshowStore.slideshowState.set(SlideshowState.None);
resetSavedUser();
vi.clearAllMocks();
});
@@ -14,7 +14,6 @@
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { getAssetActions } from '$lib/services/asset.service';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
@@ -71,6 +70,7 @@
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
onRandom?: () => Promise<{ id: string } | undefined>;
}
@@ -86,10 +86,10 @@
onAction,
onUndoDelete,
onClose,
onRemoveFromAlbum,
onRandom,
}: Props = $props();
const { setAssetId } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress,
@@ -188,7 +188,7 @@
if (editManager.hasAppliedEdits) {
const refreshedAsset = await getAssetInfo({ id: asset.id });
onAssetChange?.(refreshedAsset);
assetViewingStore.setAsset(refreshedAsset);
assetViewerManager.setAsset(refreshedAsset);
}
assetViewerManager.closeEditor();
};
@@ -239,7 +239,7 @@
}
if ($slideshowRepeat && slideshowStartAssetId) {
await setAssetId(slideshowStartAssetId);
await assetViewerManager.setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
return;
}
@@ -255,7 +255,7 @@
let assetViewerHtmlElement = $state<HTMLElement>();
const slideshowHistory = new SlideshowHistory((asset) => {
handlePromiseError(setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
handlePromiseError(assetViewerManager.setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
});
const handleVideoStarted = () => {
@@ -478,6 +478,7 @@
{onUndoDelete}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? () => onClose(asset) : undefined}
{onRemoveFromAlbum}
{playOriginalVideo}
{setPlayOriginalVideo}
/>
@@ -485,7 +486,7 @@
{/if}
{#if $slideshowState != SlideshowState.None}
<div class="absolute w-full flex justify-center">
<div class="absolute inset-s-0 top-0 flex w-full justify-start">
<SlideshowBar
{isFullScreen}
assetType={previewStackedAsset?.type ?? asset.type}
@@ -580,17 +581,16 @@
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
class={[
'row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light',
showDetailPanel ? 'w-90' : 'w-100',
]}
translate="yes"
>
{#if showDetailPanel}
<div class="w-90 h-full">
<DetailPanel {asset} currentAlbum={album} />
</div>
<DetailPanel {asset} currentAlbum={album} />
{:else if assetViewerManager.isShowEditor}
<div class="w-100 h-full">
<EditorPanel {asset} onClose={closeEditor} />
</div>
<EditorPanel {asset} onClose={closeEditor} />
{/if}
</div>
{/if}
@@ -1,24 +1,20 @@
<script lang="ts">
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import Portal from '$lib/elements/Portal.svelte';
import GeolocationPointPickerModal from '$lib/modals/GeolocationPointPickerModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { Icon, modalManager } from '@immich/ui';
import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
isOwner: boolean;
asset: AssetResponseDto;
}
};
let { isOwner, asset = $bindable() }: Props = $props();
let isShowChangeLocation = $state(false);
const onClose = async (point?: { lng: number; lat: number }) => {
isShowChangeLocation = false;
const onAction = async () => {
const point = await modalManager.show(GeolocationPointPickerModal, { asset });
if (!point) {
return;
}
@@ -38,7 +34,7 @@
<button
type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
onclick={() => (isOwner ? (isShowChangeLocation = true) : null)}
onclick={isOwner ? onAction : undefined}
title={isOwner ? $t('edit_location') : ''}
class:hover:text-primary={isOwner}
>
@@ -72,12 +68,11 @@
<button
type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:text-primary"
onclick={() => (isShowChangeLocation = true)}
onclick={onAction}
title={$t('add_location')}
>
<div class="flex gap-4">
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
<p>{$t('add_a_location')}</p>
</div>
<div class="focus:outline-none p-1">
@@ -85,9 +80,3 @@
</div>
</button>
{/if}
{#if isShowChangeLocation}
<Portal>
<ChangeLocation {asset} {onClose} />
</Portal>
{/if}
@@ -23,7 +23,7 @@
{ label: '2:3', value: '2:3', width: 16, height: 24 },
{ label: '16:9', value: '16:9', width: 24, height: 14 },
{ label: '9:16', value: '9:16', width: 14, height: 24 },
{ label: 'Square', value: '1:1', width: 20, height: 20 },
{ label: $t('crop_aspect_ratio_square'), value: '1:1', width: 20, height: 20 },
];
let isRotated = $derived(transformManager.normalizedRotation % 180 !== 0);
@@ -1,16 +1,16 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { shortcut } from '$lib/actions/shortcut';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { clamp } from 'lodash-es';
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
@@ -27,6 +27,7 @@
let faceRect: Rect | undefined = $state();
let faceSelectorEl: HTMLDivElement | undefined = $state();
let scrollableListEl: HTMLDivElement | undefined = $state();
let searchInputEl: HTMLInputElement | null = $state(null);
let page = $state(1);
let candidates = $state<PersonResponseDto[]>([]);
@@ -81,6 +82,8 @@
onMount(async () => {
setupCanvas();
await getPeople();
await tick();
searchInputEl?.focus();
});
const imageContentMetrics = $derived.by(() => {
@@ -221,12 +224,15 @@
$effect(() => {
const rect = faceRect;
if (rect) {
const cvs = canvas;
if (rect && cvs) {
rect.on('moving', positionFaceSelector);
rect.on('scaling', positionFaceSelector);
cvs.on('object:modified', () => searchInputEl?.focus());
return () => {
rect.off('moving', positionFaceSelector);
rect.off('scaling', positionFaceSelector);
cvs.off('object:modified', () => searchInputEl?.focus());
};
}
});
@@ -281,7 +287,7 @@
},
});
await assetViewingStore.setAssetId(assetId);
await assetViewerManager.setAssetId(assetId);
} catch (error) {
handleError(error, 'Error tagging face');
} finally {
@@ -290,7 +296,7 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: cancel }} />
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: cancel, ignoreInputFields: false }} />
<div
id="face-editor-data"
@@ -310,7 +316,7 @@
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
<div class="my-3 relative">
<Input placeholder={$t('search_people')} bind:value={searchTerm} size="tiny" />
<Input placeholder={$t('search_people')} bind:value={searchTerm} bind:ref={searchInputEl} size="tiny" />
</div>
<div bind:this={scrollableListEl} class="h-62.5 overflow-y-auto mt-2">
@@ -3,7 +3,6 @@
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
@@ -179,7 +178,7 @@
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
await assetViewingStore.setAssetId(assetId);
await assetViewerManager.setAssetId(assetId);
} catch (error) {
handleError(error, $t('error_delete_face'));
}
@@ -19,13 +19,13 @@
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { QueryParameter } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { memoryManager, type MemoryAsset } from '$lib/managers/memory-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
@@ -77,7 +77,6 @@
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);
const { isViewing } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 });
// need to include padding in the viewport for gallery
const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 });
@@ -87,7 +86,7 @@
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: { id: string }) => {
if ($isViewing) {
if (assetViewerManager.isViewing) {
return asset;
}
@@ -187,7 +186,7 @@
if (!current) {
return;
}
memoryStore.hideAssetsFromMemory(ids);
memoryManager.hideAssetsFromMemory(ids);
init(page);
};
@@ -196,7 +195,7 @@
return;
}
await memoryStore.deleteAssetFromMemory(current.asset.id);
await memoryManager.deleteAssetFromMemory(current.asset.id);
init(page);
};
@@ -205,7 +204,7 @@
return;
}
await memoryStore.deleteMemory(current.memory.id);
await memoryManager.deleteMemory(current.memory.id);
toastManager.primary($t('removed_memory'));
init(page);
};
@@ -216,7 +215,7 @@
}
const newSavedState = !current.memory.isSaved;
await memoryStore.updateMemorySaved(current.memory.id, newSavedState);
await memoryManager.updateMemorySaved(current.memory.id, newSavedState);
toastManager.primary(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
init(page);
};
@@ -254,11 +253,11 @@
const loadFromParams = (page: Page | NavigationTarget | null) => {
const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
return memoryStore.getMemoryAsset(assetId);
return memoryManager.getMemoryAsset(assetId);
};
const init = (target: Page | NavigationTarget | null) => {
if (memoryStore.memories.length === 0) {
if (memoryManager.memories.length === 0) {
return handlePromiseError(goto(Route.photos()));
}
@@ -281,7 +280,7 @@
if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) {
return;
}
if ($isViewing) {
if (assetViewerManager.isViewing) {
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
} else if (isVideo) {
// Image assets will start playing when the image is loaded. Only autostart video assets.
@@ -291,7 +290,7 @@
};
afterNavigate(({ from, to }) => {
memoryStore.ready().then(
memoryManager.ready().then(
() => {
let target;
if (to?.params?.assetId) {
@@ -326,7 +325,7 @@
</script>
<svelte:document
use:shortcuts={$isViewing
use:shortcuts={assetViewerManager.isViewing
? []
: [
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => handleNextAsset() },
@@ -3,7 +3,7 @@
import IndividualSharedViewer from '$lib/components/share-page/individual-shared-viewer.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { user } from '$lib/stores/user.store';
import { setSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
@@ -31,7 +31,6 @@
const { data }: Props = $props();
let { gridScrollTarget } = assetViewingStore;
let { sharedLink, passwordRequired, key, slug, meta } = $state(data);
let { title, description } = $state(meta);
let isOwned = $derived($user ? $user.id === sharedLink?.userId : false);
@@ -48,7 +47,7 @@
$t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } });
await tick();
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: assetViewerManager.gridScrollTarget },
{ forceNavigate: true, replaceState: true },
);
} catch (error) {
@@ -212,12 +212,12 @@
bottom: `${rootHeight - top}px`,
left: `${left}px`,
width: `${boundary.width}px`,
maxHeight: maxHeight(top - dropdownOffset),
maxHeight: maxHeight(boundary.top - dropdownOffset),
};
}
const viewportHeight = visualViewport?.height || rootHeight;
const availableHeight = modalBounds ? rootHeight - bottom : viewportHeight - boundary.bottom;
const viewportHeight = visualViewport?.height || window.innerHeight;
const availableHeight = viewportHeight - boundary.bottom;
return {
top: `${bottom}px`,
left: `${left}px`,
@@ -2,16 +2,17 @@
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
@@ -64,7 +65,6 @@
allowDeletion = true,
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
const navigationAssets = $derived(viewerAssets ?? assets);
const geometry = $derived(
@@ -258,7 +258,7 @@
const shortcutList = $derived(
(() => {
if ($isViewerOpen) {
if (assetViewerManager.isViewing) {
return [];
}
@@ -353,10 +353,10 @@
}
});
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(navigationAssets, $viewingAsset),
previousAsset: getPreviousAsset(navigationAssets, $viewingAsset),
const assetCursor = $derived<AssetCursor>({
current: assetViewerManager.asset!,
nextAsset: getNextAsset(navigationAssets, assetViewerManager.asset),
previousAsset: getPreviousAsset(navigationAssets, assetViewerManager.asset),
});
</script>
@@ -410,7 +410,7 @@
{/if}
<!-- Overlay Asset Viewer -->
{#if $isViewerOpen}
{#if assetViewerManager.isViewing}
<Portal target="body">
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
@@ -419,7 +419,7 @@
onRandom={handleRandom}
onAssetChange={updateCurrentAsset}
onClose={() => {
assetViewingStore.showAssetViewer(false);
assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
@@ -11,6 +11,7 @@
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
@@ -18,7 +19,6 @@
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
@@ -88,10 +88,7 @@
onDestroy(() => timelineManager.destroy());
$effect(() => options && void timelineManager.updateOptions(options));
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
let scrollableElement: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state();
let invisible = $state(true);
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
@@ -209,7 +206,7 @@
timelineManager.viewportWidth = rect.width;
}
}
const scrollTarget = $gridScrollTarget?.at;
const scrollTarget = assetViewerManager.gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget);
@@ -518,8 +515,8 @@
});
$effect(() => {
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
if (assetViewerManager.asset && assetViewerManager.isViewing) {
const { localDateTime } = getTimes(assetViewerManager.asset.fileCreatedAt, DateTime.local().offset / 60);
void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month });
}
});
@@ -565,7 +562,7 @@
onAfterUpdate={() => {
const asset = page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
assetViewerManager.gridScrollTarget = { at: asset };
}
void scrollAfterNavigate();
}}
@@ -722,7 +719,7 @@
</section>
<Portal target="body">
{#if $showAssetViewer}
{#if assetViewerManager.isViewing}
<TimelineAssetViewer bind:invisible {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
{/if}
</Portal>
@@ -2,11 +2,11 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import { AssetAction } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
@@ -18,8 +18,6 @@
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
invisible: boolean;
@@ -65,7 +63,7 @@
};
let assetCursor = $state<AssetCursor>({
current: $viewingAsset,
current: assetViewerManager.asset!,
previousAsset: undefined,
nextAsset: undefined,
});
@@ -82,9 +80,10 @@
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => handlePromiseError(loadCloseAssets($viewingAsset)));
const asset = assetViewerManager.asset;
if (asset) {
untrack(() => handlePromiseError(loadCloseAssets(asset)));
}
});
const handleRandom = async () => {
@@ -99,8 +98,26 @@
const handleClose = async (asset: { id: string }) => {
invisible = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
assetViewerManager.gridScrollTarget = { at: asset.id };
await navigate({
targetRoute: 'current',
assetId: null,
assetGridRouteSearchParams: assetViewerManager.gridScrollTarget,
});
};
const handleRemoveFromAlbum = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
if (!assetIds.includes(assetCursor.current.id)) {
return;
}
// keep the cleanup workflow in viewer by moving to adjacent asset first
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(assetCursor.current));
};
const handlePreAction = async (action: Action) => {
@@ -188,7 +205,7 @@
const restoredAsset = assets[0];
const asset = await getAssetInfo({ ...authManager.params, id: restoredAsset.id });
assetViewingStore.setAsset(asset);
assetViewerManager.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
};
@@ -232,6 +249,7 @@
}}
onUndoDelete={handleUndoDelete}
onRandom={handleRandom}
onRemoveFromAlbum={handleRemoveFromAlbum}
onClose={handleClose}
/>
{/await}
@@ -1,26 +1,24 @@
<script lang="ts">
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import GeolocationPointPickerModal from '$lib/modals/GeolocationPointPickerModal.svelte';
import { user } from '$lib/stores/user.store';
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
import { getAssetControlContext } from '$lib/utils/context';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
type Props = {
menuItem?: boolean;
}
};
let { menuItem = false }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
let isShowChangeLocation = $state(false);
async function handleConfirm(point?: { lng: number; lat: number }) {
isShowChangeLocation = false;
const onAction = async () => {
const point = await modalManager.show(GeolocationPointPickerModal, {});
if (!point) {
return;
}
@@ -29,20 +27,14 @@
try {
await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } });
toastManager.primary();
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_update_location'));
}
}
};
</script>
{#if menuItem}
<MenuOption
text={$t('change_location')}
icon={mdiMapMarkerMultipleOutline}
onClick={() => (isShowChangeLocation = true)}
/>
{/if}
{#if isShowChangeLocation}
<ChangeLocation onClose={handleConfirm} />
<MenuOption text={$t('change_location')} icon={mdiMapMarkerMultipleOutline} onClick={onAction} />
{/if}
@@ -10,16 +10,19 @@
interface Props {
album: AlbumResponseDto;
onRemove: ((assetIds: string[]) => void) | undefined;
assetIds?: string[];
menuItem?: boolean;
}
let { album = $bindable(), onRemove, menuItem = false }: Props = $props();
let { album = $bindable(), onRemove, assetIds, menuItem = false }: Props = $props();
const { getAssets, clearSelect } = getAssetControlContext();
const context = getAssetControlContext();
const removeFromAlbum = async () => {
const ids = assetIds ?? context?.getAssets().map(({ id }) => id) ?? [];
const isConfirmed = await modalManager.showDialog({
prompt: $t('remove_assets_album_confirmation', { values: { count: getAssets().length } }),
prompt: $t('remove_assets_album_confirmation', { values: { count: ids.length } }),
});
if (!isConfirmed) {
@@ -27,7 +30,6 @@
}
try {
const ids = [...getAssets()].map((a) => a.id);
const results = await removeAssetFromAlbum({
id: album.id,
bulkIdsDto: { ids },
@@ -40,7 +42,7 @@
const count = results.filter(({ success }) => success).length;
toastManager.primary($t('assets_removed_count', { values: { count } }));
clearSelect();
context?.clearSelect();
} catch (error) {
handleError(error, $t('errors.error_removing_assets_from_album'));
}
@@ -5,6 +5,7 @@
setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit,
} from '$lib/components/timeline/actions/focus-actions';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
@@ -14,7 +15,6 @@
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte';
import { handlePromiseError } from '$lib/utils';
@@ -32,8 +32,6 @@
let { timelineManager = $bindable(), assetInteraction, onEscape, scrollToAsset }: Props = $props();
const { isViewing: showAssetViewer } = assetViewingStore;
const trashOrDelete = async (forceRequested?: boolean) => {
const force = forceRequested || !featureFlagsManager.value.trash;
const selectedAssets = assetInteraction.selectedAssets;
@@ -144,7 +142,7 @@
};
const shortcutList = $derived.by(() => {
if (searchStore.isSearchEnabled || $showAssetViewer || isModalOpen()) {
if (searchStore.isSearchEnabled || assetViewerManager.isViewing || isModalOpen()) {
return [];
}
@@ -2,8 +2,8 @@
import { shortcuts } from '$lib/actions/shortcut';
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
import Portal from '$lib/elements/Portal.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
@@ -22,8 +22,6 @@
}
let { assets, onResolve, onStack }: Props = $props();
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
// eslint-disable-next-line svelte/no-unnecessary-state-wrap
let selectedAssetIds = $state(new SvelteSet<string>());
let trashCount = $derived(assets.length - selectedAssetIds.size);
@@ -40,7 +38,7 @@
});
onDestroy(() => {
assetViewingStore.showAssetViewer(false);
assetViewerManager.showAssetViewer(false);
});
const onRandom = async () => {
@@ -71,7 +69,7 @@
const onViewAsset = async ({ id }: AssetResponseDto) => {
const asset = await getAssetInfo({ ...authManager.params, id });
setAsset(asset);
assetViewerManager.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: asset.id });
};
@@ -86,9 +84,9 @@
};
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(assets, $viewingAsset),
previousAsset: getPreviousAsset(assets, $viewingAsset),
current: assetViewerManager.asset!,
nextAsset: getNextAsset(assets, assetViewerManager.asset),
previousAsset: getPreviousAsset(assets, assetViewerManager.asset),
});
</script>
@@ -166,7 +164,7 @@
</div>
</div>
{#if $showAssetViewer}
{#if assetViewerManager.isViewing}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
@@ -174,7 +172,7 @@
showNavigation={assets.length > 1}
{onRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);
assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
+17 -1
View File
@@ -1,4 +1,4 @@
import { cleanClass } from '$lib';
import { cleanClass, isDefined } from '$lib';
describe('cleanClass', () => {
it('should return a string of class names', () => {
@@ -13,3 +13,19 @@ describe('cleanClass', () => {
expect(cleanClass('class1', ['class2', 'class3'])).toBe('class1 class2 class3');
});
});
describe('isDefined', () => {
it('should return false for null', () => {
expect(isDefined(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(isDefined(undefined)).toBe(false);
});
it('should return true for everything else', () => {
for (const value of [0, 1, 2, true, false, {}, 'foo', 'bar', []]) {
expect(isDefined(value)).toBe(true);
}
});
});
+2
View File
@@ -14,3 +14,5 @@ export const cleanClass = (...classNames: unknown[]) => {
.join(' '),
);
};
export const isDefined = <T>(value: T): value is NonNullable<T> => value !== null && value !== undefined;
@@ -1,7 +1,10 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { ImageLoaderStatus } from '$lib/utils/adaptive-image-loader.svelte';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import type { ZoomImageWheelState } from '@zoom-image/core';
import { cubicOut } from 'svelte/easing';
@@ -21,7 +24,7 @@ export type Events = {
Copy: [];
};
export class AssetViewerManager extends BaseEventManager<Events> {
class AssetViewerManager extends BaseEventManager<Events> {
#zoomState = $state(createDefaultZoomState());
#animationFrameId: number | null = null;
@@ -40,6 +43,18 @@ export class AssetViewerManager extends BaseEventManager<Events> {
isPlayingMotionPhoto = $state(false);
isShowEditor = $state(false);
#viewingAssetStoreState = $state<AssetResponseDto>();
#viewState = $state<boolean>(false);
gridScrollTarget = $state<AssetGridRouteSearchParams | null | undefined>();
get asset() {
return this.#viewingAssetStoreState;
}
get isViewing() {
return this.#viewState;
}
get isImageLoading() {
return this.#isImageLoading;
}
@@ -145,6 +160,21 @@ export class AssetViewerManager extends BaseEventManager<Events> {
closeEditor() {
this.isShowEditor = false;
}
setAsset(asset: AssetResponseDto) {
this.#viewingAssetStoreState = asset;
this.#viewState = true;
}
async setAssetId(id: string): Promise<AssetResponseDto> {
const asset = await getAssetInfo({ ...authManager.params, id });
this.setAsset(asset);
return asset;
}
showAssetViewer(show: boolean) {
this.#viewState = show;
}
}
export const assetViewerManager = new AssetViewerManager();
@@ -0,0 +1,15 @@
import type { LatLng } from '$lib/types';
class GeolocationManager {
#lastPoint = $state<LatLng>();
get lastPoint() {
return this.#lastPoint;
}
onSelected(point: LatLng) {
this.#lastPoint = point;
}
}
export const geolocationManager = new GeolocationManager();
@@ -21,7 +21,7 @@ export type MemoryAsset = MemoryIndex & {
nextMemory?: MemoryResponseDto;
};
class MemoryStoreSvelte {
class MemoryManager {
#loading: Promise<void> | undefined;
constructor() {
@@ -135,4 +135,4 @@ class MemoryStoreSvelte {
}
}
export const memoryStore = new MemoryStoreSvelte();
export const memoryManager = new MemoryManager();
+4 -1
View File
@@ -28,7 +28,10 @@
let { onClose }: Props = $props();
onMount(async () => {
albums = await getAllAlbums({});
// TODO the server should *really* just return all albums (paginated ideally)
const ownedAlbums = await getAllAlbums({ shared: false });
ownedAlbums.push.apply(ownedAlbums, await getAllAlbums({ shared: true }));
albums = ownedAlbums;
recentAlbums = albums.sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3);
loading = false;
});
@@ -0,0 +1,67 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
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 { DateTime } from 'luxon';
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
import AssetChangeDateModal from './AssetChangeDateModal.svelte';
describe('AssetChangeDateModal component', () => {
const initialDate = DateTime.fromISO('2026-03-19T23:31:30.112');
const initialTimeZone = 'Europe/Lisbon';
const onClose = vi.fn();
const getDateInput = async () => (await screen.findByDisplayValue('2026-03-19T23:31:30.112')) as HTMLInputElement;
const getTimeZoneInput = () => screen.getByRole('combobox', { name: /timezone/i }) as HTMLInputElement;
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.stubGlobal('visualViewport', getVisualViewportMock());
vi.resetAllMocks();
Element.prototype.animate = getAnimateMock();
});
afterAll(async () => {
await waitFor(() => {
expect(document.body.style.pointerEvents).not.toBe('none');
});
});
test('preserves the selected timezone when changing the datetime', async () => {
render(AssetChangeDateModal, {
props: {
initialDate,
initialTimeZone,
timezoneInput: true,
asset: { id: 'asset-id' } as never,
onClose,
},
});
const timezoneInput = getTimeZoneInput();
const datetimeInput = await getDateInput();
const initialTimezoneValue = timezoneInput.value;
await fireEvent.focus(timezoneInput);
await fireEvent.input(timezoneInput, { target: { value: 'Pacific/Pitcairn' } });
const option = await screen.findByText(/Pacific\/Pitcairn/i);
await fireEvent.click(option);
expect(timezoneInput.value).toBe('Pacific/Pitcairn (-08:00)');
expect(timezoneInput.value).not.toBe(initialTimezoneValue);
const beforeDatetime = datetimeInput.value;
await fireEvent.input(datetimeInput, {
target: { value: '2026-03-19T23:31:31.113' },
});
await fireEvent.change(datetimeInput, {
target: { value: '2026-03-19T23:31:31.113' },
});
expect(datetimeInput.value).not.toBe(beforeDatetime);
expect(timezoneInput.value).toBe('Pacific/Pitcairn (-08:00)');
});
});
+13 -5
View File
@@ -23,10 +23,7 @@
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));
let selectedOption = $state(getPreferredTimeZone(initialDate, initialTimeZone, getTimezones(selectedDate)));
const onSubmit = async () => {
if (!date.isValid || !selectedOption) {
@@ -45,6 +42,12 @@
}
};
const updateSelectedDate = (value: string) => {
selectedDate = value;
selectedOption = getPreferredTimeZone(initialDate, initialTimeZone, getTimezones(value), selectedOption);
};
// 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>
@@ -59,7 +62,12 @@
size="small"
>
<Label for="datetime" class="block mb-1">{$t('date_and_time')}</Label>
<DateInput class="immich-form-input w-full mb-2" id="datetime" type="datetime-local" bind:value={selectedDate} />
<DateInput
class="immich-form-input w-full mb-2"
id="datetime"
type="datetime-local"
bind:value={() => selectedDate, updateSelectedDate}
/>
{#if timezoneInput}
<div class="w-full">
<Combobox bind:selectedOption label={$t('timezone')} options={timezones} placeholder={$t('search_timezone')} />
@@ -1,30 +1,27 @@
<script lang="ts">
import { isDefined } from '$lib';
import { clickOutside } from '$lib/actions/click-outside';
import { listNavigation } from '$lib/actions/list-navigation';
import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte';
import type Map from '$lib/components/shared-components/map/map.svelte';
import { timeDebounceOnSearch, timeToLoadTheMap } from '$lib/constants';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { lastChosenLocation } from '$lib/stores/asset-editor.store';
import { geolocationManager } from '$lib/managers/geolocation.manager.svelte';
import type { LatLng } from '$lib/types';
import { delay } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
import { ConfirmModal, LoadingSpinner } from '@immich/ui';
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
interface Point {
lng: number;
lat: number;
}
interface Props {
asset?: AssetResponseDto | undefined;
point?: Point;
onClose: (point?: Point) => void;
}
type Props = {
asset?: AssetResponseDto;
point?: LatLng;
onClose: (point?: LatLng) => void;
};
let { asset = undefined, point: initialPoint, onClose }: Props = $props();
let { asset, point: initialPoint, onClose }: Props = $props();
let places: PlacesResponseDto[] = $state([]);
let suggestedPlaces: PlacesResponseDto[] = $derived(places.slice(0, 5));
@@ -35,15 +32,22 @@
let hideSuggestion = $state(false);
let mapElement = $state<ReturnType<typeof Map>>();
let previousLocation = get(lastChosenLocation);
let assetPoint = $derived.by<LatLng | undefined>(() => {
if (!asset || !asset.exifInfo) {
return;
}
let assetLat = $derived(initialPoint?.lat ?? asset?.exifInfo?.latitude ?? undefined);
let assetLng = $derived(initialPoint?.lng ?? asset?.exifInfo?.longitude ?? undefined);
const { latitude, longitude } = asset.exifInfo;
if (!isDefined(latitude) || !isDefined(longitude)) {
return;
}
let mapLat = $derived(assetLat ?? previousLocation?.lat ?? undefined);
let mapLng = $derived(assetLng ?? previousLocation?.lng ?? undefined);
return { lat: latitude, lng: longitude };
});
let zoom = $derived(mapLat && mapLng ? 12.5 : 1);
let point = $state<LatLng | undefined>(initialPoint ?? assetPoint);
let zoom = $state(point ? 12.5 : 1);
let center = $state(point ?? geolocationManager.lastPoint);
$effect(() => {
if (mapElement && initialPoint) {
@@ -57,11 +61,9 @@
}
});
let point: Point | null = $state(initialPoint ?? null);
const handleConfirm = (confirmed?: boolean) => {
if (point && confirmed) {
lastChosenLocation.set(point);
geolocationManager.onSelected(point);
onClose(point);
} else {
onClose();
@@ -201,12 +203,12 @@
{:then { default: Map }}
<Map
bind:this={mapElement}
mapMarkers={assetLat !== undefined && assetLng !== undefined && asset
mapMarkers={asset && assetPoint
? [
{
id: asset.id,
lat: assetLat,
lon: assetLng,
lat: assetPoint.lat,
lon: assetPoint.lng,
city: asset.exifInfo?.city ?? null,
state: asset.exifInfo?.state ?? null,
country: asset.exifInfo?.country ?? null,
@@ -214,7 +216,7 @@
]
: []}
{zoom}
center={mapLat && mapLng ? { lat: mapLat, lng: mapLng } : undefined}
{center}
simplified={true}
clickable={true}
onClickPoint={(selected) => (point = selected)}
@@ -225,7 +227,7 @@
</div>
<div class="grid sm:grid-cols-2 gap-4 text-sm text-start mt-4">
<CoordinatesInput lat={point ? point.lat : assetLat} lng={point ? point.lng : assetLng} {onUpdate} />
<CoordinatesInput lat={point?.lat} lng={point?.lng} {onUpdate} />
</div>
</div>
{/snippet}
@@ -1,20 +1,21 @@
<script lang="ts">
import type { LatLng } from '$lib/types';
import { ConfirmModal } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
location: { latitude: number | undefined; longitude: number | undefined };
type Props = {
point: LatLng;
assetCount: number;
onClose: (confirm: boolean) => void;
}
};
let { location, assetCount, onClose }: Props = $props();
let { point, assetCount, onClose }: Props = $props();
</script>
<ConfirmModal title={$t('confirm')} size="small" confirmColor="primary" {onClose}>
{#snippet prompt()}
<p>{$t('update_location_action_prompt', { values: { count: assetCount } })}</p>
<p>- {$t('latitude')}: {location.latitude}</p>
<p>- {$t('longitude')}: {location.longitude}</p>
<p>- {$t('latitude')}: {point.lat}</p>
<p>- {$t('longitude')}: {point.lng}</p>
{/snippet}
</ConfirmModal>
+5 -23
View File
@@ -1,5 +1,4 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@@ -138,16 +137,8 @@ const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: strin
description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } });
}
toastManager.custom(
{
component: ToastAction,
props: {
title: $t('info'),
color: 'primary',
description,
button: { text: $t('view_album'), color: 'primary', onClick: () => goto(Route.viewAlbum({ id: albumId })) },
},
},
toastManager.primary(
{ description, button: { label: $t('view_album'), onclick: () => goto(Route.viewAlbum({ id: albumId })) } },
{ timeout: 5000 },
);
};
@@ -229,18 +220,9 @@ export const handleUpdateAlbum = async ({ id }: { id: string }, dto: UpdateAlbum
try {
const response = await updateAlbumInfo({ id, updateAlbumDto: dto });
eventManager.emit('AlbumUpdate', response);
toastManager.custom({
component: ToastAction,
props: {
color: 'primary',
title: $t('success'),
description: $t('album_info_updated'),
button: {
text: $t('view_album'),
color: 'primary',
onClick: () => goto(Route.viewAlbum({ id })),
},
},
toastManager.primary({
description: $t('album_info_updated'),
button: { label: $t('view_album'), onclick: () => goto(Route.viewAlbum({ id })) },
});
return true;
+6 -12
View File
@@ -8,17 +8,16 @@ import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
import type { AssetControlContext } from '$lib/types';
import { getSharedLink, sleep } from '$lib/utils';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { asQueryString } from '$lib/utils/shared-links';
import {
AssetJobName,
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAssetInfo,
getBaseUrl,
runAssetJobs,
updateAsset,
type AssetJobsDto,
@@ -308,6 +307,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
{
filename: asset.originalFileName,
id: asset.id,
cacheKey: asset.thumbhash,
},
];
@@ -321,13 +321,12 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
assets.push({
filename: motionAsset.originalFileName,
id: asset.livePhotoVideoId,
cacheKey: motionAsset.thumbhash,
});
}
}
const queryParams = asQueryString(authManager.params);
for (const [i, { filename, id }] of assets.entries()) {
for (const [i, { filename, id, cacheKey }] of assets.entries()) {
if (i !== 0) {
// play nice with Safari
await sleep(500);
@@ -335,12 +334,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
try {
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
downloadUrl(
getBaseUrl() +
`/assets/${id}/original` +
(queryParams ? `?${queryParams}&edited=${edited}` : `?edited=${edited}`),
filename,
);
downloadUrl(getAssetMediaUrl({ id, size: AssetMediaSize.Original, edited, cacheKey }), filename);
} catch (error) {
handleError(error, $t('errors.error_downloading', { values: { filename } }));
}
@@ -1,10 +0,0 @@
import { writable } from 'svelte/store';
function createAlbumAssetSelectionStore() {
const isAlbumAssetSelectionOpen = writable<boolean>(false);
return {
isAlbumAssetSelectionOpen,
};
}
export const albumAssetSelectionStore = createAlbumAssetSelectionStore();
-4
View File
@@ -1,4 +0,0 @@
import { writable } from 'svelte/store';
//-----other
export const lastChosenLocation = writable<{ lng: number; lat: number } | null>(null);
-36
View File
@@ -1,36 +0,0 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { readonly, writable } from 'svelte/store';
function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>();
const viewState = writable<boolean>(false);
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
const setAsset = (asset: AssetResponseDto) => {
viewingAssetStoreState.set(asset);
viewState.set(true);
};
const setAssetId = async (id: string): Promise<AssetResponseDto> => {
const asset = await getAssetInfo({ ...authManager.params, id });
setAsset(asset);
return asset;
};
const showAssetViewer = (show: boolean) => {
viewState.set(show);
};
return {
asset: readonly(viewingAssetStoreState),
isViewing: viewState,
gridScrollTarget,
setAsset,
setAssetId,
showAssetViewer,
};
}
export const assetViewingStore = createAssetViewingStore();
+28 -1
View File
@@ -80,7 +80,34 @@ function createUploadStore() {
};
const removeItem = (id: string) => {
uploadAssets.update((uploadingAsset) => uploadingAsset.filter((a) => a.id != id));
uploadAssets.update((uploadingAsset) => {
const assetToRemove = uploadingAsset.find((a) => a.id === id);
if (assetToRemove) {
stats.update((stats) => {
switch (assetToRemove.state) {
case UploadState.DONE: {
stats.success--;
break;
}
case UploadState.DUPLICATED: {
stats.duplicates--;
break;
}
case UploadState.ERROR: {
stats.errors--;
break;
}
}
stats.total--;
return stats;
});
}
return uploadingAsset.filter((a) => a.id != id);
});
};
const dismissErrors = () =>
+2
View File
@@ -5,6 +5,8 @@ import type { ActionItem } from '@immich/ui';
import type { DateTime } from 'luxon';
import type { SvelteSet } from 'svelte/reactivity';
export type LatLng = { lng: number; lat: number };
export interface ReleaseEvent {
isAvailable: boolean;
/** ISO8601 */
+8 -18
View File
@@ -1,4 +1,3 @@
import ToastAction from '$lib/components/ToastAction.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { StackResponse } from '$lib/utils/asset-utils';
@@ -32,24 +31,15 @@ export const deleteAssets = async (
await deleteBulk({ assetBulkDeleteDto: { ids, force } });
onAssetDelete(ids);
toastManager.custom(
toastManager.primary(
{
component: ToastAction,
props: {
title: $t('success'),
description: force
? $t('assets_permanently_deleted_count', { values: { count: ids.length } })
: $t('assets_trashed_count', { values: { count: ids.length } }),
color: 'success',
button:
onUndoDelete && !force
? {
color: 'secondary',
text: $t('undo'),
onClick: () => undoDeleteAssets(onUndoDelete, assets),
}
: undefined,
},
description: force
? $t('assets_permanently_deleted_count', { values: { count: ids.length } })
: $t('assets_trashed_count', { values: { count: ids.length } }),
button:
onUndoDelete && !force
? { label: $t('undo'), color: 'secondary', onclick: () => undoDeleteAssets(onUndoDelete, assets) }
: undefined,
},
{ timeout: 5000 },
);
+5 -11
View File
@@ -1,4 +1,3 @@
import ToastAction from '$lib/components/ToastAction.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
@@ -326,16 +325,11 @@ export const stackAssets = async (assets: { id: string }[], showNotification = t
try {
const stack = await createStack({ stackCreateDto: { assetIds: assets.map(({ id }) => id) } });
if (showNotification) {
toastManager.custom({
component: ToastAction,
props: {
title: $t('success'),
description: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
color: 'success',
button: {
text: $t('view_stack'),
onClick: () => navigate({ targetRoute: 'current', assetId: stack.primaryAssetId }),
},
toastManager.primary({
description: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
button: {
label: $t('view_stack'),
onclick: () => navigate({ targetRoute: 'current', assetId: stack.primaryAssetId }),
},
});
}
+1 -1
View File
@@ -216,7 +216,7 @@ async function fileUploader({
uploadAssetsStore.track('success');
}
if (albumId) {
if (albumId && !authManager.isSharedLink) {
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') });
await addAssetsToAlbums([albumId], [responseData.id], { notify: false });
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') });
+5 -7
View File
@@ -1,30 +1,28 @@
<script lang="ts">
import { page } from '$app/state';
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import type { Snippet } from 'svelte';
interface Props {
children?: Snippet;
}
let { children }: Props = $props();
let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
// $page.data.asset is loaded by route specific +page.ts loaders if that
// route contains the assetId path.
$effect.pre(() => {
if (page.data.asset) {
setAsset(page.data.asset);
assetViewerManager.setAsset(page.data.asset);
} else {
$showAssetViewer = false;
assetViewerManager.showAssetViewer(false);
}
const asset = page.url.searchParams.get('at');
$gridScrollTarget = { at: asset };
assetViewerManager.gridScrollTarget = { at: asset };
});
</script>
<div class:display-none={$showAssetViewer}>
<div class:display-none={assetViewerManager.isViewing}>
{@render children?.()}
</div>
<UploadCover />
@@ -46,7 +46,6 @@
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { preferences, user } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
@@ -86,14 +85,9 @@
}
let { data = $bindable() }: Props = $props();
let { isViewing: showAssetViewer, setAssetId, gridScrollTarget } = assetViewingStore;
let { slideshowState, slideshowNavigation } = slideshowStore;
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
let timelineManager = $state<TimelineManager>() as TimelineManager;
let showAlbumUsers = $derived(timelineManager?.showAssetOwners ?? false);
@@ -114,7 +108,9 @@
? await timelineManager.getRandomAsset()
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
if (asset) {
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
handlePromiseError(
assetViewerManager.setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)),
);
}
};
@@ -128,7 +124,7 @@
await handleCloseSelectAssets();
return;
}
if ($showAssetViewer) {
if (assetViewerManager.isViewing) {
return;
}
if (assetInteraction.selectionActive) {
@@ -240,7 +236,7 @@
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
$effect(() => {
if ($showAssetViewer || !isShared) {
if (assetViewerManager.isViewing || !isShared) {
return;
}
@@ -252,7 +248,9 @@
let isOwned = $derived($user.id == album.ownerId);
let showActivityStatus = $derived(
album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || activityManager.commentCount > 0),
album.albumUsers.length > 0 &&
!assetViewerManager.isViewing &&
(album.isActivityEnabled || activityManager.commentCount > 0),
);
let isEditor = $derived(
album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor ||
@@ -287,7 +285,11 @@
}
};
const onAlbumAddAssets = async () => {
const onAlbumAddAssets = async ({ albumIds }: { albumIds: string[] }) => {
if (!albumIds.includes(album.id)) {
return;
}
await refreshAlbum();
timelineInteraction.clearMultiselect();
await setModeToView();
@@ -318,7 +320,7 @@
type: $t('command'),
icon: mdiArrowLeft,
onAction: handleEscape,
$if: () => !$showAssetViewer,
$if: () => !assetViewerManager.isViewing,
shortcuts: { key: 'Escape' },
});
</script>
@@ -472,13 +474,6 @@
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
{#if assetInteraction.selectedAssets.length === 1}
<MenuOption
text={$t('set_as_album_cover')}
icon={mdiImageOutline}
onClick={() => updateThumbnailUsingCurrentSelection()}
/>
{/if}
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
@@ -486,6 +481,13 @@
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
{/if}
{#if assetInteraction.selectedAssets.length === 1}
<MenuOption
text={$t('set_as_album_cover')}
icon={mdiImageOutline}
onClick={() => updateThumbnailUsingCurrentSelection()}
/>
{/if}
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
@@ -514,7 +516,7 @@
onclick={async () => {
timelineManager.suspendTransitions = true;
viewMode = AlbumPageViewMode.SELECT_ASSETS;
oldAt = { at: $gridScrollTarget?.at };
oldAt = { at: assetViewerManager.gridScrollTarget?.at };
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: null } },
{ replaceState: true },
@@ -617,7 +619,7 @@
{/if}
{/if}
</div>
{#if album.albumUsers.length > 0 && album && assetViewerManager.isShowActivityPanel && $user && !$showAssetViewer}
{#if album.albumUsers.length > 0 && album && assetViewerManager.isShowActivityPanel && $user && !assetViewerManager.isViewing}
<div class="flex">
<div
transition:fly={{ duration: 150 }}
@@ -5,9 +5,9 @@
import type { SelectionBBox } from '$lib/components/shared-components/map/types';
import { timeToLoadTheMap } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { delay } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
@@ -20,9 +20,6 @@
}
let { data }: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
let selectedClusterIds = $state.raw(new Set<string>());
let selectedClusterBBox = $state.raw<SelectionBBox>();
let isTimelinePanelVisible = $state(false);
@@ -34,7 +31,7 @@
}
onDestroy(() => {
assetViewingStore.showAssetViewer(false);
assetViewerManager.showAssetViewer(false);
});
if (!featureFlagsManager.value.map) {
@@ -42,7 +39,7 @@
}
async function onViewAssets(assetIds: string[]) {
await setAssetId(assetIds[0]);
await assetViewerManager.setAssetId(assetIds[0]);
closeTimelinePanel();
}
@@ -50,7 +47,7 @@
selectedClusterIds = new Set(assetIds);
selectedClusterBBox = bbox;
isTimelinePanelVisible = true;
assetViewingStore.showAssetViewer(false);
assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}
</script>
@@ -89,13 +86,13 @@
</div>
</UserPageLayout>
<Portal target="body">
{#if $showAssetViewer}
{#if assetViewerManager.isViewing}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
cursor={{ current: $viewingAsset }}
cursor={{ current: assetViewerManager.asset! }}
showNavigation={false}
onClose={() => {
assetViewingStore.showAssetViewer(false);
assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
isShared={false}
@@ -20,13 +20,13 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { memoryStore } from '$lib/stores/memory.store.svelte';
import { preferences, user } from '$lib/stores/user.store';
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
import {
@@ -43,7 +43,6 @@
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore;
let timelineManager = $state<TimelineManager>() as TimelineManager;
const options = { visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true };
@@ -62,7 +61,7 @@
});
const handleEscape = () => {
if ($showAssetViewer) {
if (assetViewerManager.isViewing) {
return;
}
if (assetInteraction.selectionActive) {
@@ -91,7 +90,7 @@
});
const items = $derived(
memoryStore.memories.map((memory) => ({
memoryManager.memories.map((memory) => ({
id: memory.id,
title: $memoryLaneTitle(memory),
href: Route.memories({ id: memory.assets[0].id }),
@@ -4,11 +4,11 @@
import { shortcuts } from '$lib/actions/shortcut';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { locale } from '$lib/stores/preferences.store';
import { stackAssets } from '$lib/utils/asset-utils';
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
@@ -57,7 +57,6 @@
};
let duplicates = $state(data.duplicates);
const { isViewing: showAssetViewer } = assetViewingStore;
const correctDuplicatesIndex = (index: number) => {
return Math.max(0, Math.min(index, duplicates.length - 1));
@@ -178,19 +177,7 @@
const handleFirst = () => navigateToIndex(0);
const handlePrevious = () => navigateToIndex(Math.max(duplicatesIndex - 1, 0));
const handlePreviousShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handlePrevious();
};
const handleNext = async () => navigateToIndex(Math.min(duplicatesIndex + 1, duplicates.length - 1));
const handleNextShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handleNext();
};
const handleLast = () => navigateToIndex(duplicates.length - 1);
const navigateToIndex = async (index: number) =>
@@ -198,10 +185,12 @@
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut },
]}
use:shortcuts={assetViewerManager.isViewing
? []
: [
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePrevious },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNext },
]}
/>
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
@@ -1,6 +1,6 @@
<script lang="ts">
import { isDefined } from '$lib';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants';
@@ -8,8 +8,10 @@
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import GeolocationPointPickerModal from '$lib/modals/GeolocationPointPickerModal.svelte';
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { LatLng } from '$lib/types';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { setQueryValue } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -19,15 +21,15 @@
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
type Props = {
data: PageData;
}
};
let { data }: Props = $props();
let isLoading = $state(false);
let assetInteraction = new AssetInteraction();
let location = $state<{ latitude: number; longitude: number }>({ latitude: 0, longitude: 0 });
let point = $state<LatLng>();
let locationUpdated = $state(false);
let timelineManager = $state<TimelineManager>() as TimelineManager;
@@ -39,8 +41,12 @@
};
const handleUpdate = async () => {
if (!point) {
return;
}
const confirmed = await modalManager.show(GeolocationUpdateConfirmModal, {
location: location ?? { latitude: 0, longitude: 0 },
point,
assetCount: assetInteraction.selectedAssets.length,
});
@@ -51,8 +57,8 @@
await updateAssets({
assetBulkUpdateDto: {
ids: assetInteraction.selectedAssets.map((asset) => asset.id),
latitude: location?.latitude ?? undefined,
longitude: location?.longitude ?? undefined,
latitude: point.lat,
longitude: point.lng,
},
});
@@ -86,18 +92,13 @@
cancelMultiselect(assetInteraction);
};
const handlePickOnMap = async () => {
const point = await modalManager.show(ChangeLocation, {
point: {
lat: location.latitude,
lng: location.longitude,
},
});
if (!point) {
const handlePickPoint = async () => {
const selected = await modalManager.show(GeolocationPointPickerModal, { point });
if (!selected) {
return;
}
location = { latitude: point.lat, longitude: point.lng };
point = selected;
};
const handleEscape = () => {
if (assetInteraction.selectionActive) {
@@ -106,9 +107,10 @@
}
};
const hasGps = (asset: TimelineAsset) => {
return !!asset.latitude && !!asset.longitude;
};
type AssetPoint = { latitude: number; longitude: number };
const hasGps = (asset: TimelineAsset | AssetPoint): asset is AssetPoint =>
isDefined(asset.latitude) && isDefined(asset.longitude);
const handleThumbnailClick = (
asset: TimelineAsset,
@@ -126,7 +128,7 @@
setTimeout(() => {
locationUpdated = false;
}, 1500);
location = { latitude: asset.latitude!, longitude: asset.longitude! };
point = { lat: asset.latitude, lng: asset.longitude };
void setQueryValue('at', asset.id);
} else {
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
@@ -148,11 +150,17 @@
title="latitude, longitude"
class="rounded-3xl font-mono text-sm text-primary px-2 py-1 transition-all duration-100 ease-in-out {locationUpdated
? 'bg-primary/90 text-light font-semibold scale-105'
: ''}">{location.latitude.toFixed(3)}, {location.longitude.toFixed(3)}</Text
: ''}"
>
{#if point}
{point.lat.toFixed(3)}, {point.lng.toFixed(3)}
{:else}
{$t('none')}
{/if}
</Text>
</div>
<Button size="small" color="secondary" variant="ghost" leadingIcon={mdiPencilOutline} onclick={handlePickOnMap}>
<Button size="small" color="secondary" variant="ghost" leadingIcon={mdiPencilOutline} onclick={handlePickPoint}>
<Text class="hidden sm:inline-block">{$t('location_picker_choose_on_map')}</Text>
</Button>
<Button
@@ -3,7 +3,7 @@
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import LargeAssetData from '$lib/components/utilities-page/large-assets/large-asset-data.svelte';
import Portal from '$lib/elements/Portal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { handlePromiseError } from '$lib/utils';
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
@@ -19,10 +19,10 @@
let assets = $derived(data.assets);
let asset = $derived(data.asset);
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
$effect(() => {
if (asset) {
setAsset(asset);
assetViewerManager.setAsset(asset);
}
});
@@ -39,7 +39,7 @@
const onAction = (payload: Action) => {
if (payload.type == 'trash') {
assets = assets.filter((a) => a.id != payload.asset.id);
$showAssetViewer = false;
assetViewerManager.showAssetViewer(false);
}
};
@@ -48,9 +48,9 @@
};
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(assets, $viewingAsset),
previousAsset: getPreviousAsset(assets, $viewingAsset),
current: assetViewerManager.asset!,
nextAsset: getNextAsset(assets, assetViewerManager.asset),
previousAsset: getPreviousAsset(assets, assetViewerManager.asset),
});
</script>
@@ -68,7 +68,7 @@
</div>
</UserPageLayout>
{#if $showAssetViewer}
{#if assetViewerManager.isViewing}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
@@ -77,7 +77,7 @@
{onRandom}
{onAction}
onClose={() => {
assetViewingStore.showAssetViewer(false);
assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
+1 -1
View File
@@ -52,7 +52,7 @@
prompt_default: $t('are_you_sure_to_do_this'),
show_password: $t('show_password'),
hide_password: $t('hide_password'),
dark_theme: $t('dark_theme'),
dark_theme: themeManager.isDark ? $t('light_theme') : $t('dark_theme'),
open_menu: $t('open'),
command_palette_prompt_default: $t('command_palette_prompt'),
command_palette_to_select: $t('command_palette_to_select'),