From d7c28470ee8e105a68e48bbf58a63f1348896eca Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Sun, 21 Dec 2025 15:52:52 +0100 Subject: [PATCH 001/174] feat: focus jumped-to item in timeline (#24738) --- web/src/lib/components/timeline/Timeline.svelte | 6 +++++- .../lib/components/timeline/actions/focus-actions.ts | 11 +++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 6e21479acc..cc2c3ef162 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -13,6 +13,7 @@ import Skeleton from '$lib/elements/Skeleton.svelte'; import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; + import { focusAsset } from '$lib/components/timeline/actions/focus-actions'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types'; @@ -25,7 +26,7 @@ import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util'; import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk'; import { DateTime } from 'luxon'; - import { onDestroy, onMount, type Snippet } from 'svelte'; + import { onDestroy, onMount, tick, type Snippet } from 'svelte'; import type { UpdatePayload } from 'vite'; interface Props { @@ -226,6 +227,9 @@ if (!scrolled) { // if the asset is not found, scroll to the top timelineManager.scrollTo(0); + } else if (scrollTarget) { + await tick(); + focusAsset(scrollTarget); } invisible = false; }; diff --git a/web/src/lib/components/timeline/actions/focus-actions.ts b/web/src/lib/components/timeline/actions/focus-actions.ts index f0f9e2e50c..49f1eef767 100644 --- a/web/src/lib/components/timeline/actions/focus-actions.ts +++ b/web/src/lib/components/timeline/actions/focus-actions.ts @@ -21,11 +21,15 @@ export const focusPreviousAsset = () => const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement; +export const focusAsset = (assetId: string) => { + const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${assetId}"]`); + element?.focus(); +}; + export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => { const scrolled = scrollToAsset(asset); if (scrolled) { - const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`); - element?.focus(); + focusAsset(asset.id); } }; @@ -71,8 +75,7 @@ export const setFocusTo = async ( if (!invocation.isStillValid()) { return; } - const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`); - element?.focus(); + focusAsset(asset.id); } invocation.endInvocation(); From f053ce548da9df98f902236d5a6f0194dcd65d89 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Sun, 21 Dec 2025 20:35:21 +0100 Subject: [PATCH 002/174] fix: product keys wording in commercial guidelines faq (#24765) --- docs/docs/FAQ.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index 9dcfcac48b..2fa8fd12b0 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -22,7 +22,7 @@ For organizations seeking to resell Immich, we have established the following gu - Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team. -- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work. +- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase product keys directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work. When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app From dfdbb773cee380081a87aa8fc848f55e91d42340 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sun, 21 Dec 2025 21:10:22 -0500 Subject: [PATCH 003/174] fix(web): display jxl original (#24766) display jxl original --- web/src/lib/utils/asset-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index bc853c53e4..aa96d56aec 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -353,7 +353,7 @@ const supportedImageMimeTypes = new Set([ const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755 if (isSafari) { - supportedImageMimeTypes.add('image/heic').add('image/heif'); + supportedImageMimeTypes.add('image/heic').add('image/heif').add('image/jxl'); } /** From 165f9e15ee17d569782a9601162af93da32928ab Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 22 Dec 2025 10:04:08 -0500 Subject: [PATCH 004/174] feat: modal routes (#24726) feat: new user route --- web/src/lib/constants.ts | 6 +- web/src/lib/modals/LibraryRenameModal.svelte | 41 ---- web/src/lib/services/library.service.ts | 36 +-- web/src/lib/services/user-admin.service.ts | 9 +- web/src/lib/sidebars/AdminSidebar.svelte | 2 +- web/src/params/id.ts | 3 +- web/src/routes/+layout.svelte | 2 +- .../{+page.svelte => (list)/+layout.svelte} | 16 +- .../{+page.ts => (list)/+layout.ts} | 4 +- .../library-management/(list)/+page.svelte | 0 .../(list)/new/+page.svelte} | 18 +- .../library-management/(list)/new/+page.ts | 14 ++ .../library-management/[id]/+layout.svelte | 118 ++++++++++ .../admin/library-management/[id]/+layout.ts | 29 +++ .../library-management/[id]/+page.svelte | 105 --------- .../admin/library-management/[id]/+page.ts | 15 +- .../library-management/[id]/edit/+page.svelte | 35 +++ .../library-management/[id]/edit/+page.ts | 15 ++ .../routes/admin/users/(list)/+layout.svelte | 93 ++++++++ .../users/{+page.ts => (list)/+layout.ts} | 8 +- .../routes/admin/users/(list)/+page.svelte | 0 .../admin/users/(list)/new/+page.svelte} | 18 +- .../routes/admin/users/(list)/new/+page.ts | 14 ++ web/src/routes/admin/users/+page.svelte | 93 -------- .../routes/admin/users/[id]/+layout.svelte | 222 ++++++++++++++++++ web/src/routes/admin/users/[id]/+layout.ts | 38 +++ web/src/routes/admin/users/[id]/+page.svelte | 218 ----------------- web/src/routes/admin/users/[id]/+page.ts | 23 +- .../admin/users/[id]/edit/+page.svelte} | 32 +-- web/src/routes/admin/users/[id]/edit/+page.ts | 15 ++ 30 files changed, 672 insertions(+), 570 deletions(-) delete mode 100644 web/src/lib/modals/LibraryRenameModal.svelte rename web/src/routes/admin/library-management/{+page.svelte => (list)/+layout.svelte} (90%) rename web/src/routes/admin/library-management/{+page.ts => (list)/+layout.ts} (93%) create mode 100644 web/src/routes/admin/library-management/(list)/+page.svelte rename web/src/{lib/modals/LibraryCreateModal.svelte => routes/admin/library-management/(list)/new/+page.svelte} (77%) create mode 100644 web/src/routes/admin/library-management/(list)/new/+page.ts create mode 100644 web/src/routes/admin/library-management/[id]/+layout.svelte create mode 100644 web/src/routes/admin/library-management/[id]/+layout.ts create mode 100644 web/src/routes/admin/library-management/[id]/edit/+page.svelte create mode 100644 web/src/routes/admin/library-management/[id]/edit/+page.ts create mode 100644 web/src/routes/admin/users/(list)/+layout.svelte rename web/src/routes/admin/users/{+page.ts => (list)/+layout.ts} (72%) create mode 100644 web/src/routes/admin/users/(list)/+page.svelte rename web/src/{lib/modals/UserCreateModal.svelte => routes/admin/users/(list)/new/+page.svelte} (92%) create mode 100644 web/src/routes/admin/users/(list)/new/+page.ts delete mode 100644 web/src/routes/admin/users/+page.svelte create mode 100644 web/src/routes/admin/users/[id]/+layout.svelte create mode 100644 web/src/routes/admin/users/[id]/+layout.ts rename web/src/{lib/modals/UserEditModal.svelte => routes/admin/users/[id]/edit/+page.svelte} (84%) create mode 100644 web/src/routes/admin/users/[id]/edit/+page.ts diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 38933aea85..6f7c0d8e1e 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -1,3 +1,5 @@ +export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/; + export enum AssetAction { ARCHIVE = 'archive', UNARCHIVE = 'unarchive', @@ -20,7 +22,9 @@ export enum AssetAction { export enum AppRoute { ADMIN_USERS = '/admin/users', - ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management', + ADMIN_USERS_NEW = '/admin/users/new', + ADMIN_LIBRARIES = '/admin/library-management', + ADMIN_LIBRARIES_NEW = '/admin/library-management/new', ADMIN_SETTINGS = '/admin/system-settings', ADMIN_STATS = '/admin/server-status', ADMIN_QUEUES = '/admin/queues', diff --git a/web/src/lib/modals/LibraryRenameModal.svelte b/web/src/lib/modals/LibraryRenameModal.svelte deleted file mode 100644 index 0a7d675b11..0000000000 --- a/web/src/lib/modals/LibraryRenameModal.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - - - -
- - - -
-
- - - - - - - -
diff --git a/web/src/lib/services/library.service.ts b/web/src/lib/services/library.service.ts index a7f2ba25e7..62371e2512 100644 --- a/web/src/lib/services/library.service.ts +++ b/web/src/lib/services/library.service.ts @@ -1,12 +1,10 @@ import { goto } from '$app/navigation'; import { AppRoute } from '$lib/constants'; import { eventManager } from '$lib/managers/event-manager.svelte'; -import LibraryCreateModal from '$lib/modals/LibraryCreateModal.svelte'; import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte'; import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte'; import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte'; import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte'; -import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte'; import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; import { @@ -19,6 +17,7 @@ import { updateLibrary, type CreateLibraryDto, type LibraryResponseDto, + type UpdateLibraryDto, } from '@immich/sdk'; import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js'; @@ -38,7 +37,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp title: $t('create_library'), type: $t('command'), icon: mdiPlusBoxOutline, - onAction: () => handleShowLibraryCreateModal(), + onAction: () => goto(AppRoute.ADMIN_LIBRARIES_NEW), shortcuts: { shift: true, key: 'n' }, }; @@ -46,11 +45,11 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp }; export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => { - const Rename: ActionItem = { + const Edit: ActionItem = { icon: mdiPencilOutline, type: $t('command'), - title: $t('rename'), - onAction: () => modalManager.show(LibraryRenameModal, { library }), + title: $t('edit'), + onAction: () => goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}/edit`), shortcuts: { key: 'r' }, }; @@ -85,7 +84,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse shortcuts: { shift: true, key: 'r' }, }; - return { Rename, Delete, AddFolder, AddExclusionPattern, Scan }; + return { Edit, Delete, AddFolder, AddExclusionPattern, Scan }; }; export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => { @@ -150,7 +149,7 @@ const handleScanLibrary = async (library: LibraryResponseDto) => { }; export const handleViewLibrary = async (library: LibraryResponseDto) => { - await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`); + await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`); }; export const handleCreateLibrary = async (dto: CreateLibraryDto) => { @@ -160,33 +159,24 @@ export const handleCreateLibrary = async (dto: CreateLibraryDto) => { const library = await createLibrary({ createLibraryDto: dto }); eventManager.emit('LibraryCreate', library); toastManager.success($t('admin.library_created', { values: { library: library.name } })); - return true; + return library; } catch (error) { handleError(error, $t('errors.unable_to_create_library')); - return false; } }; -export const handleRenameLibrary = async (library: { id: string }, name?: string) => { +export const handleUpdateLibrary = async (library: LibraryResponseDto, dto: UpdateLibraryDto) => { const $t = await getFormatter(); - if (!name) { - return false; - } - try { - const updatedLibrary = await updateLibrary({ - id: library.id, - updateLibraryDto: { name }, - }); + const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: dto }); eventManager.emit('LibraryUpdate', updatedLibrary); toastManager.success($t('admin.library_updated')); + return true; } catch (error) { handleError(error, $t('errors.unable_to_update_library')); return false; } - - return true; }; const handleDeleteLibrary = async (library: LibraryResponseDto) => { @@ -357,7 +347,3 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi handleError(error, $t('errors.unable_to_update_library')); } }; - -export const handleShowLibraryCreateModal = async () => { - await modalManager.show(LibraryCreateModal, {}); -}; diff --git a/web/src/lib/services/user-admin.service.ts b/web/src/lib/services/user-admin.service.ts index 997a43fc7f..5fd4ff99fd 100644 --- a/web/src/lib/services/user-admin.service.ts +++ b/web/src/lib/services/user-admin.service.ts @@ -1,10 +1,9 @@ import { goto } from '$app/navigation'; +import { AppRoute } from '$lib/constants'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte'; -import UserCreateModal from '$lib/modals/UserCreateModal.svelte'; import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte'; -import UserEditModal from '$lib/modals/UserEditModal.svelte'; import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte'; import { user as authUser } from '$lib/stores/user.store'; import type { HeaderButtonActionItem } from '$lib/types'; @@ -39,7 +38,7 @@ export const getUserAdminsActions = ($t: MessageFormatter) => { title: $t('create_user'), type: $t('command'), icon: mdiPlusBoxOutline, - onAction: () => modalManager.show(UserCreateModal, {}), + onAction: () => goto(AppRoute.ADMIN_USERS_NEW), shortcuts: { shift: true, key: 'n' }, }; @@ -50,7 +49,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons const Update: ActionItem = { icon: mdiPencilOutline, title: $t('edit'), - onAction: () => modalManager.show(UserEditModal, { user }), + onAction: () => goto(`${AppRoute.ADMIN_USERS}/${user.id}/edit`), }; const Delete: ActionItem = { @@ -103,7 +102,7 @@ export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => { const response = await createUserAdmin({ userAdminCreateDto: dto }); eventManager.emit('UserAdminCreate', response); toastManager.success(); - return true; + return response; } catch (error) { handleError(error, $t('errors.unable_to_create_user')); } diff --git a/web/src/lib/sidebars/AdminSidebar.svelte b/web/src/lib/sidebars/AdminSidebar.svelte index 919c072527..fa660d7e2f 100644 --- a/web/src/lib/sidebars/AdminSidebar.svelte +++ b/web/src/lib/sidebars/AdminSidebar.svelte @@ -9,7 +9,7 @@
- + diff --git a/web/src/params/id.ts b/web/src/params/id.ts index 6b16a651d1..b7e93be6ae 100644 --- a/web/src/params/id.ts +++ b/web/src/params/id.ts @@ -1,6 +1,7 @@ +import { UUID_REGEX } from '$lib/constants'; import type { ParamMatcher } from '@sveltejs/kit'; /* Returns true if the given param matches UUID format */ export const match: ParamMatcher = (param: string) => { - return /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/.test(param); + return UUID_REGEX.test(param); }; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 77a3d402b2..3a1d4f49f9 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -166,7 +166,7 @@ title: $t('external_libraries'), description: $t('admin.external_libraries_page_description'), icon: mdiBookshelf, - onAction: () => goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT), + onAction: () => goto(AppRoute.ADMIN_LIBRARIES), }, { title: $t('server_stats'), diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/(list)/+layout.svelte similarity index 90% rename from web/src/routes/admin/library-management/+page.svelte rename to web/src/routes/admin/library-management/(list)/+layout.svelte index e61b4433be..e741dbd610 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/(list)/+layout.svelte @@ -4,27 +4,29 @@ import OnEvents from '$lib/components/OnEvents.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import { AppRoute } from '$lib/constants'; - import { getLibrariesActions, handleShowLibraryCreateModal, handleViewLibrary } from '$lib/services/library.service'; + import { getLibrariesActions, handleViewLibrary } from '$lib/services/library.service'; import { locale } from '$lib/stores/preferences.store'; import { getBytesWithUnit } from '$lib/utils/byte-units'; import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk'; import { Button, CommandPaletteContext } from '@immich/ui'; + import type { Snippet } from 'svelte'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - import type { PageData } from './$types'; + import type { LayoutData } from './$types'; type Props = { - data: PageData; + children?: Snippet; + data: LayoutData; }; - let { data }: Props = $props(); + let { children, data }: Props = $props(); let libraries = $state(data.libraries); let statistics = $state(data.statistics); let owners = $state(data.owners); const onLibraryCreate = async (library: LibraryResponseDto) => { - await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`); + await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`); }; const onLibraryUpdate = async (library: LibraryResponseDto) => { @@ -100,10 +102,12 @@ {:else} goto(AppRoute.ADMIN_LIBRARIES_NEW)} class="mt-10 mx-auto" /> {/if} + + {@render children?.()}
diff --git a/web/src/routes/admin/library-management/+page.ts b/web/src/routes/admin/library-management/(list)/+layout.ts similarity index 93% rename from web/src/routes/admin/library-management/+page.ts rename to web/src/routes/admin/library-management/(list)/+layout.ts index aa892f81ed..aee777a9e8 100644 --- a/web/src/routes/admin/library-management/+page.ts +++ b/web/src/routes/admin/library-management/(list)/+layout.ts @@ -1,7 +1,7 @@ import { authenticate, requestServerInfo } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import { getAllLibraries, getLibraryStatistics, getUserAdmin, searchUsersAdmin } from '@immich/sdk'; -import type { PageLoad } from './$types'; +import type { LayoutLoad } from './$types'; export const load = (async ({ url }) => { await authenticate(url, { admin: true }); @@ -26,4 +26,4 @@ export const load = (async ({ url }) => { title: $t('external_libraries'), }, }; -}) satisfies PageLoad; +}) satisfies LayoutLoad; diff --git a/web/src/routes/admin/library-management/(list)/+page.svelte b/web/src/routes/admin/library-management/(list)/+page.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web/src/lib/modals/LibraryCreateModal.svelte b/web/src/routes/admin/library-management/(list)/new/+page.svelte similarity index 77% rename from web/src/lib/modals/LibraryCreateModal.svelte rename to web/src/routes/admin/library-management/(list)/new/+page.svelte index d2d7f62c02..b236b83ce8 100644 --- a/web/src/lib/modals/LibraryCreateModal.svelte +++ b/web/src/routes/admin/library-management/(list)/new/+page.svelte @@ -1,5 +1,7 @@ diff --git a/web/src/routes/admin/library-management/(list)/new/+page.ts b/web/src/routes/admin/library-management/(list)/new/+page.ts new file mode 100644 index 0000000000..da3756bcf6 --- /dev/null +++ b/web/src/routes/admin/library-management/(list)/new/+page.ts @@ -0,0 +1,14 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); + const $t = await getFormatter(); + + return { + meta: { + title: $t('external_libraries'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/admin/library-management/[id]/+layout.svelte b/web/src/routes/admin/library-management/[id]/+layout.svelte new file mode 100644 index 0000000000..62b4891459 --- /dev/null +++ b/web/src/routes/admin/library-management/[id]/+layout.svelte @@ -0,0 +1,118 @@ + + + + + + + + +
+ {library.name} +
+ + + +
+ + + {#if library.importPaths.length === 0} + modalManager.show(LibraryFolderAddModal, { library })} + /> + {:else} + + + {#each library.importPaths as folder (folder)} + {@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)} + + + + + {/each} + +
+ {folder} + + + +
+ {/if} +
+ + + + + {#each library.exclusionPatterns as exclusionPattern (exclusionPattern)} + {@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)} + + + + + {/each} + +
+ {exclusionPattern} + + + +
+
+
+ {@render children?.()} +
+
diff --git a/web/src/routes/admin/library-management/[id]/+layout.ts b/web/src/routes/admin/library-management/[id]/+layout.ts new file mode 100644 index 0000000000..d465d92c45 --- /dev/null +++ b/web/src/routes/admin/library-management/[id]/+layout.ts @@ -0,0 +1,29 @@ +import { AppRoute } from '$lib/constants'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk'; +import { redirect } from '@sveltejs/kit'; +import type { LayoutLoad } from './$types'; + +export const load = (async ({ params: { id }, url }) => { + await authenticate(url, { admin: true }); + + let library: LibraryResponseDto; + + try { + library = await getLibrary({ id }); + } catch { + redirect(302, AppRoute.ADMIN_LIBRARIES); + } + + const statistics = await getLibraryStatistics({ id }); + const $t = await getFormatter(); + + return { + library, + statistics, + meta: { + title: $t('admin.library_details'), + }, + }; +}) satisfies LayoutLoad; diff --git a/web/src/routes/admin/library-management/[id]/+page.svelte b/web/src/routes/admin/library-management/[id]/+page.svelte index 6bffbe39aa..e69de29bb2 100644 --- a/web/src/routes/admin/library-management/[id]/+page.svelte +++ b/web/src/routes/admin/library-management/[id]/+page.svelte @@ -1,105 +0,0 @@ - - - (library = newLibrary)} - onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)} -/> - - - - - -
- {library.name} -
- - - -
- - - {#if library.importPaths.length === 0} - modalManager.show(LibraryFolderAddModal, { library })} - /> - {:else} - - - {#each library.importPaths as folder (folder)} - {@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)} - - - - - {/each} - -
- {folder} - - - -
- {/if} -
- - - - - {#each library.exclusionPatterns as exclusionPattern (exclusionPattern)} - {@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)} - - - - - {/each} - -
- {exclusionPattern} - - - -
-
-
-
-
diff --git a/web/src/routes/admin/library-management/[id]/+page.ts b/web/src/routes/admin/library-management/[id]/+page.ts index 77ce1eb1c8..10f0f33e11 100644 --- a/web/src/routes/admin/library-management/[id]/+page.ts +++ b/web/src/routes/admin/library-management/[id]/+page.ts @@ -1,26 +1,13 @@ -import { AppRoute } from '$lib/constants'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; -import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk'; -import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -export const load = (async ({ params: { id }, url }) => { +export const load = (async ({ url }) => { await authenticate(url, { admin: true }); - let library: LibraryResponseDto; - try { - library = await getLibrary({ id }); - } catch { - redirect(302, AppRoute.ADMIN_LIBRARY_MANAGEMENT); - } - - const statistics = await getLibraryStatistics({ id }); const $t = await getFormatter(); return { - library, - statistics, meta: { title: $t('admin.library_details'), }, diff --git a/web/src/routes/admin/library-management/[id]/edit/+page.svelte b/web/src/routes/admin/library-management/[id]/edit/+page.svelte new file mode 100644 index 0000000000..06e0af13e6 --- /dev/null +++ b/web/src/routes/admin/library-management/[id]/edit/+page.svelte @@ -0,0 +1,35 @@ + + + + + + + diff --git a/web/src/routes/admin/library-management/[id]/edit/+page.ts b/web/src/routes/admin/library-management/[id]/edit/+page.ts new file mode 100644 index 0000000000..10f0f33e11 --- /dev/null +++ b/web/src/routes/admin/library-management/[id]/edit/+page.ts @@ -0,0 +1,15 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); + + const $t = await getFormatter(); + + return { + meta: { + title: $t('admin.library_details'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/admin/users/(list)/+layout.svelte b/web/src/routes/admin/users/(list)/+layout.svelte new file mode 100644 index 0000000000..a8c281690d --- /dev/null +++ b/web/src/routes/admin/users/(list)/+layout.svelte @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + {#each users as user (user.id)} + + + + + + + {/each} + +
{$t('email')}
+ {user.email} + + +
+ + {@render children?.()} +
+
diff --git a/web/src/routes/admin/users/+page.ts b/web/src/routes/admin/users/(list)/+layout.ts similarity index 72% rename from web/src/routes/admin/users/+page.ts rename to web/src/routes/admin/users/(list)/+layout.ts index 521f8573e1..cccc7e647a 100644 --- a/web/src/routes/admin/users/+page.ts +++ b/web/src/routes/admin/users/(list)/+layout.ts @@ -1,18 +1,18 @@ import { authenticate, requestServerInfo } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import { searchUsersAdmin } from '@immich/sdk'; -import type { PageLoad } from './$types'; +import type { LayoutLoad } from './$types'; export const load = (async ({ url }) => { await authenticate(url, { admin: true }); await requestServerInfo(); - const allUsers = await searchUsersAdmin({ withDeleted: true }); + const users = await searchUsersAdmin({ withDeleted: true }); const $t = await getFormatter(); return { - allUsers, + users, meta: { title: $t('admin.user_management'), }, }; -}) satisfies PageLoad; +}) satisfies LayoutLoad; diff --git a/web/src/routes/admin/users/(list)/+page.svelte b/web/src/routes/admin/users/(list)/+page.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web/src/lib/modals/UserCreateModal.svelte b/web/src/routes/admin/users/(list)/new/+page.svelte similarity index 92% rename from web/src/lib/modals/UserCreateModal.svelte rename to web/src/routes/admin/users/(list)/new/+page.svelte index 7dd0449119..d4f753197a 100644 --- a/web/src/lib/modals/UserCreateModal.svelte +++ b/web/src/routes/admin/users/(list)/new/+page.svelte @@ -1,4 +1,6 @@ - - - - - - -
-
- - - - - - - - - - {#each allUsers as user (user.id)} - - - - - - - {/each} - -
{$t('email')}
- {user.email} - - -
-
-
-
diff --git a/web/src/routes/admin/users/[id]/+layout.svelte b/web/src/routes/admin/users/[id]/+layout.svelte new file mode 100644 index 0000000000..d3fe5fdcaf --- /dev/null +++ b/web/src/routes/admin/users/[id]/+layout.svelte @@ -0,0 +1,222 @@ + + + + + + + +
+ + {#if user.deletedAt} + + {/if} + +
+
+
+ + {user.name} +
+ {#if user.isAdmin} +
+ {$t('admin.admin_user')} +
+ {/if} +
+
+
+ + + +
+
+ + + +
+ {$t('name')} + {user.name} +
+
+ {$t('email')} + {user.email} +
+
+ {$t('created_at')} + {userCreatedAtDateAndTime} +
+
+ {$t('updated_at')} + {userUpdatedAtDateAndTime} +
+
+ {$t('id')} + {user.id} +
+
+
+ + + + + + + + + + + + + + + + + {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} + + {$t('storage_usage', { + values: { + used: getByteUnitString(usedBytes, $locale, 3), + available: getByteUnitString(availableBytes, $locale, 3), + }, + })} + + {:else} + + + {$t('unlimited')} + + {/if} + + {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} +
+

{$t('storage')}

+
+
+
+
+ {/if} +
+ + + + {#each userSessions as session (session.id)} + + {:else} + {$t('no_devices')} + {/each} + + +
+ + {@render children?.()} +
+
+
diff --git a/web/src/routes/admin/users/[id]/+layout.ts b/web/src/routes/admin/users/[id]/+layout.ts new file mode 100644 index 0000000000..32c41b0aca --- /dev/null +++ b/web/src/routes/admin/users/[id]/+layout.ts @@ -0,0 +1,38 @@ +import { AppRoute, UUID_REGEX } from '$lib/constants'; +import { authenticate, requestServerInfo } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk'; +import { redirect } from '@sveltejs/kit'; +import type { LayoutLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(url, { admin: true }); + await requestServerInfo(); + + if (!UUID_REGEX.test(params.id)) { + redirect(302, AppRoute.ADMIN_USERS); + } + + const [user] = await searchUsersAdmin({ id: params.id, withDeleted: true }).catch(() => []); + if (!user) { + redirect(302, AppRoute.ADMIN_USERS); + } + + const [userPreferences, userStatistics, userSessions] = await Promise.all([ + getUserPreferencesAdmin({ id: user.id }), + getUserStatisticsAdmin({ id: user.id }), + getUserSessionsAdmin({ id: user.id }), + ]); + + const $t = await getFormatter(); + + return { + user, + userPreferences, + userStatistics, + userSessions, + meta: { + title: $t('admin.user_details'), + }, + }; +}) satisfies LayoutLoad; diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index c41afba97c..e69de29bb2 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -1,218 +0,0 @@ - - - - - - - -
- - {#if user.deletedAt} - - {/if} - -
-
-
- - {user.name} -
- {#if user.isAdmin} -
- {$t('admin.admin_user')} -
- {/if} -
-
-
- - - -
-
- - - -
- {$t('name')} - {user.name} -
-
- {$t('email')} - {user.email} -
-
- {$t('created_at')} - {userCreatedAtDateAndTime} -
-
- {$t('updated_at')} - {userUpdatedAtDateAndTime} -
-
- {$t('id')} - {user.id} -
-
-
- - - - - - - - - - - - - - - - - {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} - - {$t('storage_usage', { - values: { - used: getByteUnitString(usedBytes, $locale, 3), - available: getByteUnitString(availableBytes, $locale, 3), - }, - })} - - {:else} - - - {$t('unlimited')} - - {/if} - - {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} -
-

{$t('storage')}

-
-
-
-
- {/if} -
- - - - {#each userSessions as session (session.id)} - - {:else} - {$t('no_devices')} - {/each} - - -
-
-
-
diff --git a/web/src/routes/admin/users/[id]/+page.ts b/web/src/routes/admin/users/[id]/+page.ts index bfc5bcefa9..775f9662d2 100644 --- a/web/src/routes/admin/users/[id]/+page.ts +++ b/web/src/routes/admin/users/[id]/+page.ts @@ -1,31 +1,12 @@ -import { AppRoute } from '$lib/constants'; -import { authenticate, requestServerInfo } from '$lib/utils/auth'; +import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; -import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk'; -import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -export const load = (async ({ params, url }) => { +export const load = (async ({ url }) => { await authenticate(url, { admin: true }); - await requestServerInfo(); - const [user] = await searchUsersAdmin({ id: params.id, withDeleted: true }).catch(() => []); - if (!user) { - redirect(302, AppRoute.ADMIN_USERS); - } - - const [userPreferences, userStatistics, userSessions] = await Promise.all([ - getUserPreferencesAdmin({ id: user.id }), - getUserStatisticsAdmin({ id: user.id }), - getUserSessionsAdmin({ id: user.id }), - ]); - const $t = await getFormatter(); return { - user, - userPreferences, - userStatistics, - userSessions, meta: { title: $t('admin.user_details'), }, diff --git a/web/src/lib/modals/UserEditModal.svelte b/web/src/routes/admin/users/[id]/edit/+page.svelte similarity index 84% rename from web/src/lib/modals/UserEditModal.svelte rename to web/src/routes/admin/users/[id]/edit/+page.svelte index 4b4878e46d..ea3bd827df 100644 --- a/web/src/lib/modals/UserEditModal.svelte +++ b/web/src/routes/admin/users/[id]/edit/+page.svelte @@ -1,10 +1,10 @@ diff --git a/web/src/routes/admin/users/[id]/edit/+page.ts b/web/src/routes/admin/users/[id]/edit/+page.ts new file mode 100644 index 0000000000..64420e91da --- /dev/null +++ b/web/src/routes/admin/users/[id]/edit/+page.ts @@ -0,0 +1,15 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); + + const $t = await getFormatter(); + + return { + meta: { + title: $t('admin.user_details'), + }, + }; +}) satisfies PageLoad; From c7510d572a94fa9904e3ea23f9dce11d63810b94 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 22 Dec 2025 10:23:57 -0500 Subject: [PATCH 005/174] chore: move models (#24778) --- .../AdminSidebar.svelte | 0 .../components/layouts/AdminPageLayout.svelte | 2 +- .../onboarding-page/onboarding-hello.svelte | 2 +- .../upload-asset-preview.svelte | 4 +-- web/src/lib/models/onboarding-role.ts | 4 --- web/src/lib/models/upload-asset.ts | 22 --------------- web/src/lib/stores/upload.ts | 2 +- web/src/lib/types.ts | 28 +++++++++++++++++++ web/src/lib/utils/file-uploader.ts | 2 +- web/src/routes/auth/onboarding/+page.svelte | 2 +- 10 files changed, 35 insertions(+), 33 deletions(-) rename web/src/lib/{sidebars => components}/AdminSidebar.svelte (100%) delete mode 100644 web/src/lib/models/onboarding-role.ts delete mode 100644 web/src/lib/models/upload-asset.ts diff --git a/web/src/lib/sidebars/AdminSidebar.svelte b/web/src/lib/components/AdminSidebar.svelte similarity index 100% rename from web/src/lib/sidebars/AdminSidebar.svelte rename to web/src/lib/components/AdminSidebar.svelte diff --git a/web/src/lib/components/layouts/AdminPageLayout.svelte b/web/src/lib/components/layouts/AdminPageLayout.svelte index d63e306853..6a0d6508dc 100644 --- a/web/src/lib/components/layouts/AdminPageLayout.svelte +++ b/web/src/lib/components/layouts/AdminPageLayout.svelte @@ -1,7 +1,7 @@ {#if action.$if?.() ?? true} - onAction(action)} /> + onAction(action)} /> {/if} diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte index 6828efbb33..ec21ab9872 100644 --- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte +++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte @@ -1,68 +1,47 @@ + +
- +
{#if keys.length > 0} @@ -79,6 +58,7 @@ {#each keys as key (key.id)} + {@const { Update, Delete } = getApiKeyActions($t, key)} @@ -91,22 +71,8 @@ >{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)} - handleUpdate(key)} - /> - handleDelete(key)} - /> + + {/each} diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index dff27ef4fd..6bc26220a7 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -2,6 +2,7 @@ import type { ThemeSetting } from '$lib/managers/theme-manager.svelte'; import type { ReleaseEvent } from '$lib/types'; import type { AlbumResponseDto, + ApiKeyResponseDto, LibraryResponseDto, LoginResponseDto, QueueResponseDto, @@ -19,6 +20,10 @@ export type Events = { LanguageChange: [{ name: string; code: string; rtl?: boolean }]; ThemeChange: [ThemeSetting]; + ApiKeyCreate: [ApiKeyResponseDto]; + ApiKeyUpdate: [ApiKeyResponseDto]; + ApiKeyDelete: [ApiKeyResponseDto]; + AssetReplace: [{ oldAssetId: string; newAssetId: string }]; AlbumDelete: [AlbumResponseDto]; diff --git a/web/src/lib/modals/ApiKeyCreateModal.svelte b/web/src/lib/modals/ApiKeyCreateModal.svelte index 72019eb58a..c5078ca3dc 100644 --- a/web/src/lib/modals/ApiKeyCreateModal.svelte +++ b/web/src/lib/modals/ApiKeyCreateModal.svelte @@ -1,12 +1,12 @@ - - -
-
- - - -
- - -
- - - - - - - -
+ +
+ + + +
+ +
diff --git a/web/src/lib/modals/ApiKeyUpdateModal.svelte b/web/src/lib/modals/ApiKeyUpdateModal.svelte index a380c72a06..afc55c5dea 100644 --- a/web/src/lib/modals/ApiKeyUpdateModal.svelte +++ b/web/src/lib/modals/ApiKeyUpdateModal.svelte @@ -1,69 +1,44 @@ - - -
-
- - - -
- - -
- - - - - - - -
+ +
+ + + +
+ +
diff --git a/web/src/lib/services/api-key.service.ts b/web/src/lib/services/api-key.service.ts new file mode 100644 index 0000000000..4833bafadc --- /dev/null +++ b/web/src/lib/services/api-key.service.ts @@ -0,0 +1,110 @@ +import { eventManager } from '$lib/managers/event-manager.svelte'; +import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte'; +import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte'; +import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte'; +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; +import { + createApiKey, + deleteApiKey, + updateApiKey, + type ApiKeyCreateDto, + type ApiKeyResponseDto, + type ApiKeyUpdateDto, +} from '@immich/sdk'; +import { modalManager, toastManager, type ActionItem } from '@immich/ui'; +import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import type { MessageFormatter } from 'svelte-i18n'; + +export const getApiKeysActions = ($t: MessageFormatter) => { + const Create: ActionItem = { + title: $t('new_api_key'), + icon: mdiPlus, + onAction: () => modalManager.show(ApiKeyCreateModal, {}), + }; + + return { Create }; +}; + +export const getApiKeyActions = ($t: MessageFormatter, apiKey: ApiKeyResponseDto) => { + const Update: ActionItem = { + title: $t('edit_key'), + icon: mdiPencilOutline, + onAction: () => modalManager.show(ApiKeyUpdateModal, { apiKey }), + }; + + const Delete: ActionItem = { + title: $t('delete_key'), + icon: mdiTrashCanOutline, + onAction: () => handleDeleteApiKey(apiKey), + }; + + return { Update, Delete }; +}; + +export const handleCreateApiKey = async (dto: ApiKeyCreateDto) => { + const $t = await getFormatter(); + + try { + if (!dto.name) { + toastManager.warning($t('api_key_empty')); + return; + } + + if (dto.permissions.length === 0) { + toastManager.warning($t('permission_empty')); + return; + } + + const { apiKey, secret } = await createApiKey({ apiKeyCreateDto: dto }); + + eventManager.emit('ApiKeyCreate', apiKey); + + // no nested modal + void modalManager.show(ApiKeySecretModal, { secret }); + + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_create_api_key')); + } +}; + +export const handleUpdateApiKey = async (apiKey: { id: string }, dto: ApiKeyUpdateDto) => { + const $t = await getFormatter(); + + if (!dto.name) { + toastManager.warning($t('api_key_empty')); + return; + } + + if (dto.permissions && dto.permissions.length === 0) { + toastManager.warning($t('permission_empty')); + return; + } + + try { + const response = await updateApiKey({ id: apiKey.id, apiKeyUpdateDto: dto }); + eventManager.emit('ApiKeyUpdate', response); + toastManager.success($t('saved_api_key')); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_save_api_key')); + } +}; + +export const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => { + const $t = await getFormatter(); + + const confirmed = await modalManager.showDialog({ prompt: $t('delete_api_key_prompt') }); + if (!confirmed) { + return; + } + + try { + await deleteApiKey({ id: apiKey.id }); + eventManager.emit('ApiKeyDelete', apiKey); + toastManager.success($t('removed_api_key', { values: { name: apiKey.name } })); + } catch (error) { + handleError(error, $t('errors.unable_to_remove_api_key')); + } +}; From 952f189d8b79ead676b409e9a0edd13e339da316 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 22 Dec 2025 11:31:22 -0500 Subject: [PATCH 007/174] feat: prefer admin settings page over users page (#24780) --- .../shared-components/navigation-bar/account-info-panel.svelte | 2 +- web/src/routes/admin/+page.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte index 1ef695ca91..6d4245436c 100644 --- a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte @@ -78,7 +78,7 @@ {#if $user.isAdmin} - - - - diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts index cbea6ddd9d..9f70024193 100644 --- a/web/src/lib/services/shared-link.service.ts +++ b/web/src/lib/services/shared-link.service.ts @@ -24,7 +24,7 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin const Edit: ActionItem = { title: $t('edit_link'), icon: mdiPencilOutline, - onAction: () => goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`), + onAction: () => goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}/edit`), }; const Delete: ActionItem = { diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/(list)/+layout.svelte similarity index 85% rename from web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte rename to web/src/routes/(user)/shared-links/(list)/+layout.svelte index cc9afd4f64..ce14870923 100644 --- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte +++ b/web/src/routes/(user)/shared-links/(list)/+layout.svelte @@ -6,20 +6,20 @@ import SharedLinkCard from '$lib/components/sharedlinks-page/SharedLinkCard.svelte'; import { AppRoute } from '$lib/constants'; import GroupTab from '$lib/elements/GroupTab.svelte'; - import SharedLinkUpdateModal from '$lib/modals/SharedLinkUpdateModal.svelte'; import { getAllSharedLinks, SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk'; - import { onMount } from 'svelte'; + import { Container } from '@immich/ui'; + import { onMount, type Snippet } from 'svelte'; import { t } from 'svelte-i18n'; - import type { PageData } from './$types'; + import type { LayoutData } from './$types'; type Props = { - data: PageData; + children?: Snippet; + data: LayoutData; }; - const { data }: Props = $props(); + const { children, data }: Props = $props(); let sharedLinks: SharedLinkResponseDto[] = $state([]); - let sharedLink = $derived(sharedLinks.find(({ id }) => id === page.params.id)); const refresh = async () => { sharedLinks = await getAllSharedLinks({}); @@ -80,7 +80,7 @@
{/snippet} -
+ {#if sharedLinks.length === 0}
{/if} - {#if sharedLink} - goto(AppRoute.SHARED_LINKS)} /> - {/if} -
+ {@render children?.()} +
diff --git a/web/src/routes/(user)/shared-links/(list)/+layout.ts b/web/src/routes/(user)/shared-links/(list)/+layout.ts new file mode 100644 index 0000000000..842940ffe1 --- /dev/null +++ b/web/src/routes/(user)/shared-links/(list)/+layout.ts @@ -0,0 +1,14 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import type { LayoutLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url); + const $t = await getFormatter(); + + return { + meta: { + title: $t('shared_links'), + }, + }; +}) satisfies LayoutLoad; diff --git a/web/src/routes/(user)/shared-links/(list)/+page.svelte b/web/src/routes/(user)/shared-links/(list)/+page.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts b/web/src/routes/(user)/shared-links/(list)/+page.ts similarity index 100% rename from web/src/routes/(user)/shared-links/[[id=id]]/+page.ts rename to web/src/routes/(user)/shared-links/(list)/+page.ts diff --git a/web/src/routes/(user)/shared-links/(list)/[id]/+layout.ts b/web/src/routes/(user)/shared-links/(list)/[id]/+layout.ts new file mode 100644 index 0000000000..c3f62e36c3 --- /dev/null +++ b/web/src/routes/(user)/shared-links/(list)/[id]/+layout.ts @@ -0,0 +1,28 @@ +import { AppRoute, UUID_REGEX } from '$lib/constants'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAllSharedLinks } from '@immich/sdk'; +import { redirect } from '@sveltejs/kit'; +import type { LayoutLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(url); + + if (!UUID_REGEX.test(params.id)) { + redirect(302, AppRoute.SHARED_LINKS); + } + + const [sharedLink] = await getAllSharedLinks({ id: params.id }); + if (!sharedLink) { + redirect(302, AppRoute.SHARED_LINKS); + } + + const $t = await getFormatter(); + + return { + sharedLink, + meta: { + title: $t('shared_links'), + }, + }; +}) satisfies LayoutLoad; diff --git a/web/src/routes/(user)/shared-links/(list)/[id]/edit/+page.svelte b/web/src/routes/(user)/shared-links/(list)/[id]/edit/+page.svelte new file mode 100644 index 0000000000..6fef129583 --- /dev/null +++ b/web/src/routes/(user)/shared-links/(list)/[id]/edit/+page.svelte @@ -0,0 +1,96 @@ + + + + {#if shareType === SharedLinkType.Album} +
+ {$t('public_album')} | + {sharedLink.album?.albumName} +
+ {/if} + + {#if shareType === SharedLinkType.Individual} +
+ {$t('individual_share')} | + {sharedLink.description || ''} +
+ {/if} + +
+
+ + + + {#if slug} + /s/{encodeURIComponent(slug)} + {/if} +
+ + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/web/src/routes/(user)/shared-links/(list)/[id]/edit/+page.ts b/web/src/routes/(user)/shared-links/(list)/[id]/edit/+page.ts new file mode 100644 index 0000000000..afbe9991ff --- /dev/null +++ b/web/src/routes/(user)/shared-links/(list)/[id]/edit/+page.ts @@ -0,0 +1,15 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url); + + const $t = await getFormatter(); + + return { + meta: { + title: $t('shared_links'), + }, + }; +}) satisfies PageLoad; From f6f9a3abb442682ddfd8d24647b397caff1132b4 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 22 Dec 2025 13:12:43 -0500 Subject: [PATCH 009/174] fix: task never rejected on cancel, add tests (#24418) --- web/src/lib/utils/cancellable-task.spec.ts | 540 +++++++++++++++++++++ web/src/lib/utils/cancellable-task.ts | 56 ++- 2 files changed, 570 insertions(+), 26 deletions(-) create mode 100644 web/src/lib/utils/cancellable-task.spec.ts diff --git a/web/src/lib/utils/cancellable-task.spec.ts b/web/src/lib/utils/cancellable-task.spec.ts new file mode 100644 index 0000000000..97d63684f8 --- /dev/null +++ b/web/src/lib/utils/cancellable-task.spec.ts @@ -0,0 +1,540 @@ +import { CancellableTask } from '$lib/utils/cancellable-task'; + +describe('CancellableTask', () => { + describe('execute', () => { + it('should execute task successfully and return LOADED', async () => { + const task = new CancellableTask(); + const taskFn = vi.fn(async (_: AbortSignal) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + const result = await task.execute(taskFn, true); + + expect(result).toBe('LOADED'); + expect(task.executed).toBe(true); + expect(task.loading).toBe(false); + expect(taskFn).toHaveBeenCalledTimes(1); + }); + + it('should call loadedCallback when task completes successfully', async () => { + const loadedCallback = vi.fn(); + const task = new CancellableTask(loadedCallback); + const taskFn = vi.fn(async () => {}); + + await task.execute(taskFn, true); + + expect(loadedCallback).toHaveBeenCalledTimes(1); + }); + + it('should return DONE if task is already executed', async () => { + const task = new CancellableTask(); + const taskFn = vi.fn(async () => {}); + + await task.execute(taskFn, true); + const result = await task.execute(taskFn, true); + + expect(result).toBe('DONE'); + expect(taskFn).toHaveBeenCalledTimes(1); + }); + + it('should wait if task is already running', async () => { + const task = new CancellableTask(); + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + const taskFn = vi.fn(async () => { + await taskPromise; + }); + + const promise1 = task.execute(taskFn, true); + const promise2 = task.execute(taskFn, true); + + expect(task.loading).toBe(true); + resolveTask!(); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + expect(result1).toBe('LOADED'); + expect(result2).toBe('WAITED'); + expect(taskFn).toHaveBeenCalledTimes(1); + }); + + it('should pass AbortSignal to task function', async () => { + const task = new CancellableTask(); + let capturedSignal: AbortSignal | null = null; + const taskFn = async (signal: AbortSignal) => { + await Promise.resolve(); + capturedSignal = signal; + }; + + await task.execute(taskFn, true); + + expect(capturedSignal).toBeInstanceOf(AbortSignal); + }); + + it('should set cancellable flag correctly', async () => { + const task = new CancellableTask(); + const taskFn = vi.fn(async () => {}); + + expect(task.cancellable).toBe(true); + const promise = task.execute(taskFn, false); + expect(task.cancellable).toBe(false); + await promise; + }); + + it('should not allow transition from prevent cancel to allow cancel when task is running', async () => { + const task = new CancellableTask(); + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + const taskFn = vi.fn(async () => { + await taskPromise; + }); + + const promise1 = task.execute(taskFn, false); + expect(task.cancellable).toBe(false); + + const promise2 = task.execute(taskFn, true); + expect(task.cancellable).toBe(false); + + resolveTask!(); + await Promise.all([promise1, promise2]); + }); + }); + + describe('cancel', () => { + it('should cancel a running task', async () => { + const task = new CancellableTask(); + let taskStarted = false; + const taskFn = async (signal: AbortSignal) => { + taskStarted = true; + await new Promise((resolve) => setTimeout(resolve, 100)); + if (signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + }; + + const promise = task.execute(taskFn, true); + + // Wait a bit to ensure task has started + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(taskStarted).toBe(true); + + task.cancel(); + + const result = await promise; + expect(result).toBe('CANCELED'); + expect(task.executed).toBe(false); + }); + + it('should call canceledCallback when task is canceled', async () => { + const canceledCallback = vi.fn(); + const task = new CancellableTask(undefined, canceledCallback); + const taskFn = async (signal: AbortSignal) => { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + }; + + const promise = task.execute(taskFn, true); + await new Promise((resolve) => setTimeout(resolve, 10)); + task.cancel(); + await promise; + + expect(canceledCallback).toHaveBeenCalledTimes(1); + }); + + it('should not cancel if task is not cancellable', async () => { + const task = new CancellableTask(); + const taskFn = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const promise = task.execute(taskFn, false); + task.cancel(); + const result = await promise; + + expect(result).toBe('LOADED'); + expect(task.executed).toBe(true); + }); + + it('should not cancel if task is already executed', async () => { + const task = new CancellableTask(); + const taskFn = vi.fn(async () => {}); + + await task.execute(taskFn, true); + expect(task.executed).toBe(true); + + task.cancel(); + expect(task.executed).toBe(true); + }); + }); + + describe('reset', () => { + it('should reset task to initial state', async () => { + const task = new CancellableTask(); + const taskFn = vi.fn(async () => {}); + + await task.execute(taskFn, true); + expect(task.executed).toBe(true); + + await task.reset(); + + expect(task.executed).toBe(false); + expect(task.cancelToken).toBe(null); + expect(task.loading).toBe(false); + }); + + it('should cancel running task before resetting', async () => { + const task = new CancellableTask(); + const taskFn = async (signal: AbortSignal) => { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + }; + + const promise = task.execute(taskFn, true); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const resetPromise = task.reset(); + + await promise; + await resetPromise; + + expect(task.executed).toBe(false); + expect(task.loading).toBe(false); + }); + + it('should allow re-execution after reset', async () => { + const task = new CancellableTask(); + const taskFn = vi.fn(async () => {}); + + await task.execute(taskFn, true); + await task.reset(); + const result = await task.execute(taskFn, true); + + expect(result).toBe('LOADED'); + expect(task.executed).toBe(true); + expect(taskFn).toHaveBeenCalledTimes(2); + }); + }); + + describe('waitUntilCompletion', () => { + it('should return DONE if task is already executed', async () => { + const task = new CancellableTask(); + const taskFn = vi.fn(async () => {}); + + await task.execute(taskFn, true); + const result = await task.waitUntilCompletion(); + + expect(result).toBe('DONE'); + }); + + it('should return WAITED if task completes while waiting', async () => { + const task = new CancellableTask(); + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + const taskFn = async () => { + await taskPromise; + }; + + const executePromise = task.execute(taskFn, true); + const waitPromise = task.waitUntilCompletion(); + + resolveTask!(); + + const [, waitResult] = await Promise.all([executePromise, waitPromise]); + + expect(waitResult).toBe('WAITED'); + }); + + it('should return CANCELED if task is canceled', async () => { + const task = new CancellableTask(); + const taskFn = async (signal: AbortSignal) => { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + }; + + const executePromise = task.execute(taskFn, true); + const waitPromise = task.waitUntilCompletion(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + task.cancel(); + + const [, waitResult] = await Promise.all([executePromise, waitPromise]); + + expect(waitResult).toBe('CANCELED'); + }); + }); + + describe('waitUntilExecution', () => { + it('should return DONE if task is already executed', async () => { + const task = new CancellableTask(); + const taskFn = vi.fn(async () => {}); + + await task.execute(taskFn, true); + const result = await task.waitUntilExecution(); + + expect(result).toBe('DONE'); + }); + + it('should return WAITED if task completes successfully', async () => { + const task = new CancellableTask(); + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + const taskFn = async () => { + await taskPromise; + }; + + const executePromise = task.execute(taskFn, true); + const waitPromise = task.waitUntilExecution(); + + resolveTask!(); + + const [, waitResult] = await Promise.all([executePromise, waitPromise]); + + expect(waitResult).toBe('WAITED'); + }); + + it('should retry if task is canceled and wait for next execution', async () => { + vi.useFakeTimers(); + + const task = new CancellableTask(); + let attempt = 0; + const taskFn = async (signal: AbortSignal) => { + attempt++; + await new Promise((resolve) => setTimeout(resolve, 100)); + if (signal.aborted && attempt === 1) { + throw new DOMException('Aborted', 'AbortError'); + } + }; + + // Start first execution + const executePromise1 = task.execute(taskFn, true); + const waitPromise = task.waitUntilExecution(); + + // Cancel the first execution + vi.advanceTimersByTime(10); + task.cancel(); + vi.advanceTimersByTime(100); + await executePromise1; + + // Start second execution + const executePromise2 = task.execute(taskFn, true); + vi.advanceTimersByTime(100); + + const [executeResult, waitResult] = await Promise.all([executePromise2, waitPromise]); + + expect(executeResult).toBe('LOADED'); + expect(waitResult).toBe('WAITED'); + expect(attempt).toBe(2); + + vi.useRealTimers(); + }); + }); + + describe('error handling', () => { + it('should return ERRORED when task throws non-abort error', async () => { + const task = new CancellableTask(); + const error = new Error('Task failed'); + const taskFn = async () => { + await Promise.resolve(); + throw error; + }; + + const result = await task.execute(taskFn, true); + + expect(result).toBe('ERRORED'); + expect(task.executed).toBe(false); + }); + + it('should call errorCallback when task throws non-abort error', async () => { + const errorCallback = vi.fn(); + const task = new CancellableTask(undefined, undefined, errorCallback); + const error = new Error('Task failed'); + const taskFn = async () => { + await Promise.resolve(); + throw error; + }; + + await task.execute(taskFn, true); + + expect(errorCallback).toHaveBeenCalledTimes(1); + expect(errorCallback).toHaveBeenCalledWith(error); + }); + + it('should return CANCELED when task throws AbortError', async () => { + const task = new CancellableTask(); + const taskFn = async () => { + await Promise.resolve(); + throw new DOMException('Aborted', 'AbortError'); + }; + + const result = await task.execute(taskFn, true); + + expect(result).toBe('CANCELED'); + expect(task.executed).toBe(false); + }); + + it('should allow re-execution after error', async () => { + const task = new CancellableTask(); + const taskFn1 = async () => { + await Promise.resolve(); + throw new Error('Failed'); + }; + const taskFn2 = vi.fn(async () => {}); + + const result1 = await task.execute(taskFn1, true); + expect(result1).toBe('ERRORED'); + + const result2 = await task.execute(taskFn2, true); + expect(result2).toBe('LOADED'); + expect(task.executed).toBe(true); + }); + }); + + describe('loading property', () => { + it('should return true when task is running', async () => { + const task = new CancellableTask(); + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + const taskFn = async () => { + await taskPromise; + }; + + expect(task.loading).toBe(false); + + const promise = task.execute(taskFn, true); + expect(task.loading).toBe(true); + + resolveTask!(); + await promise; + + expect(task.loading).toBe(false); + }); + }); + + describe('complete promise', () => { + it('should resolve when task completes successfully', async () => { + const task = new CancellableTask(); + const taskFn = vi.fn(async () => {}); + + const completePromise = task.complete; + await task.execute(taskFn, true); + await expect(completePromise).resolves.toBeUndefined(); + }); + + it('should reject when task is canceled', async () => { + const task = new CancellableTask(); + const taskFn = async (signal: AbortSignal) => { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + }; + + const completePromise = task.complete; + const promise = task.execute(taskFn, true); + await new Promise((resolve) => setTimeout(resolve, 10)); + task.cancel(); + await promise; + + await expect(completePromise).rejects.toBeUndefined(); + }); + + it('should reject when task errors', async () => { + const task = new CancellableTask(); + const taskFn = async () => { + await Promise.resolve(); + throw new Error('Failed'); + }; + + const completePromise = task.complete; + await task.execute(taskFn, true); + + await expect(completePromise).rejects.toBeUndefined(); + }); + }); + + describe('abort signal handling', () => { + it('should automatically call abort() on signal when task is canceled', async () => { + const task = new CancellableTask(); + let capturedSignal: AbortSignal | null = null; + const taskFn = async (signal: AbortSignal) => { + capturedSignal = signal; + // Simulate a long-running task + await new Promise((resolve) => setTimeout(resolve, 100)); + if (signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + }; + + const promise = task.execute(taskFn, true); + + // Wait a bit to ensure task has started + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(capturedSignal).not.toBeNull(); + expect(capturedSignal!.aborted).toBe(false); + + // Cancel the task + task.cancel(); + + // Verify the signal was aborted + expect(capturedSignal!.aborted).toBe(true); + + const result = await promise; + expect(result).toBe('CANCELED'); + }); + + it('should detect if signal was aborted after task completes', async () => { + const task = new CancellableTask(); + let controller: AbortController | null = null; + const taskFn = async (_: AbortSignal) => { + // Capture the controller to abort it externally + controller = task.cancelToken; + // Simulate some work + await new Promise((resolve) => setTimeout(resolve, 10)); + // Now abort before the function returns + controller?.abort(); + }; + + const result = await task.execute(taskFn, true); + + expect(result).toBe('CANCELED'); + expect(task.executed).toBe(false); + }); + + it('should handle abort signal in async operations', async () => { + const task = new CancellableTask(); + const taskFn = async (signal: AbortSignal) => { + // Simulate listening to abort signal during async operation + return new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + setTimeout(() => resolve(), 100); + }); + }; + + const promise = task.execute(taskFn, true); + await new Promise((resolve) => setTimeout(resolve, 10)); + task.cancel(); + + const result = await promise; + expect(result).toBe('CANCELED'); + }); + }); +}); diff --git a/web/src/lib/utils/cancellable-task.ts b/web/src/lib/utils/cancellable-task.ts index cf6335977a..f5f4d7830b 100644 --- a/web/src/lib/utils/cancellable-task.ts +++ b/web/src/lib/utils/cancellable-task.ts @@ -15,15 +15,7 @@ export class CancellableTask { private canceledCallback?: () => void, private errorCallback?: (error: unknown) => void, ) { - this.complete = new Promise((resolve, reject) => { - this.loadedSignal = resolve; - this.canceledSignal = reject; - }).catch( - () => - // if no-one waits on complete its rejected a uncaught rejection message is logged. - // prevent this message with an empty reject handler, since waiting on a bucket is optional. - void 0, - ); + this.init(); } get loading() { @@ -34,11 +26,30 @@ export class CancellableTask { if (this.executed) { return 'DONE'; } - // if there is a cancel token, task is currently executing, so wait on the promise. If it - // isn't, then the task is in new state, it hasn't been loaded, nor has it been executed. - // in either case, we wait on the promise. - await this.complete; - return 'WAITED'; + // The `complete` promise resolves when executed, rejects when canceled/errored. + try { + const complete = this.complete; + await complete; + return 'WAITED'; + } catch { + // ignore + } + return 'CANCELED'; + } + + async waitUntilExecution() { + // Keep retrying until the task completes successfully (not canceled) + for (;;) { + try { + if (this.executed) { + return 'DONE'; + } + await this.complete; + return 'WAITED'; + } catch { + continue; + } + } } async execute Promise>(f: F, cancellable: boolean) { @@ -80,21 +91,14 @@ export class CancellableTask { } private init() { - this.cancelToken = null; - this.executed = false; - // create a promise, and store its resolve/reject callbacks. The loadedSignal callback - // will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal - // callback will be called if the bucket is canceled before it was loaded, rejecting the - // promise. this.complete = new Promise((resolve, reject) => { + this.cancelToken = null; + this.executed = false; this.loadedSignal = resolve; this.canceledSignal = reject; - }).catch( - () => - // if no-one waits on complete its rejected a uncaught rejection message is logged. - // prevent this message with an empty reject handler, since waiting on a bucket is optional. - void 0, - ); + }); + // Suppress unhandled rejection warning + this.complete.catch(() => {}); } // will reset this job back to the initial state (isLoaded=false, no errors, etc) From dd744f8ee39f4aa18977a7f122b8dfafb1ef003e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 22 Dec 2025 13:33:49 -0500 Subject: [PATCH 010/174] refactor: album edit modal (#24786) --- .../components/album-page/albums-list.svelte | 61 ++++++----------- web/src/lib/managers/event-manager.svelte.ts | 1 + web/src/lib/modals/AlbumEditModal.svelte | 66 +++++++------------ web/src/lib/services/album.service.ts | 33 +++++++++- 4 files changed, 74 insertions(+), 87 deletions(-) diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index e4b588af8f..d3e0665de3 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -1,13 +1,9 @@ - + {#if albums.length > 0} {#if userSettings.view === AlbumViewMode.Cover} diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 6bc26220a7..6038c3c3f0 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -26,6 +26,7 @@ export type Events = { AssetReplace: [{ oldAssetId: string; newAssetId: string }]; + AlbumUpdate: [AlbumResponseDto]; AlbumDelete: [AlbumResponseDto]; QueueUpdate: [QueueResponseDto]; diff --git a/web/src/lib/modals/AlbumEditModal.svelte b/web/src/lib/modals/AlbumEditModal.svelte index 84a44c8395..668beacc21 100644 --- a/web/src/lib/modals/AlbumEditModal.svelte +++ b/web/src/lib/modals/AlbumEditModal.svelte @@ -1,63 +1,41 @@ - - -
-
-
From 2541011eaa625719bfc4009bcfe6792da345dcd2 Mon Sep 17 00:00:00 2001 From: Timon Date: Mon, 5 Jan 2026 13:12:44 +0100 Subject: [PATCH 036/174] fix(web): duplicate key error and enable expiration editing for expired shared links (#24686) Co-authored-by: Daniel Dietzler --- web/src/lib/components/SharedLinkExpiration.svelte | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/SharedLinkExpiration.svelte b/web/src/lib/components/SharedLinkExpiration.svelte index 735d9f8712..f8f6167084 100644 --- a/web/src/lib/components/SharedLinkExpiration.svelte +++ b/web/src/lib/components/SharedLinkExpiration.svelte @@ -1,7 +1,7 @@ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} @@ -175,6 +185,7 @@ {person} preAction={handlePreAction} onAction={handleAction} + onUndoDelete={handleUndoDelete} onPrevious={handlePrevious} onNext={handleNext} onRandom={handleRandom} From b7bb118c00dabba982c318b9b5e4809d0e90bc00 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Mon, 5 Jan 2026 08:30:33 -0500 Subject: [PATCH 038/174] chore(deployment): add healthcheck option for DB (#25024) --- docker/docker-compose.dev.yml | 2 ++ docker/docker-compose.prod.yml | 2 ++ docker/docker-compose.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 4c74d1d640..6c2bcb8926 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -146,6 +146,8 @@ services: ports: - 5432:5432 shm_size: 128mb + healthcheck: + disable: false # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics # immich-prometheus: # container_name: immich_prometheus diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 21178d8d76..b8471097ae 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -77,6 +77,8 @@ services: - 5432:5432 shm_size: 128mb restart: always + healthcheck: + disable: false # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics immich-prometheus: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f5dfb1233f..a58b8948a5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -69,6 +69,8 @@ services: - ${DB_DATA_LOCATION}:/var/lib/postgresql/data shm_size: 128mb restart: always + healthcheck: + disable: false volumes: model-cache: From e4311da1a4cafed1ca6217e170fe683ff632a6e1 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 5 Jan 2026 10:03:35 -0500 Subject: [PATCH 039/174] fix: shared-link-mapper (#24794) --- e2e/src/api/specs/shared-link.e2e-spec.ts | 38 +-- server/src/dtos/shared-link.dto.ts | 41 +-- .../src/services/shared-link.service.spec.ts | 3 +- server/src/services/shared-link.service.ts | 15 +- server/test/fixtures/shared-link.stub.ts | 267 +++++------------- .../components/album-page/albums-list.svelte | 24 +- .../asset-viewer/actions/share-action.svelte | 26 -- .../asset-viewer/asset-viewer-nav-bar.svelte | 10 +- .../lib/modals/SharedLinkCreateModal.svelte | 6 +- web/src/lib/services/asset.service.ts | 19 +- web/src/lib/services/shared-link.service.ts | 7 +- 11 files changed, 152 insertions(+), 304 deletions(-) delete mode 100644 web/src/lib/components/asset-viewer/actions/share-action.svelte diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index f25a54786a..8c15a14da5 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -20,7 +20,6 @@ describe('/shared-links', () => { let user1: LoginResponseDto; let user2: LoginResponseDto; let album: AlbumResponseDto; - let metadataAlbum: AlbumResponseDto; let deletedAlbum: AlbumResponseDto; let linkWithDeletedAlbum: SharedLinkResponseDto; let linkWithPassword: SharedLinkResponseDto; @@ -41,18 +40,9 @@ describe('/shared-links', () => { [asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]); - [album, deletedAlbum, metadataAlbum] = await Promise.all([ + [album, deletedAlbum] = await Promise.all([ createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }), createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }), - createAlbum( - { - createAlbumDto: { - albumName: 'metadata album', - assetIds: [asset1.id], - }, - }, - { headers: asBearerAuth(user1.accessToken) }, - ), ]); [linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] = @@ -75,14 +65,14 @@ describe('/shared-links', () => { password: 'foo', }), utils.createSharedLink(user1.accessToken, { - type: SharedLinkType.Album, - albumId: metadataAlbum.id, + type: SharedLinkType.Individual, + assetIds: [asset1.id], showMetadata: true, - slug: 'metadata-album', + slug: 'metadata-slug', }), utils.createSharedLink(user1.accessToken, { - type: SharedLinkType.Album, - albumId: metadataAlbum.id, + type: SharedLinkType.Individual, + assetIds: [asset1.id], showMetadata: false, }), ]); @@ -95,9 +85,7 @@ describe('/shared-links', () => { const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`); expect(resp.status).toBe(200); expect(resp.header['content-type']).toContain('text/html'); - expect(resp.text).toContain( - ``, - ); + expect(resp.text).toContain(``); }); it('should have correct asset count in meta tag for empty album', async () => { @@ -144,9 +132,7 @@ describe('/shared-links', () => { const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`); expect(resp.status).toBe(200); expect(resp.header['content-type']).toContain('text/html'); - expect(resp.text).toContain( - ``, - ); + expect(resp.text).toContain(``); }); }); @@ -271,12 +257,12 @@ describe('/shared-links', () => { ); }); - it('should return metadata for album shared link', async () => { + it('should return metadata for individual shared link', async () => { const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key }); expect(status).toBe(200); - expect(body.assets).toHaveLength(0); - expect(body.album).toBeDefined(); + expect(body.assets).toHaveLength(1); + expect(body.album).not.toBeDefined(); }); it('should not return metadata for album shared link without metadata', async () => { @@ -284,7 +270,7 @@ describe('/shared-links', () => { expect(status).toBe(200); expect(body.assets).toHaveLength(1); - expect(body.album).toBeDefined(); + expect(body.album).not.toBeDefined(); const asset = body.assets[0]; expect(asset).not.toHaveProperty('exifInfo'); diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index b2aad8957e..82698ebddc 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString } from 'class-validator'; -import _ from 'lodash'; import { SharedLink } from 'src/database'; import { HistoryBuilder, Property } from 'src/decorators'; import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto'; @@ -118,10 +117,10 @@ export class SharedLinkResponseDto { slug!: string | null; } -export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto { - const linkAssets = sharedLink.assets || []; +export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetadata: boolean }): SharedLinkResponseDto { + const assets = sharedLink.assets || []; - return { + const response = { id: sharedLink.id, description: sharedLink.description, password: sharedLink.password, @@ -130,35 +129,19 @@ export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto { type: sharedLink.type, createdAt: sharedLink.createdAt, expiresAt: sharedLink.expiresAt, - assets: linkAssets.map((asset) => mapAsset(asset)), - album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, - allowUpload: sharedLink.allowUpload, - allowDownload: sharedLink.allowDownload, - showMetadata: sharedLink.showExif, - slug: sharedLink.slug, - }; -} - -export function mapSharedLinkWithoutMetadata(sharedLink: SharedLink): SharedLinkResponseDto { - const linkAssets = sharedLink.assets || []; - const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset); - - const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id); - - return { - id: sharedLink.id, - description: sharedLink.description, - password: sharedLink.password, - userId: sharedLink.userId, - key: sharedLink.key.toString('base64url'), - type: sharedLink.type, - createdAt: sharedLink.createdAt, - expiresAt: sharedLink.expiresAt, - assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })), + assets: assets.map((asset) => mapAsset(asset, { stripMetadata: options.stripAssetMetadata })), album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, showMetadata: sharedLink.showExif, slug: sharedLink.slug, }; + + // unless we select sharedLink.album.sharedLinks this will be wrong + if (response.album) { + response.album.hasSharedLink = true; + response.album.shared = true; + } + + return response; } diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 062214b975..90c212650e 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -55,7 +55,8 @@ describe(SharedLinkService.name, () => { }, }); mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); - await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata); + const response = await sut.getMine(authDto, {}); + expect(response.assets[0]).toMatchObject({ hasMetadata: false }); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 199f0bf7a7..1440598084 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -6,7 +6,6 @@ import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { mapSharedLink, - mapSharedLinkWithoutMetadata, SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto, @@ -22,7 +21,7 @@ export class SharedLinkService extends BaseService { async getAll(auth: AuthDto, { id, albumId }: SharedLinkSearchDto): Promise { return this.sharedLinkRepository .getAll({ userId: auth.user.id, id, albumId }) - .then((links) => links.map((link) => mapSharedLink(link))); + .then((links) => links.map((link) => mapSharedLink(link, { stripAssetMetadata: false }))); } async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise { @@ -31,7 +30,7 @@ export class SharedLinkService extends BaseService { } const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id); - const response = this.mapToSharedLink(sharedLink, { withExif: sharedLink.showExif }); + const response = mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif }); if (sharedLink.password) { response.token = this.validateAndRefreshToken(sharedLink, dto); } @@ -41,7 +40,7 @@ export class SharedLinkService extends BaseService { async get(auth: AuthDto, id: string): Promise { const sharedLink = await this.findOrFail(auth.user.id, id); - return this.mapToSharedLink(sharedLink, { withExif: true }); + return mapSharedLink(sharedLink, { stripAssetMetadata: false }); } async create(auth: AuthDto, dto: SharedLinkCreateDto): Promise { @@ -81,7 +80,7 @@ export class SharedLinkService extends BaseService { slug: dto.slug || null, }); - return this.mapToSharedLink(sharedLink, { withExif: true }); + return mapSharedLink(sharedLink, { stripAssetMetadata: false }); } catch (error) { this.handleError(error); } @@ -108,7 +107,7 @@ export class SharedLinkService extends BaseService { showExif: dto.showMetadata, slug: dto.slug || null, }); - return this.mapToSharedLink(sharedLink, { withExif: true }); + return mapSharedLink(sharedLink, { stripAssetMetadata: false }); } catch (error) { this.handleError(error); } @@ -214,10 +213,6 @@ export class SharedLinkService extends BaseService { }; } - private mapToSharedLink(sharedLink: SharedLink, { withExif }: { withExif: boolean }) { - return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink); - } - private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string { const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`); const sharedLinkTokens = dto.token?.split(',') || []; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 19a62ad193..802b46a986 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -1,10 +1,7 @@ import { UserAdmin } from 'src/database'; -import { AlbumResponseDto } from 'src/dtos/album.dto'; -import { AssetResponseDto, MapAsset } from 'src/dtos/asset-response.dto'; -import { ExifResponseDto } from 'src/dtos/exif.dto'; +import { MapAsset } from 'src/dtos/asset-response.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; -import { mapUser } from 'src/dtos/user.dto'; -import { AssetOrder, AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum'; +import { AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -20,89 +17,6 @@ const sharedLinkBytes = Buffer.from( 'hex', ); -const assetInfo: ExifResponseDto = { - make: 'camera-make', - model: 'camera-model', - exifImageWidth: 500, - exifImageHeight: 500, - fileSizeInByte: 100, - orientation: 'orientation', - dateTimeOriginal: today, - modifyDate: today, - timeZone: 'America/Los_Angeles', - lensModel: 'fancy', - fNumber: 100, - focalLength: 100, - iso: 100, - exposureTime: '1/16', - latitude: 100, - longitude: 100, - city: 'city', - state: 'state', - country: 'country', - description: 'description', - projectionType: null, -}; - -const assetResponse: AssetResponseDto = { - id: 'id_1', - createdAt: today, - deviceAssetId: 'device_asset_id_1', - ownerId: 'user_id_1', - deviceId: 'device_id_1', - type: AssetType.Video, - originalMimeType: 'image/jpeg', - originalPath: 'fake_path/jpeg', - originalFileName: 'asset_1.jpeg', - thumbhash: null, - fileModifiedAt: today, - isOffline: false, - fileCreatedAt: today, - localDateTime: today, - updatedAt: today, - isFavorite: false, - isArchived: false, - duration: '0:00:00.00000', - exifInfo: assetInfo, - livePhotoVideoId: null, - tags: [], - people: [], - checksum: 'ZmlsZSBoYXNo', - isTrashed: false, - libraryId: 'library-id', - hasMetadata: true, - visibility: AssetVisibility.Timeline, -}; - -const assetResponseWithoutMetadata = { - id: 'id_1', - type: AssetType.Video, - originalMimeType: 'image/jpeg', - thumbhash: null, - localDateTime: today, - duration: '0:00:00.00000', - livePhotoVideoId: null, - hasMetadata: false, -} as AssetResponseDto; - -const albumResponse: AlbumResponseDto = { - albumName: 'Test Album', - description: '', - albumThumbnailAssetId: null, - createdAt: today, - updatedAt: today, - id: 'album-123', - ownerId: 'admin_id', - owner: mapUser(userStub.admin), - albumUsers: [], - shared: false, - hasSharedLink: false, - assets: [], - assetCount: 1, - isActivityEnabled: true, - order: AssetOrder.Desc, -}; - export const sharedLinkStub = { individual: Object.freeze({ id: '123', @@ -161,7 +75,7 @@ export const sharedLinkStub = { id: '123', userId: authStub.admin.user.id, key: sharedLinkBytes, - type: SharedLinkType.Album, + type: SharedLinkType.Individual, createdAt: today, expiresAt: tomorrow, allowUpload: false, @@ -169,97 +83,80 @@ export const sharedLinkStub = { showExif: false, description: null, password: null, - assets: [], - slug: null, - albumId: 'album-123', - album: { - id: 'album-123', - updateId: '42', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - albumName: 'Test Album', - description: '', - createdAt: today, - updatedAt: today, - deletedAt: null, - albumThumbnailAsset: null, - albumThumbnailAssetId: null, - albumUsers: [], - sharedLinks: [], - isActivityEnabled: true, - order: AssetOrder.Desc, - assets: [ - { - id: 'id_1', - status: AssetStatus.Active, - owner: undefined as unknown as UserAdmin, - ownerId: 'user_id_1', - deviceAssetId: 'device_asset_id_1', - deviceId: 'device_id_1', - type: AssetType.Video, - originalPath: 'fake_path/jpeg', - checksum: Buffer.from('file hash', 'utf8'), - fileModifiedAt: today, - fileCreatedAt: today, - localDateTime: today, - createdAt: today, + assets: [ + { + id: 'id_1', + status: AssetStatus.Active, + owner: undefined as unknown as UserAdmin, + ownerId: 'user_id_1', + deviceAssetId: 'device_asset_id_1', + deviceId: 'device_id_1', + type: AssetType.Video, + originalPath: 'fake_path/jpeg', + checksum: Buffer.from('file hash', 'utf8'), + fileModifiedAt: today, + fileCreatedAt: today, + localDateTime: today, + createdAt: today, + updatedAt: today, + isFavorite: false, + isArchived: false, + isExternal: false, + isOffline: false, + files: [], + thumbhash: null, + encodedVideoPath: '', + duration: null, + livePhotoVideo: null, + livePhotoVideoId: null, + originalFileName: 'asset_1.jpeg', + exifInfo: { + projectionType: null, + livePhotoCID: null, + assetId: 'id_1', + description: 'description', + exifImageWidth: 500, + exifImageHeight: 500, + fileSizeInByte: 100, + orientation: 'orientation', + dateTimeOriginal: today, + modifyDate: today, + timeZone: 'America/Los_Angeles', + latitude: 100, + longitude: 100, + city: 'city', + state: 'state', + country: 'country', + make: 'camera-make', + model: 'camera-model', + lensModel: 'fancy', + fNumber: 100, + focalLength: 100, + iso: 100, + exposureTime: '1/16', + fps: 100, + profileDescription: 'sRGB', + bitsPerSample: 8, + colorspace: 'sRGB', + autoStackId: null, + rating: 3, updatedAt: today, - isFavorite: false, - isArchived: false, - isExternal: false, - isOffline: false, - files: [], - thumbhash: null, - encodedVideoPath: '', - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - originalFileName: 'asset_1.jpeg', - exifInfo: { - projectionType: null, - livePhotoCID: null, - assetId: 'id_1', - description: 'description', - exifImageWidth: 500, - exifImageHeight: 500, - fileSizeInByte: 100, - orientation: 'orientation', - dateTimeOriginal: today, - modifyDate: today, - timeZone: 'America/Los_Angeles', - latitude: 100, - longitude: 100, - city: 'city', - state: 'state', - country: 'country', - make: 'camera-make', - model: 'camera-model', - lensModel: 'fancy', - fNumber: 100, - focalLength: 100, - iso: 100, - exposureTime: '1/16', - fps: 100, - profileDescription: 'sRGB', - bitsPerSample: 8, - colorspace: 'sRGB', - autoStackId: null, - rating: 3, - updatedAt: today, - updateId: '42', - }, - sharedLinks: [], - faces: [], - sidecarPath: null, - deletedAt: null, - duplicateId: null, updateId: '42', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, }, - ], - }, + sharedLinks: [], + faces: [], + sidecarPath: null, + deletedAt: null, + duplicateId: null, + updateId: '42', + libraryId: null, + stackId: null, + visibility: AssetVisibility.Timeline, + }, + ], + albumId: null, + album: null, + slug: null, }), passwordRequired: Object.freeze({ id: '123', @@ -312,20 +209,4 @@ export const sharedLinkResponseStub = { userId: 'admin_id', slug: null, }), - readonlyNoMetadata: Object.freeze({ - id: '123', - userId: 'admin_id', - key: sharedLinkBytes.toString('base64url'), - type: SharedLinkType.Album, - createdAt: today, - expiresAt: tomorrow, - description: null, - password: null, - allowUpload: false, - allowDownload: false, - showMetadata: false, - slug: null, - album: { ...albumResponse, startDate: assetResponse.localDateTime, endDate: assetResponse.localDateTime }, - assets: [{ ...assetResponseWithoutMetadata, exifInfo: undefined }], - }), }; diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index d3e0665de3..f6ca2b610e 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -13,8 +13,8 @@ AlbumGroupBy, AlbumSortBy, AlbumViewMode, - SortOrder, locale, + SortOrder, type AlbumViewSettings, } from '$lib/stores/preferences.store'; import { user } from '$lib/stores/user.store'; @@ -23,7 +23,12 @@ import type { ContextMenuPosition } from '$lib/utils/context-menu'; import { handleError } from '$lib/utils/handle-error'; import { normalizeSearchString } from '$lib/utils/string-utils'; - import { addUsersToAlbum, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk'; + import { + addUsersToAlbum, + type AlbumResponseDto, + type AlbumUserAddDto, + type SharedLinkResponseDto, + } from '@immich/sdk'; import { modalManager } from '@immich/ui'; import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js'; import { groupBy } from 'lodash-es'; @@ -208,12 +213,7 @@ } case 'sharedLink': { - const success = await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id }); - if (success) { - selectedAlbum.shared = true; - selectedAlbum.hasSharedLink = true; - onUpdate(selectedAlbum); - } + await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id }); break; } } @@ -274,9 +274,15 @@ ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id); sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id); }; + + const onSharedLinkCreate = (sharedLink: SharedLinkResponseDto) => { + if (sharedLink.album) { + onUpdate(sharedLink.album); + } + }; - + {#if albums.length > 0} {#if userSettings.view === AlbumViewMode.Cover} diff --git a/web/src/lib/components/asset-viewer/actions/share-action.svelte b/web/src/lib/components/asset-viewer/actions/share-action.svelte deleted file mode 100644 index bfd3312f3b..0000000000 --- a/web/src/lib/components/asset-viewer/actions/share-action.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 78138ae4ae..3bd08f0a7b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import CastButton from '$lib/cast/cast-button.svelte'; + import ActionButton from '$lib/components/ActionButton.svelte'; import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte'; import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte'; @@ -18,14 +19,13 @@ import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte'; 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 ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte'; import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte'; import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.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 { AppRoute } from '$lib/constants'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; - import { handleReplaceAsset } from '$lib/services/asset.service'; + import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { user } from '$lib/stores/user.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; @@ -113,6 +113,8 @@ let isLocked = $derived(asset.visibility === AssetVisibility.Locked); let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); + const { Share } = $derived(getAssetActions($t, asset)); + // $: showEditorButton = // isOwner && // asset.type === AssetTypeEnum.Image && @@ -135,9 +137,7 @@
- {#if !asset.isTrashed && $user && !isLocked} - - {/if} + {#if asset.isOffline} void; + onClose: () => void; albumId?: string; assetIds?: string[]; } - let { onClose, albumId = $bindable(), assetIds = $bindable([]) }: Props = $props(); + let { onClose, albumId, assetIds }: Props = $props(); let description = $state(''); let allowDownload = $state(true); @@ -44,7 +44,7 @@ slug, }); if (success) { - onClose(true); + onClose(); } }; diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index 245b4888ca..a64da2a6d6 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -1,6 +1,23 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; +import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; +import { user as authUser } from '$lib/stores/user.store'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; -import { copyAsset, deleteAssets } from '@immich/sdk'; +import { AssetVisibility, copyAsset, deleteAssets, type AssetResponseDto } from '@immich/sdk'; +import { modalManager, type ActionItem } from '@immich/ui'; +import { mdiShareVariantOutline } from '@mdi/js'; +import type { MessageFormatter } from 'svelte-i18n'; +import { get } from 'svelte/store'; + +export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => { + const Share: ActionItem = { + title: $t('share'), + icon: mdiShareVariantOutline, + $if: () => !!(get(authUser) && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked), + onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }), + }; + + return { Share }; +}; export const handleReplaceAsset = async (oldAssetId: string) => { const [newAssetId] = await openFileUploadDialog({ multiple: false }); diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts index 9f70024193..50069dc6d8 100644 --- a/web/src/lib/services/shared-link.service.ts +++ b/web/src/lib/services/shared-link.service.ts @@ -9,6 +9,7 @@ import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; import { createSharedLink, + getSharedLinkById, removeSharedLink, removeSharedLinkAssets, updateSharedLink, @@ -58,7 +59,11 @@ export const handleCreateSharedLink = async (dto: SharedLinkCreateDto) => { const $t = await getFormatter(); try { - const sharedLink = await createSharedLink({ sharedLinkCreateDto: dto }); + let sharedLink = await createSharedLink({ sharedLinkCreateDto: dto }); + if (dto.albumId) { + // fetch album details, for event + sharedLink = await getSharedLinkById({ id: sharedLink.id }); + } eventManager.emit('SharedLinkCreate', sharedLink); From 4147f1d912b2ca35ccfd89d18029def5ed64f0e0 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 5 Jan 2026 10:03:44 -0500 Subject: [PATCH 040/174] fix: duplicate api call on new library page (#25036) --- .../library-management/(list)/new/+page.svelte | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/web/src/routes/admin/library-management/(list)/new/+page.svelte b/web/src/routes/admin/library-management/(list)/new/+page.svelte index b236b83ce8..fdad5aa91e 100644 --- a/web/src/routes/admin/library-management/(list)/new/+page.svelte +++ b/web/src/routes/admin/library-management/(list)/new/+page.svelte @@ -4,20 +4,20 @@ import { AppRoute } from '$lib/constants'; import { handleCreateLibrary } from '$lib/services/library.service'; import { user } from '$lib/stores/user.store'; - import { searchUsersAdmin } from '@immich/sdk'; import { FormModal, Text } from '@immich/ui'; import { mdiFolderSync } from '@mdi/js'; - import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; + import { type PageData } from './$types'; + + type Props = { + data: PageData; + }; + + const { data }: Props = $props(); let ownerId: string = $state($user.id); - - let userOptions: { value: string; text: string }[] = $state([]); - - onMount(async () => { - const users = await searchUsersAdmin({}); - userOptions = users.map((user) => ({ value: user.id, text: user.name })); - }); + const users = $state(data.allUsers); + const userOptions = $derived(users.map((user) => ({ value: user.id, text: user.name }))); const onClose = async () => { await goto(AppRoute.ADMIN_LIBRARIES); From edd3ab7cc954253a085960bb3e390f0aefd1ee31 Mon Sep 17 00:00:00 2001 From: Daniel Ramos Date: Mon, 5 Jan 2026 16:21:02 +0100 Subject: [PATCH 041/174] feat(server): implement switchable logging formats (console/json) (#24791) * feat(server): add LogFormat enum and configuration * feat(server): add structured logging formatters * feat(server): implement switchable logging formats (console/json) * Revert "feat(server): add LogFormat enum and configuration" This reverts commit 565e95ae68277b809c080a62ea8881353be7b5e3. * feat(server): implement JSON logging using NestJS native support * refactor: rename LOG_FORMAT to IMMICH_LOG_FORMAT for consistency * docs: add IMMICH_LOG_FORMAT documentation * chore: format environment-variables.md * chore: format monitoring.md --- docs/docs/features/monitoring.md | 36 +++++++++++++++++++ docs/docs/install/environment-variables.md | 1 + server/src/dtos/env.dto.ts | 6 +++- server/src/enum.ts | 5 +++ server/src/repositories/config.repository.ts | 3 ++ server/src/repositories/logging.repository.ts | 21 +++++++---- .../repositories/config.repository.mock.ts | 3 +- 7 files changed, 67 insertions(+), 8 deletions(-) diff --git a/docs/docs/features/monitoring.md b/docs/docs/features/monitoring.md index f087a3306f..46063fded6 100644 --- a/docs/docs/features/monitoring.md +++ b/docs/docs/features/monitoring.md @@ -112,4 +112,40 @@ You can then make a new panel, specifying Prometheus as the data source for it. -- TODO: add images and more details here +## Structured Logging + +In addition to Prometheus metrics, Immich supports structured JSON logging which is ideal for log aggregation systems like Grafana Loki, ELK Stack, Datadog, Splunk, and others. + +### Configuration + +By default, Immich outputs human-readable console logs. To enable JSON logging, set the `IMMICH_LOG_FORMAT` environment variable: + +```bash +IMMICH_LOG_FORMAT=json +``` + +:::tip +The default is `IMMICH_LOG_FORMAT=console` for human-readable logs with colors during development. For production deployments using log aggregation, use `IMMICH_LOG_FORMAT=json`. +::: + +### JSON Log Format + +When enabled, logs are output in structured JSON format: + +```json +{"level":"log","pid":36,"timestamp":1766533331507,"message":"Initialized websocket server","context":"WebsocketRepository"} +{"level":"warn","pid":48,"timestamp":1766533331629,"message":"Unable to open /build/www/index.html, skipping SSR.","context":"ApiService"} +{"level":"error","pid":36,"timestamp":1766533331690,"message":"Failed to load plugin immich-core:","context":"Error"} +``` + +This format includes: + +- `level`: Log level (log, warn, error, etc.) +- `pid`: Process ID +- `timestamp`: Unix timestamp in milliseconds +- `message`: Log message +- `context`: Service or component that generated the log + +For more information on log formats, see [`IMMICH_LOG_FORMAT`](/install/environment-variables.md#general). + [prom-file]: https://github.com/immich-app/immich/releases/latest/download/prometheus.yml diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index e9d95cf3fe..a7494d5415 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -34,6 +34,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N | `TZ` | Timezone | \*1 | server | microservices | | `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices | | `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices | +| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices | | `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**\*2⚠️ | `/data` | server | api, microservices | | `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices | | `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | | diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts index 22f3d4dd32..e088a33413 100644 --- a/server/src/dtos/env.dto.ts +++ b/server/src/dtos/env.dto.ts @@ -1,6 +1,6 @@ import { Transform, Type } from 'class-transformer'; import { IsEnum, IsInt, IsString, Matches } from 'class-validator'; -import { DatabaseSslMode, ImmichEnvironment, LogLevel } from 'src/enum'; +import { DatabaseSslMode, ImmichEnvironment, LogFormat, LogLevel } from 'src/enum'; import { IsIPRange, Optional, ValidateBoolean } from 'src/validation'; export class EnvDto { @@ -48,6 +48,10 @@ export class EnvDto { @Optional() IMMICH_LOG_LEVEL?: LogLevel; + @IsEnum(LogFormat) + @Optional() + IMMICH_LOG_FORMAT?: LogFormat; + @Optional() @Matches(/^\//, { message: 'IMMICH_MEDIA_LOCATION must be an absolute path' }) IMMICH_MEDIA_LOCATION?: string; diff --git a/server/src/enum.ts b/server/src/enum.ts index 9d0a2c0426..b150cdbfb3 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -454,6 +454,11 @@ export enum LogLevel { Fatal = 'fatal', } +export enum LogFormat { + Console = 'console', + Json = 'json', +} + export enum ApiCustomExtension { Permission = 'x-immich-permission', AdminOnly = 'x-immich-admin-only', diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index b87fcd2bb8..54a5d1987f 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -17,6 +17,7 @@ import { ImmichHeader, ImmichTelemetry, ImmichWorker, + LogFormat, LogLevel, QueueName, } from 'src/enum'; @@ -29,6 +30,7 @@ export interface EnvData { environment: ImmichEnvironment; configFile?: string; logLevel?: LogLevel; + logFormat?: LogFormat; buildMetadata: { build?: string; @@ -233,6 +235,7 @@ const getEnv = (): EnvData => { environment, configFile: dto.IMMICH_CONFIG_FILE, logLevel: dto.IMMICH_LOG_LEVEL, + logFormat: dto.IMMICH_LOG_FORMAT || LogFormat.Console, buildMetadata: { build: dto.IMMICH_BUILD, diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index 576ee6c810..39867b14d0 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -2,7 +2,7 @@ import { ConsoleLogger, Inject, Injectable, Scope } from '@nestjs/common'; import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util'; import { ClsService } from 'nestjs-cls'; import { Telemetry } from 'src/decorators'; -import { LogLevel } from 'src/enum'; +import { LogFormat, LogLevel } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; type LogDetails = any; @@ -27,10 +27,12 @@ export class MyConsoleLogger extends ConsoleLogger { constructor( private cls: ClsService | undefined, - options?: { color?: boolean; context?: string }, + options?: { json?: boolean; color?: boolean; context?: string }, ) { - super(options?.context || MyConsoleLogger.name); - this.isColorEnabled = options?.color || false; + super(options?.context || MyConsoleLogger.name, { + json: options?.json ?? false, + }); + this.isColorEnabled = !options?.json && (options?.color || false); } isLevelEnabled(level: LogLevel) { @@ -79,10 +81,17 @@ export class LoggingRepository { @Inject(ConfigRepository) configRepository: ConfigRepository | undefined, ) { let noColor = false; + let logFormat = LogFormat.Console; if (configRepository) { - noColor = configRepository.getEnv().noColor; + const env = configRepository.getEnv(); + noColor = env.noColor; + logFormat = env.logFormat ?? logFormat; } - this.logger = new MyConsoleLogger(cls, { context: LoggingRepository.name, color: !noColor }); + this.logger = new MyConsoleLogger(cls, { + context: LoggingRepository.name, + json: logFormat === LogFormat.Json, + color: !noColor, + }); } static create(context?: string) { diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index 01e724529c..62e498372e 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -1,4 +1,4 @@ -import { DatabaseExtension, ImmichEnvironment, ImmichWorker } from 'src/enum'; +import { DatabaseExtension, ImmichEnvironment, ImmichWorker, LogFormat } from 'src/enum'; import { ConfigRepository, EnvData } from 'src/repositories/config.repository'; import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; @@ -6,6 +6,7 @@ import { Mocked, vitest } from 'vitest'; const envData: EnvData = { port: 2283, environment: ImmichEnvironment.Production, + logFormat: LogFormat.Console, buildMetadata: {}, bull: { From b190423d96384c1e64c1fe22faf82c77b3d3de35 Mon Sep 17 00:00:00 2001 From: Nikhil Alapati <35281285+NikhilAlapati@users.noreply.github.com> Date: Mon, 5 Jan 2026 07:26:45 -0800 Subject: [PATCH 042/174] fix(server): migrate motion part of live photo (#24688) Co-authored-by: Nikhil Alapati --- .../services/storage-template.service.spec.ts | 33 +++++++++++++++++++ .../src/services/storage-template.service.ts | 9 +++++ 2 files changed, 42 insertions(+) diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index c1898f8f12..0b5d538cea 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -616,6 +616,39 @@ describe(StorageTemplateService.name, () => { ); expect(mocks.asset.update).not.toHaveBeenCalled(); }); + + it('should migrate live photo motion video alongside the still image', async () => { + const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`; + const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`; + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([stillAsset])); + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset); + + mocks.move.create.mockResolvedValueOnce({ + id: '123', + entityId: stillAsset.id, + pathType: AssetPathType.Original, + oldPath: stillAsset.originalPath, + newPath: newStillPicturePath, + }); + + mocks.move.create.mockResolvedValueOnce({ + id: '124', + entityId: motionAsset.id, + pathType: AssetPathType.Original, + oldPath: motionAsset.originalPath, + newPath: newMotionPicturePath, + }); + + await sut.handleMigration(); + + expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); + expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(motionAsset.id); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath }); + }); }); describe('file rename correctness', () => { diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index bbf5a8a6bf..cd641d7036 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -188,6 +188,15 @@ export class StorageTemplateService extends BaseService { const storageLabel = user?.storageLabel || null; const filename = asset.originalFileName || asset.id; await this.moveAsset(asset, { storageLabel, filename }); + + // move motion part of live photo + if (asset.livePhotoVideoId) { + const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId); + if (livePhotoVideo) { + const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath); + await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }); + } + } } this.logger.debug('Cleaning up empty directories...'); From c87c1866ae80529082dcd1c4dd4645617f8efb5c Mon Sep 17 00:00:00 2001 From: Felipe Cury <26260549+flpcury@users.noreply.github.com> Date: Tue, 6 Jan 2026 00:36:48 +0900 Subject: [PATCH 043/174] fix: grammar in trigger_description string (#24867) Fix typo in trigger_description string --- i18n/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i18n/en.json b/i18n/en.json index 521eac10b1..ba3785ab35 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2153,7 +2153,7 @@ "trigger": "Trigger", "trigger_asset_uploaded": "Asset Uploaded", "trigger_asset_uploaded_description": "Triggered when a new asset is uploaded", - "trigger_description": "An event that kick off the workflow", + "trigger_description": "An event that kicks off the workflow", "trigger_person_recognized": "Person Recognized", "trigger_person_recognized_description": "Triggered when a person is detected", "trigger_type": "Trigger type", From eb718145c0e72a4b539b741ee2899bcc2ffb856f Mon Sep 17 00:00:00 2001 From: Flozza Date: Tue, 6 Jan 2026 02:10:53 +1030 Subject: [PATCH 044/174] docs: config options for hardware transcoding (#24853) --- docs/docs/features/hardware-transcoding.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/docs/features/hardware-transcoding.md b/docs/docs/features/hardware-transcoding.md index d28cd97de0..e68f6f6983 100644 --- a/docs/docs/features/hardware-transcoding.md +++ b/docs/docs/features/hardware-transcoding.md @@ -71,6 +71,22 @@ For RKMPP to work: 5. (Optional) Enable hardware decoding for optimal performance. +
+immich.json + +If you use a [configuration file](/install/config-file.md), use the `accel` option to select the hardware (e.g. `qsv` for Intel or `nvenc` for Nvidia). Set `accelDecode` to `true` if you want hardware decoding. + +```json +{ + "ffmpeg": { + "accel": "qsv", + "accelDecode": true + } +} +``` + +
+ #### Single Compose File Some platforms, including Unraid and Portainer, do not support multiple Compose files as of writing. As an alternative, you can "inline" the relevant contents of the [`hwaccel.transcoding.yml`][hw-file] file into the `immich-server` service directly. From 57fca378bc26afc38491b4d0d75fb86279235df4 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 5 Jan 2026 10:44:29 -0500 Subject: [PATCH 045/174] refactor: page container (#25038) --- .../ServerStatisticsPanel.svelte | 11 +++--- web/src/routes/admin/queues/+page.svelte | 14 ++++---- .../routes/admin/server-status/+page.svelte | 9 +++-- .../routes/admin/system-settings/+page.svelte | 34 +++++++++---------- 4 files changed, 32 insertions(+), 36 deletions(-) diff --git a/web/src/lib/components/server-statistics/ServerStatisticsPanel.svelte b/web/src/lib/components/server-statistics/ServerStatisticsPanel.svelte index 1fc01faf9c..319428c839 100644 --- a/web/src/lib/components/server-statistics/ServerStatisticsPanel.svelte +++ b/web/src/lib/components/server-statistics/ServerStatisticsPanel.svelte @@ -3,7 +3,7 @@ import { locale } from '$lib/stores/preferences.store'; import { getByteUnitString, getBytesWithUnit } from '$lib/utils/byte-units'; import type { ServerStatsResponseDto } from '@immich/sdk'; - import { Icon } from '@immich/ui'; + import { Heading, Icon } from '@immich/ui'; import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -25,15 +25,16 @@ let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0)); -
+
-

{$t('total_usage')}

+ {$t('total_usage')} -