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 @@ - - -
- - - {#each validatedPaths as validatedPath, listIndex (validatedPath.importPath)} - - - - - - - {/each} - - - - - -
- {#if validatedPath.isValid} - - {:else} - - {/if} - {validatedPath.importPath} - onEditImportPath(listIndex)} - size="small" - /> -
- {#if importPaths.length === 0} - {$t('admin.no_paths_added')} - {/if} - -
-
-
- -
-
- - -
-
-
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 @@ - - -
- - - {#each exclusionPatterns as exclusionPattern, listIndex (exclusionPattern)} - - - - - {/each} - - - - - -
{exclusionPattern} - onEditExclusionPattern(listIndex)} - aria-label={$t('edit_exclusion_pattern')} - size="small" - /> -
- {#if exclusionPatterns.length === 0} - {$t('admin.no_pattern_added')} - {/if} - - -
- -
- - -
-
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 @@ + + + + +
+ {$t('admin.exclusion_pattern_description')} + + + + +
+
+ + + + + + + +
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 @@ + + + + +
+ {$t('admin.exclusion_pattern_description')} + + + + +
+
+ + + + + + + +
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 @@ - - - - -
-

- {$t('admin.exclusion_pattern_description')} -

- {$t('admin.add_exclusion_pattern_description')} -

-
- - -
-
- {#if isDuplicate} -

{$t('errors.exclusion_pattern_already_exists')}

- {/if} -
-
-
- - - - {#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 @@ + + + + +
+ {$t('admin.library_folder_description')} + + + + +
+
+ + + + + + + +
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 @@ + + + + +
+ {$t('admin.library_folder_description')} + + + + +
+
+ + + + + + + +
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 @@ - - - - -
-

{$t('admin.library_import_path_description')}

- -
- - -
- -
- {#if isDuplicate} -

{$t('errors.import_path_already_exists')}

- {/if} -
-
-
- - - - - {#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)} - - {#if editImportPaths === index} - -
- handleUpdate(lib, index)} - onCancel={() => (editImportPaths = undefined)} - /> -
- {/if} - {#if editScanSettings === index} - -
- handleUpdate(lib, index)} - onCancel={() => (editScanSettings = undefined)} - /> -
- {/if} {/each}
{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')} - /> -
+
+
- - {: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)} + + + + + {/each} + +
+ {folder} + + + +
+ {/if} +
+
+
+ + +
+
+ + {$t('exclusion_pattern')} +
+ +
+
+ +
+ + + {#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 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} -
+
{user.name}