From d310c6f3cd71544613ef360dc54cda8896b4e657 Mon Sep 17 00:00:00 2001
From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Date: Tue, 18 Nov 2025 21:27:41 +0100
Subject: [PATCH] feat: library details page (#23908)
* feat: library details page
* chore: clean up
---------
Co-authored-by: Jason Rasmussen
---
i18n/en.json | 17 +-
web/src/lib/assets/empty-folders.svg | 1 +
.../forms/library-import-paths-form.svelte | 207 ----------
.../forms/library-scan-settings-form.svelte | 151 --------
.../empty-placeholder.svelte | 5 +-
web/src/lib/managers/event-manager.svelte.ts | 5 +
.../LibraryExclusionPatternAddModal.svelte | 43 +++
.../LibraryExclusionPatternEditModal.svelte | 45 +++
.../LibraryExclusionPatternModal.svelte | 78 ----
.../lib/modals/LibraryFolderAddModal.svelte | 44 +++
.../lib/modals/LibraryFolderEditModal.svelte | 45 +++
.../lib/modals/LibraryImportPathModal.svelte | 75 ----
web/src/lib/modals/LibraryRenameModal.svelte | 18 +-
web/src/lib/services/library.service.ts | 352 ++++++++++++++++++
web/src/routes/(user)/albums/+page.svelte | 2 +-
.../[[assetId=id]]/+page.svelte | 2 +-
web/src/routes/(user)/explore/+page.svelte | 2 +-
.../[[assetId=id]]/+page.svelte | 2 +-
.../[[assetId=id]]/+page.svelte | 2 +-
.../(user)/photos/[[assetId=id]]/+page.svelte | 2 +-
web/src/routes/(user)/sharing/+page.svelte | 2 +-
.../[[assetId=id]]/+page.svelte | 2 +-
.../(user)/utilities/geolocation/+page.svelte | 2 +-
.../admin/library-management/+page.svelte | 343 +++--------------
.../routes/admin/library-management/+page.ts | 13 +-
.../library-management/[id]/+page.svelte | 131 +++++++
.../admin/library-management/[id]/+page.ts | 28 ++
web/src/routes/admin/users/+page.svelte | 56 ++-
web/src/routes/admin/users/[id]/+page.svelte | 2 +-
29 files changed, 814 insertions(+), 863 deletions(-)
create mode 100644 web/src/lib/assets/empty-folders.svg
delete mode 100644 web/src/lib/components/forms/library-import-paths-form.svelte
delete mode 100644 web/src/lib/components/forms/library-scan-settings-form.svelte
create mode 100644 web/src/lib/modals/LibraryExclusionPatternAddModal.svelte
create mode 100644 web/src/lib/modals/LibraryExclusionPatternEditModal.svelte
delete mode 100644 web/src/lib/modals/LibraryExclusionPatternModal.svelte
create mode 100644 web/src/lib/modals/LibraryFolderAddModal.svelte
create mode 100644 web/src/lib/modals/LibraryFolderEditModal.svelte
delete mode 100644 web/src/lib/modals/LibraryImportPathModal.svelte
create mode 100644 web/src/lib/services/library.service.ts
create mode 100644 web/src/routes/admin/library-management/[id]/+page.svelte
create mode 100644 web/src/routes/admin/library-management/[id]/+page.ts
diff --git a/i18n/en.json b/i18n/en.json
index 5edbd87973..8c4ee068fd 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -17,7 +17,6 @@
"add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern",
- "add_import_path": "Add import path",
"add_location": "Add location",
"add_more_users": "Add more users",
"add_partner": "Add partner",
@@ -113,13 +112,17 @@
"jobs_failed": "{jobCount, plural, other {# failed}}",
"library_created": "Created library: {library}",
"library_deleted": "Library deleted",
- "library_import_path_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
+ "library_details": "Library details",
+ "library_folder_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
+ "library_remove_exclusion_pattern_prompt": "Are you sure you want to remove this exclusion pattern?",
+ "library_remove_folder_prompt": "Are you sure you want to remove this import folder?",
"library_scanning": "Periodic Scanning",
"library_scanning_description": "Configure periodic library scanning",
"library_scanning_enable_description": "Enable periodic library scanning",
"library_settings": "External Library",
"library_settings_description": "Manage external library settings",
"library_tasks_description": "Scan external libraries for new and/or changed assets",
+ "library_updated": "Updated library",
"library_watching_enable_description": "Watch external libraries for file changes",
"library_watching_settings": "Library watching [EXPERIMENTAL]",
"library_watching_settings_description": "Automatically watch for changed files",
@@ -901,8 +904,6 @@
"edit_description_prompt": "Please select a new description:",
"edit_exclusion_pattern": "Edit exclusion pattern",
"edit_faces": "Edit faces",
- "edit_import_path": "Edit import path",
- "edit_import_paths": "Edit Import Paths",
"edit_key": "Edit key",
"edit_link": "Edit link",
"edit_location": "Edit location",
@@ -974,8 +975,8 @@
"failed_to_stack_assets": "Failed to stack assets",
"failed_to_unstack_assets": "Failed to un-stack assets",
"failed_to_update_notification_status": "Failed to update notification status",
- "import_path_already_exists": "This import path already exists.",
"incorrect_email_or_password": "Incorrect email or password",
+ "library_folder_already_exists": "This import path already exists.",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
@@ -984,7 +985,6 @@
"unable_to_add_assets_to_shared_link": "Unable to add assets to shared link",
"unable_to_add_comment": "Unable to add comment",
"unable_to_add_exclusion_pattern": "Unable to add exclusion pattern",
- "unable_to_add_import_path": "Unable to add import path",
"unable_to_add_partners": "Unable to add partners",
"unable_to_add_remove_archive": "Unable to {archived, select, true {remove asset from} other {add asset to}} archive",
"unable_to_add_remove_favorites": "Unable to {favorite, select, true {add asset to} other {remove asset from}} favorites",
@@ -1007,12 +1007,10 @@
"unable_to_delete_asset": "Unable to delete asset",
"unable_to_delete_assets": "Error deleting assets",
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
- "unable_to_delete_import_path": "Unable to delete import path",
"unable_to_delete_shared_link": "Unable to delete shared link",
"unable_to_delete_user": "Unable to delete user",
"unable_to_download_files": "Unable to download files",
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
- "unable_to_edit_import_path": "Unable to edit import path",
"unable_to_empty_trash": "Unable to empty trash",
"unable_to_enter_fullscreen": "Unable to enter fullscreen",
"unable_to_exit_fullscreen": "Unable to exit fullscreen",
@@ -1063,6 +1061,7 @@
"unable_to_update_user": "Unable to update user",
"unable_to_upload_file": "Unable to upload file"
},
+ "exclusion_pattern": "Exclusion pattern",
"exif": "Exif",
"exif_bottom_sheet_description": "Add Description...",
"exif_bottom_sheet_description_error": "Error updating description",
@@ -1251,6 +1250,8 @@
"let_others_respond": "Let others respond",
"level": "Level",
"library": "Library",
+ "library_add_folder": "Add folder",
+ "library_edit_folder": "Edit folder",
"library_options": "Library options",
"library_page_device_albums": "Albums on Device",
"library_page_new_album": "New album",
diff --git a/web/src/lib/assets/empty-folders.svg b/web/src/lib/assets/empty-folders.svg
new file mode 100644
index 0000000000..b4a58cf245
--- /dev/null
+++ b/web/src/lib/assets/empty-folders.svg
@@ -0,0 +1 @@
+
diff --git a/web/src/lib/components/forms/library-import-paths-form.svelte b/web/src/lib/components/forms/library-import-paths-form.svelte
deleted file mode 100644
index 02f82504a7..0000000000
--- a/web/src/lib/components/forms/library-import-paths-form.svelte
+++ /dev/null
@@ -1,207 +0,0 @@
-
-
-
diff --git a/web/src/lib/components/forms/library-scan-settings-form.svelte b/web/src/lib/components/forms/library-scan-settings-form.svelte
deleted file mode 100644
index fde3849599..0000000000
--- a/web/src/lib/components/forms/library-scan-settings-form.svelte
+++ /dev/null
@@ -1,151 +0,0 @@
-
-
-
diff --git a/web/src/lib/components/shared-components/empty-placeholder.svelte b/web/src/lib/components/shared-components/empty-placeholder.svelte
index ae7f9aab6a..78c675c93b 100644
--- a/web/src/lib/components/shared-components/empty-placeholder.svelte
+++ b/web/src/lib/components/shared-components/empty-placeholder.svelte
@@ -7,9 +7,10 @@
fullWidth?: boolean;
src?: string;
title?: string;
+ class?: string;
}
- let { onClick = undefined, text, fullWidth = false, src = empty1Url, title }: Props = $props();
+ let { onClick = undefined, text, fullWidth = false, src = empty1Url, title, class: className }: Props = $props();
let width = $derived(fullWidth ? 'w-full' : 'w-1/2');
@@ -22,7 +23,7 @@
diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts
index d4f90fc3ad..62fc9df8da 100644
--- a/web/src/lib/managers/event-manager.svelte.ts
+++ b/web/src/lib/managers/event-manager.svelte.ts
@@ -1,6 +1,7 @@
import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
import type {
AlbumResponseDto,
+ LibraryResponseDto,
LoginResponseDto,
SharedLinkResponseDto,
SystemConfigDto,
@@ -27,6 +28,10 @@ export type Events = {
UserAdminRestore: [UserAdminResponseDto];
SystemConfigUpdate: [SystemConfigDto];
+
+ LibraryCreate: [LibraryResponseDto];
+ LibraryUpdate: [LibraryResponseDto];
+ LibraryDelete: [{ id: string }];
};
type Listener, K extends keyof EventMap> = (...params: EventMap[K]) => void;
diff --git a/web/src/lib/modals/LibraryExclusionPatternAddModal.svelte b/web/src/lib/modals/LibraryExclusionPatternAddModal.svelte
new file mode 100644
index 0000000000..12c13f9a06
--- /dev/null
+++ b/web/src/lib/modals/LibraryExclusionPatternAddModal.svelte
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/modals/LibraryExclusionPatternEditModal.svelte b/web/src/lib/modals/LibraryExclusionPatternEditModal.svelte
new file mode 100644
index 0000000000..56207c8cf4
--- /dev/null
+++ b/web/src/lib/modals/LibraryExclusionPatternEditModal.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/modals/LibraryExclusionPatternModal.svelte b/web/src/lib/modals/LibraryExclusionPatternModal.svelte
deleted file mode 100644
index fe5da01c45..0000000000
--- a/web/src/lib/modals/LibraryExclusionPatternModal.svelte
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
-
-
-
-
- {#if isEditing}
-
- {/if}
-
-
-
-
diff --git a/web/src/lib/modals/LibraryFolderAddModal.svelte b/web/src/lib/modals/LibraryFolderAddModal.svelte
new file mode 100644
index 0000000000..67ae5bf773
--- /dev/null
+++ b/web/src/lib/modals/LibraryFolderAddModal.svelte
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/modals/LibraryFolderEditModal.svelte b/web/src/lib/modals/LibraryFolderEditModal.svelte
new file mode 100644
index 0000000000..c1ab657275
--- /dev/null
+++ b/web/src/lib/modals/LibraryFolderEditModal.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/modals/LibraryImportPathModal.svelte b/web/src/lib/modals/LibraryImportPathModal.svelte
deleted file mode 100644
index 5c1454fdd9..0000000000
--- a/web/src/lib/modals/LibraryImportPathModal.svelte
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- {#if isEditing}
-
- {/if}
-
-
-
-
diff --git a/web/src/lib/modals/LibraryRenameModal.svelte b/web/src/lib/modals/LibraryRenameModal.svelte
index af204cdf0e..0a7d675b11 100644
--- a/web/src/lib/modals/LibraryRenameModal.svelte
+++ b/web/src/lib/modals/LibraryRenameModal.svelte
@@ -1,21 +1,25 @@
diff --git a/web/src/lib/services/library.service.ts b/web/src/lib/services/library.service.ts
new file mode 100644
index 0000000000..415d6dae42
--- /dev/null
+++ b/web/src/lib/services/library.service.ts
@@ -0,0 +1,352 @@
+import { goto } from '$app/navigation';
+import { AppRoute } from '$lib/constants';
+import { eventManager } from '$lib/managers/event-manager.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 LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte';
+import type { ActionItem } from '$lib/types';
+import { handleError } from '$lib/utils/handle-error';
+import { getFormatter } from '$lib/utils/i18n';
+import {
+ createLibrary,
+ deleteLibrary,
+ QueueCommand,
+ QueueName,
+ runQueueCommandLegacy,
+ scanLibrary,
+ updateLibrary,
+ type LibraryResponseDto,
+} from '@immich/sdk';
+import { modalManager, toastManager } from '@immich/ui';
+import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
+import type { MessageFormatter } from 'svelte-i18n';
+
+export const getLibrariesActions = ($t: MessageFormatter) => {
+ const ScanAll: ActionItem = {
+ title: $t('scan_all_libraries'),
+ icon: mdiSync,
+ onSelect: () => void handleScanAllLibraries(),
+ };
+
+ const Create: ActionItem = {
+ title: $t('create_library'),
+ icon: mdiPlusBoxOutline,
+ onSelect: () => void handleCreateLibrary(),
+ };
+
+ return { ScanAll, Create };
+};
+
+export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
+ const Rename: ActionItem = {
+ icon: mdiPencilOutline,
+ title: $t('rename'),
+ onSelect: () => void modalManager.show(LibraryRenameModal, { library }),
+ };
+
+ const Delete: ActionItem = {
+ icon: mdiTrashCanOutline,
+ title: $t('delete'),
+ color: 'danger',
+ onSelect: () => void handleDeleteLibrary(library),
+ };
+
+ const AddFolder: ActionItem = {
+ icon: mdiPlusBoxOutline,
+ title: $t('add'),
+ onSelect: () => void modalManager.show(LibraryFolderAddModal, { library }),
+ };
+
+ const AddExclusionPattern: ActionItem = {
+ icon: mdiPlusBoxOutline,
+ title: $t('add'),
+ onSelect: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }),
+ };
+
+ const Scan: ActionItem = {
+ icon: mdiSync,
+ title: $t('scan_library'),
+ onSelect: () => void handleScanLibrary(library),
+ };
+
+ return { Rename, Delete, AddFolder, AddExclusionPattern, Scan };
+};
+
+export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
+ const Edit: ActionItem = {
+ icon: mdiPencilOutline,
+ title: $t('edit'),
+ onSelect: () => void modalManager.show(LibraryFolderEditModal, { folder, library }),
+ };
+
+ const Delete: ActionItem = {
+ icon: mdiTrashCanOutline,
+ title: $t('delete'),
+ onSelect: () => void handleDeleteLibraryFolder(library, folder),
+ };
+
+ return { Edit, Delete };
+};
+
+export const getLibraryExclusionPatternActions = (
+ $t: MessageFormatter,
+ library: LibraryResponseDto,
+ exclusionPattern: string,
+) => {
+ const Edit: ActionItem = {
+ icon: mdiPencilOutline,
+ title: $t('edit'),
+ onSelect: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
+ };
+
+ const Delete: ActionItem = {
+ icon: mdiTrashCanOutline,
+ title: $t('delete'),
+ onSelect: () => void handleDeleteExclusionPattern(library, exclusionPattern),
+ };
+
+ return { Edit, Delete };
+};
+
+const handleScanAllLibraries = async () => {
+ const $t = await getFormatter();
+
+ try {
+ await runQueueCommandLegacy({ name: QueueName.Library, queueCommandDto: { command: QueueCommand.Start } });
+ toastManager.info($t('admin.refreshing_all_libraries'));
+ } catch (error) {
+ handleError(error, $t('errors.unable_to_scan_libraries'));
+ }
+};
+
+const handleScanLibrary = async (library: LibraryResponseDto) => {
+ const $t = await getFormatter();
+ try {
+ await scanLibrary({ id: library.id });
+ toastManager.info($t('admin.scanning_library'));
+ } catch (error) {
+ handleError(error, $t('errors.unable_to_scan_library'));
+ }
+};
+
+export const handleViewLibrary = async (library: LibraryResponseDto) => {
+ await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
+};
+
+export const handleCreateLibrary = async () => {
+ const $t = await getFormatter();
+
+ const ownerId = await modalManager.show(LibraryUserPickerModal, {});
+ if (!ownerId) {
+ return;
+ }
+
+ try {
+ const createdLibrary = await createLibrary({ createLibraryDto: { ownerId } });
+ eventManager.emit('LibraryCreate', createdLibrary);
+ toastManager.success($t('admin.library_created', { values: { library: createdLibrary.name } }));
+ } catch (error) {
+ handleError(error, $t('errors.unable_to_create_library'));
+ }
+};
+
+export const handleRenameLibrary = async (library: { id: string }, name?: string) => {
+ const $t = await getFormatter();
+
+ if (!name) {
+ return false;
+ }
+
+ try {
+ const updatedLibrary = await updateLibrary({
+ id: library.id,
+ updateLibraryDto: { name },
+ });
+ eventManager.emit('LibraryUpdate', updatedLibrary);
+ toastManager.success($t('admin.library_updated'));
+ } catch (error) {
+ handleError(error, $t('errors.unable_to_update_library'));
+ return false;
+ }
+
+ return true;
+};
+
+const handleDeleteLibrary = async (library: LibraryResponseDto) => {
+ const $t = await getFormatter();
+
+ const confirmed = await modalManager.showDialog({
+ prompt: $t('admin.confirm_delete_library', { values: { library: library.name } }),
+ });
+
+ if (!confirmed) {
+ return;
+ }
+
+ if (library.assetCount > 0) {
+ const isConfirmed = await modalManager.showDialog({
+ prompt: $t('admin.confirm_delete_library_assets', { values: { count: library.assetCount } }),
+ });
+ if (!isConfirmed) {
+ return;
+ }
+ }
+
+ try {
+ await deleteLibrary({ id: library.id });
+ eventManager.emit('LibraryDelete', { id: library.id });
+ toastManager.success($t('admin.library_deleted'));
+ } catch (error) {
+ handleError(error, $t('errors.unable_to_remove_library'));
+ }
+};
+
+export const handleAddLibraryFolder = async (library: LibraryResponseDto, folder: string) => {
+ const $t = await getFormatter();
+
+ if (library.importPaths.includes(folder)) {
+ toastManager.danger($t('errors.library_folder_already_exists'));
+ return false;
+ }
+
+ try {
+ const updatedLibrary = await updateLibrary({
+ id: library.id,
+ updateLibraryDto: { importPaths: [...library.importPaths, folder] },
+ });
+ eventManager.emit('LibraryUpdate', updatedLibrary);
+ toastManager.success($t('admin.library_updated'));
+ } catch (error) {
+ handleError(error, $t('errors.unable_to_update_library'));
+ return false;
+ }
+
+ return true;
+};
+
+export const handleEditLibraryFolder = async (library: LibraryResponseDto, oldFolder: string, newFolder: string) => {
+ const $t = await getFormatter();
+
+ if (oldFolder === newFolder) {
+ return true;
+ }
+
+ const importPaths = library.importPaths.map((path) => (path === oldFolder ? newFolder : path));
+
+ try {
+ const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { importPaths } });
+ eventManager.emit('LibraryUpdate', updatedLibrary);
+ toastManager.success($t('admin.library_updated'));
+ } catch (error) {
+ handleError(error, $t('errors.unable_to_update_library'));
+ return false;
+ }
+
+ return true;
+};
+
+const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: string) => {
+ const $t = await getFormatter();
+
+ const confirmed = await modalManager.showDialog({
+ prompt: $t('admin.library_remove_folder_prompt'),
+ confirmText: $t('remove'),
+ });
+
+ if (!confirmed) {
+ return false;
+ }
+
+ try {
+ const updatedLibrary = await updateLibrary({
+ id: library.id,
+ updateLibraryDto: { importPaths: library.importPaths.filter((path) => path !== folder) },
+ });
+ eventManager.emit('LibraryUpdate', updatedLibrary);
+ toastManager.success($t('admin.library_updated'));
+ } catch (error) {
+ handleError(error, $t('errors.unable_to_update_library'));
+ return false;
+ }
+
+ return true;
+};
+
+export const handleAddLibraryExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => {
+ const $t = await getFormatter();
+
+ if (library.exclusionPatterns.includes(exclusionPattern)) {
+ toastManager.danger($t('errors.exclusion_pattern_already_exists'));
+ return false;
+ }
+
+ try {
+ const updatedLibrary = await updateLibrary({
+ id: library.id,
+ updateLibraryDto: { exclusionPatterns: [...library.exclusionPatterns, exclusionPattern] },
+ });
+ eventManager.emit('LibraryUpdate', updatedLibrary);
+ toastManager.success($t('admin.library_updated'));
+ } catch (error) {
+ handleError(error, $t('errors.unable_to_update_library'));
+ return false;
+ }
+
+ return true;
+};
+
+export const handleEditExclusionPattern = async (
+ library: LibraryResponseDto,
+ oldExclusionPattern: string,
+ newExclusionPattern: string,
+) => {
+ const $t = await getFormatter();
+
+ if (oldExclusionPattern === newExclusionPattern) {
+ return true;
+ }
+
+ const exclusionPatterns = library.exclusionPatterns.map((pattern) =>
+ pattern === oldExclusionPattern ? newExclusionPattern : pattern,
+ );
+
+ try {
+ const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { exclusionPatterns } });
+ eventManager.emit('LibraryUpdate', updatedLibrary);
+ toastManager.success($t('admin.library_updated'));
+ } catch (error) {
+ handleError(error, $t('errors.unable_to_update_library'));
+ return false;
+ }
+
+ return true;
+};
+
+const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => {
+ const $t = await getFormatter();
+
+ const confirmed = await modalManager.showDialog({ prompt: $t('admin.library_remove_exclusion_pattern_prompt') });
+
+ if (!confirmed) {
+ return false;
+ }
+
+ try {
+ const updatedLibrary = await updateLibrary({
+ id: library.id,
+ updateLibraryDto: {
+ exclusionPatterns: library.exclusionPatterns.filter((pattern) => pattern !== exclusionPattern),
+ },
+ });
+ eventManager.emit('LibraryUpdate', updatedLibrary);
+ toastManager.success($t('admin.library_updated'));
+ } catch (error) {
+ handleError(error, $t('errors.unable_to_update_library'));
+ return false;
+ }
+
+ return true;
+};
diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte
index cdd13ba938..88bf67ca19 100644
--- a/web/src/routes/(user)/albums/+page.svelte
+++ b/web/src/routes/(user)/albums/+page.svelte
@@ -52,7 +52,7 @@
bind:albumGroupIds={albumGroups}
>
{#snippet empty()}
- createAlbumAndRedirect()} />
+ createAlbumAndRedirect()} class="mt-10 mx-auto" />
{/snippet}
diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 165ef59344..ac1ffc356c 100644
--- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -54,7 +54,7 @@
onEscape={handleEscape}
>
{#snippet empty()}
-
+
{/snippet}
diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte
index 86fb0850af..89505249b4 100644
--- a/web/src/routes/(user)/explore/+page.svelte
+++ b/web/src/routes/(user)/explore/+page.svelte
@@ -114,6 +114,6 @@
{/if}
{#if !hasPeople && places.length === 0}
-
+
{/if}
diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 676a68e673..4eebc59146 100644
--- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -59,7 +59,7 @@
onEscape={handleEscape}
>
{#snippet empty()}
-
+
{/snippet}
diff --git a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 510b609784..b16018bbf4 100644
--- a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -65,7 +65,7 @@
removeAction={AssetAction.SET_VISIBILITY_TIMELINE}
>
{#snippet empty()}
-
+
{/snippet}
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
index 748e0b7100..fde2aeda28 100644
--- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
@@ -101,7 +101,7 @@
{/if}
{#snippet empty()}
- openFileUploadDialog()} />
+ openFileUploadDialog()} class="mt-10 mx-auto" />
{/snippet}
diff --git a/web/src/routes/(user)/sharing/+page.svelte b/web/src/routes/(user)/sharing/+page.svelte
index a55452b5d1..6fcca2a8f4 100644
--- a/web/src/routes/(user)/sharing/+page.svelte
+++ b/web/src/routes/(user)/sharing/+page.svelte
@@ -94,7 +94,7 @@
{#snippet empty()}
-
+
{/snippet}
diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 99aad49285..b9019d3274 100644
--- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -104,7 +104,7 @@
})}
{#snippet empty()}
-
+
{/snippet}
diff --git a/web/src/routes/(user)/utilities/geolocation/+page.svelte b/web/src/routes/(user)/utilities/geolocation/+page.svelte
index 732e0625ab..a90c0b5632 100644
--- a/web/src/routes/(user)/utilities/geolocation/+page.svelte
+++ b/web/src/routes/(user)/utilities/geolocation/+page.svelte
@@ -208,7 +208,7 @@
{/if}
{/snippet}
{#snippet empty()}
- {}} />
+ {}} class="mt-10 mx-auto" />
{/snippet}
diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte
index 600b6ff048..37153d5003 100644
--- a/web/src/routes/admin/library-management/+page.svelte
+++ b/web/src/routes/admin/library-management/+page.svelte
@@ -1,36 +1,17 @@
+
+
{#snippet buttons()}
{#if libraries.length > 0}
-
+
{/if}
-
+
{/snippet}
-
+
{#if libraries.length > 0}
-
+
@@ -276,91 +84,36 @@
- {#each libraries as library, index (library.id)}
+ {#each libraries as library (library.id + library.name)}
+ {@const { photos, usage, videos } = statistics[library.id]}
+ {@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
| {library.name} |
- {#if owner[index] == undefined}
-
- {:else}{owner[index].name}{/if}
+ {owners[library.id].name}
|
- {#if photos[index] == undefined}
-
- {:else}
- {photos[index].toLocaleString($locale)}
- {/if}
+ {photos.toLocaleString($locale)}
|
- {#if videos[index] == undefined}
-
- {:else}
- {videos[index].toLocaleString($locale)}
- {/if}
+ {videos.toLocaleString($locale)}
|
- {#if diskUsage[index] == undefined}
-
- {:else}
- {diskUsage[index]}
- {diskUsageUnit[index]}
- {/if}
+ {diskUsage}
+ {diskUsageUnit}
|
-
-
- onScanClicked(library)} text={$t('scan_library')} />
-
- onRenameClicked(index)} text={$t('rename')} />
- onEditImportPathClicked(index)} text={$t('edit_import_paths')} />
- onScanSettingClicked(index)} text={$t('scan_settings')} />
-
- handleDelete(library, index)}
- activeColor="bg-red-200"
- textColor="text-red-600"
- text={$t('delete_library')}
- />
-
+ |
+
|
- {#if editImportPaths === index}
-
-
- handleUpdate(lib, index)}
- onCancel={() => (editImportPaths = undefined)}
- />
-
- {/if}
- {#if editScanSettings === index}
-
-
- handleUpdate(lib, index)}
- onCancel={() => (editScanSettings = undefined)}
- />
-
- {/if}
{/each}
-
-
{:else}
-
+
{/if}
diff --git a/web/src/routes/admin/library-management/+page.ts b/web/src/routes/admin/library-management/+page.ts
index 735c7fac92..cb7190b0e4 100644
--- a/web/src/routes/admin/library-management/+page.ts
+++ b/web/src/routes/admin/library-management/+page.ts
@@ -1,6 +1,6 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
-import { searchUsersAdmin } from '@immich/sdk';
+import { getAllLibraries, getLibraryStatistics, getUserAdmin, searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
@@ -9,8 +9,19 @@ export const load = (async ({ url }) => {
const allUsers = await searchUsersAdmin({ withDeleted: false });
const $t = await getFormatter();
+ const libraries = await getAllLibraries();
+ const statistics = await Promise.all(
+ libraries.map(async ({ id }) => [id, await getLibraryStatistics({ id })] as const),
+ );
+ const owners = await Promise.all(
+ libraries.map(async ({ id, ownerId }) => [id, await getUserAdmin({ id: ownerId })] as const),
+ );
+
return {
allUsers,
+ libraries,
+ statistics: Object.fromEntries(statistics),
+ owners: Object.fromEntries(owners),
meta: {
title: $t('admin.external_library_management'),
},
diff --git a/web/src/routes/admin/library-management/[id]/+page.svelte b/web/src/routes/admin/library-management/[id]/+page.svelte
new file mode 100644
index 0000000000..c6fffbbd95
--- /dev/null
+++ b/web/src/routes/admin/library-management/[id]/+page.svelte
@@ -0,0 +1,131 @@
+
+
+ (library = newLibrary)}
+ onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)}
+/>
+
+
+ {#snippet buttons()}
+
+
+
+
+
+ {/snippet}
+
+
+
{library.name}
+
+
+
+
+
+
+
+
+
+
+ {$t('folders')}
+
+
+
+
+
+
+ {#if library.importPaths.length === 0}
+
modalManager.show(LibraryFolderAddModal, { library })}
+ />
+ {:else}
+
+
+ {#each library.importPaths as folder (folder)}
+ {@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
+
+
+ {folder}
+ |
+
+
+
+ |
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+
+
+
+
+ {$t('exclusion_pattern')}
+
+
+
+
+
+
+
+
+ {#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
+ {@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
+
+
+ {exclusionPattern}
+ |
+
+
+
+ |
+
+ {/each}
+
+
+
+
+
+
+
+
diff --git a/web/src/routes/admin/library-management/[id]/+page.ts b/web/src/routes/admin/library-management/[id]/+page.ts
new file mode 100644
index 0000000000..77ce1eb1c8
--- /dev/null
+++ b/web/src/routes/admin/library-management/[id]/+page.ts
@@ -0,0 +1,28 @@
+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 }) => {
+ 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'),
+ },
+ };
+}) satisfies PageLoad;
diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte
index c4c1012774..d5a1fe0089 100644
--- a/web/src/routes/admin/users/+page.svelte
+++ b/web/src/routes/admin/users/+page.svelte
@@ -77,36 +77,34 @@
- {#if allUsers}
- {#each allUsers as user (user.id)}
- {@const UserAdminActions = getUserAdminActions($t, user)}
-
+ |
+ {user.email}
+ |
+ {user.name} |
+
+
+ {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
+ {getByteUnitString(user.quotaSizeInBytes, $locale)}
+ {:else}
+
+ {/if}
+
+ |
+
- |
- {user.email}
- |
- {user.name} |
-
-
- {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
- {getByteUnitString(user.quotaSizeInBytes, $locale)}
- {:else}
-
- {/if}
-
- |
-
-
-
- |
-
- {/each}
- {/if}
+
+
+
+
+ {/each}
diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte
index 49cfb4715a..a8b2264b6b 100644
--- a/web/src/routes/admin/users/[id]/+page.svelte
+++ b/web/src/routes/admin/users/[id]/+page.svelte
@@ -102,7 +102,7 @@
{/if}
-