feat: library details page (#23908)

* feat: library details page

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Daniel Dietzler
2025-11-18 21:27:41 +01:00
committed by GitHub
parent c086a65fa8
commit d310c6f3cd
29 changed files with 814 additions and 863 deletions

View File

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

View File

@@ -0,0 +1 @@
<svg width="900" height="600" viewBox="0 0 900 600" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="transparent" d="M0 0h900v600H0z"/><path d="M718.697 359.789c2.347 69.208-149.828 213.346-331.607 165.169-84.544-22.409-76.298-62.83-139.698-114.488-37.789-30.789-92.638-53.5-106.885-99.138-12.309-39.393-3.044-82.222 20.77-110.466 53.556-63.52 159.542-108.522 260.374-12.465 100.832 96.056 290.968-7.105 297.046 171.388z" fill="url(#a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M629.602 207.307v-51.154c0-28.251-22.902-51.153-51.154-51.153H322.681c-28.251 0-51.153 22.902-51.153 51.153v127.884" fill="#fff"/><path d="M629.602 207.307v-51.154c0-28.251-22.902-51.153-51.154-51.153H322.681c-28.251 0-51.153 22.902-51.153 51.153v127.884" stroke="#E1E4E5" stroke-width="4.13"/><path fill-rule="evenodd" clip-rule="evenodd" d="M271.528 216.252h165.353a25.578 25.578 0 0 0 21.28-11.382l35.884-53.941a25.575 25.575 0 0 1 21.357-11.407h114.2c28.251 0 51.154 22.902 51.154 51.153v255.767c0 28.252-22.903 51.154-51.154 51.154H271.528c-28.251 0-51.154-22.902-51.154-51.154V267.405c0-28.251 22.903-51.153 51.154-51.153z" fill="#fff" stroke="#E1E4E5" stroke-width="4.13"/><path fill-rule="evenodd" clip-rule="evenodd" d="M320.022 432.016v3.968h3.964A3.028 3.028 0 0 1 327 439a3.028 3.028 0 0 1-3.014 3.016h-3.964v3.968a3.028 3.028 0 0 1-3.014 3.016 3.029 3.029 0 0 1-3.014-3.016v-3.951h-3.98a3.029 3.029 0 0 1-3.014-3.017 3.029 3.029 0 0 1 3.014-3.016h3.964v-3.984a3.031 3.031 0 0 1 3.03-3.016 3.028 3.028 0 0 1 3.014 3.016zm-33.14-27.793v5.554h5.748c2.399 0 4.37 1.905 4.37 4.223 0 2.318-1.971 4.223-4.37 4.223h-5.748v5.554c0 2.318-1.971 4.223-4.37 4.223s-4.37-1.905-4.37-4.223v-5.531h-5.772c-2.399 0-4.37-1.905-4.37-4.223 0-2.318 1.971-4.223 4.37-4.223h5.748v-5.577c0-2.318 1.971-4.223 4.394-4.223 2.399 0 4.37 1.905 4.37 4.223z" fill="#E1E4E5"/><circle cx="451.101" cy="358.294" r="98.899" fill="#aaa"/><rect x="444.142" y="322.427" width="13.918" height="71.734" rx="6.959" fill="#fff"/><rect x="486.968" y="351.335" width="13.918" height="71.734" rx="6.959" transform="rotate(90 486.968 351.335)" fill="#fff"/><ellipse rx="13.917" ry="13.254" transform="matrix(-1 0 0 1 718.227 479.149)" fill="#E1E4E5"/><circle r="4.639" transform="matrix(-1 0 0 1 292.465 519.783)" fill="#E1E4E5"/><circle r="6.627" transform="matrix(-1 0 0 1 566.399 205.929)" fill="#E1E4E5"/><circle r="6.476" transform="scale(1 -1) rotate(-75 -180.786 -314.12)" fill="#E1E4E5"/><circle r="8.615" transform="matrix(-1 0 0 1 217.158 114.719)" fill="#E1E4E5"/><ellipse rx="6.627" ry="5.302" transform="matrix(-1 0 0 1 704.513 233.511)" fill="#E1E4E5"/><path d="M186.177 456.259h.174c1.026 14.545 11.844 14.769 11.844 14.769s-11.929.233-11.929 17.04c0-16.807-11.929-17.04-11.929-17.04s10.814-.224 11.84-14.769zm574.334-165.951h.18c1.067 15.36 12.309 15.596 12.309 15.596s-12.397.246-12.397 17.994c0-17.748-12.396-17.994-12.396-17.994s11.237-.236 12.304-15.596z" fill="#E1E4E5"/><defs><linearGradient id="a" x1="530.485" y1="779.032" x2="277.414" y2="-357.319" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#EEE"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,207 +0,0 @@
<script lang="ts">
import LibraryImportPathModal from '$lib/modals/LibraryImportPathModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import type { ValidateLibraryImportPathResponseDto } from '@immich/sdk';
import { validate, type LibraryResponseDto } from '@immich/sdk';
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiAlertOutline, mdiCheckCircleOutline, mdiPencilOutline, mdiRefresh } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
library: LibraryResponseDto;
onCancel: () => void;
onSubmit: (library: LibraryResponseDto) => void;
}
let { library = $bindable(), onCancel, onSubmit }: Props = $props();
let validatedPaths: ValidateLibraryImportPathResponseDto[] = $state([]);
let importPaths = $derived(validatedPaths.map((validatedPath) => validatedPath.importPath));
onMount(async () => {
if (library.importPaths) {
await handleValidation();
} else {
library.importPaths = [];
}
});
const handleValidation = async () => {
if (library.importPaths) {
const validation = await validate({
id: library.id,
validateLibraryDto: { importPaths: library.importPaths },
});
validatedPaths = validation.importPaths ?? [];
}
};
const revalidate = async (notifyIfSuccessful = true) => {
await handleValidation();
let failedPaths = 0;
for (const validatedPath of validatedPaths) {
if (!validatedPath.isValid) {
failedPaths++;
}
}
if (failedPaths === 0) {
if (notifyIfSuccessful) {
toastManager.success($t('admin.paths_validated_successfully'));
}
} else {
toastManager.warning($t('errors.paths_validation_failed', { values: { paths: failedPaths } }));
}
};
const handleAddImportPath = async (importPathToAdd: string | null) => {
if (!importPathToAdd) {
return;
}
if (!library.importPaths) {
library.importPaths = [];
}
try {
// Check so that import path isn't duplicated
if (!library.importPaths.includes(importPathToAdd)) {
library.importPaths.push(importPathToAdd);
await revalidate(false);
}
} catch (error) {
handleError(error, $t('errors.unable_to_add_import_path'));
}
};
const handleEditImportPath = async (editedImportPath: string | null, pathIndexToEdit: number) => {
if (editedImportPath === null) {
return;
}
if (!library.importPaths) {
library.importPaths = [];
}
try {
// Check so that import path isn't duplicated
if (!library.importPaths.includes(editedImportPath)) {
// Update import path
library.importPaths[pathIndexToEdit] = editedImportPath;
await revalidate(false);
}
} catch (error) {
handleError(error, $t('errors.unable_to_edit_import_path'));
}
};
const handleDeleteImportPath = async (pathIndexToDelete?: number) => {
if (pathIndexToDelete === undefined) {
return;
}
try {
if (!library.importPaths) {
library.importPaths = [];
}
const pathToDelete = library.importPaths[pathIndexToDelete];
library.importPaths = library.importPaths.filter((path) => path != pathToDelete);
await handleValidation();
} catch (error) {
handleError(error, $t('errors.unable_to_delete_import_path'));
}
};
const onEditImportPath = async (pathIndexToEdit?: number) => {
const result = await modalManager.show(LibraryImportPathModal, {
title: pathIndexToEdit === undefined ? $t('add_import_path') : $t('edit_import_path'),
submitText: pathIndexToEdit === undefined ? $t('add') : $t('save'),
isEditing: pathIndexToEdit !== undefined,
importPath: pathIndexToEdit === undefined ? null : library.importPaths[pathIndexToEdit],
importPaths: library.importPaths,
});
if (!result) {
return;
}
switch (result.action) {
case 'submit': {
// eslint-disable-next-line unicorn/prefer-ternary
if (pathIndexToEdit === undefined) {
await handleAddImportPath(result.importPath);
} else {
await handleEditImportPath(result.importPath, pathIndexToEdit);
}
break;
}
case 'delete': {
await handleDeleteImportPath(pathIndexToEdit);
break;
}
}
};
const onsubmit = (event: Event) => {
event.preventDefault();
onSubmit({ ...library });
};
</script>
<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4">
<table class="text-start">
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each validatedPaths as validatedPath, listIndex (validatedPath.importPath)}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-1/8 text-ellipsis ps-8 text-sm">
{#if validatedPath.isValid}
<Icon icon={mdiCheckCircleOutline} size="24" title={validatedPath.message} class="text-success" />
{:else}
<Icon icon={mdiAlertOutline} size="24" title={validatedPath.message} class="text-warning" />
{/if}
</td>
<td class="w-4/5 text-ellipsis px-4 text-sm">{validatedPath.importPath}</td>
<td class="w-1/5 text-ellipsis flex justify-center">
<IconButton
shape="round"
color="primary"
icon={mdiPencilOutline}
aria-label={$t('edit_import_path')}
onclick={() => onEditImportPath(listIndex)}
size="small"
/>
</td>
</tr>
{/each}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-4/5 text-ellipsis px-4 text-sm">
{#if importPaths.length === 0}
{$t('admin.no_paths_added')}
{/if}</td
>
<td class="w-1/5 text-ellipsis px-4 text-sm">
<Button shape="round" size="small" onclick={() => onEditImportPath()}>{$t('add_path')}</Button>
</td>
</tr>
</tbody>
</table>
<div class="flex justify-between w-full">
<div class="justify-end gap-2">
<Button shape="round" leadingIcon={mdiRefresh} size="small" color="secondary" onclick={() => revalidate()}
>{$t('validate')}</Button
>
</div>
<div class="flex justify-end gap-2">
<Button shape="round" size="small" color="secondary" onclick={onCancel}>{$t('cancel')}</Button>
<Button shape="round" size="small" type="submit">{$t('save')}</Button>
</div>
</div>
</form>

View File

@@ -1,151 +0,0 @@
<script lang="ts">
import LibraryExclusionPatternModal from '$lib/modals/LibraryExclusionPatternModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { type LibraryResponseDto } from '@immich/sdk';
import { Button, IconButton, modalManager } from '@immich/ui';
import { mdiPencilOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
library: Partial<LibraryResponseDto>;
onCancel: () => void;
onSubmit: (library: Partial<LibraryResponseDto>) => void;
}
let { library = $bindable(), onCancel, onSubmit }: Props = $props();
let exclusionPatterns: string[] = $state([]);
onMount(() => {
if (library.exclusionPatterns) {
exclusionPatterns = library.exclusionPatterns;
} else {
library.exclusionPatterns = [];
}
});
const handleAddExclusionPattern = (exclusionPatternToAdd: string) => {
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
try {
// Check so that exclusion pattern isn't duplicated
if (!library.exclusionPatterns.includes(exclusionPatternToAdd)) {
library.exclusionPatterns.push(exclusionPatternToAdd);
exclusionPatterns = library.exclusionPatterns;
}
} catch (error) {
handleError(error, $t('errors.unable_to_add_exclusion_pattern'));
}
};
const handleEditExclusionPattern = (editedExclusionPattern: string, patternIndex: number) => {
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
try {
library.exclusionPatterns[patternIndex] = editedExclusionPattern;
exclusionPatterns = library.exclusionPatterns;
} catch (error) {
handleError(error, $t('errors.unable_to_edit_exclusion_pattern'));
}
};
const handleDeleteExclusionPattern = (patternIndexToDelete?: number) => {
if (patternIndexToDelete === undefined) {
return;
}
try {
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
const patternToDelete = library.exclusionPatterns[patternIndexToDelete];
library.exclusionPatterns = library.exclusionPatterns.filter((path) => path != patternToDelete);
exclusionPatterns = library.exclusionPatterns;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_exclusion_pattern'));
}
};
const onEditExclusionPattern = async (patternIndexToEdit?: number) => {
const result = await modalManager.show(LibraryExclusionPatternModal, {
submitText: patternIndexToEdit === undefined ? $t('add') : $t('save'),
isEditing: patternIndexToEdit !== undefined,
exclusionPattern: patternIndexToEdit === undefined ? '' : exclusionPatterns[patternIndexToEdit],
exclusionPatterns,
});
if (!result) {
return;
}
switch (result.action) {
case 'submit': {
if (patternIndexToEdit === undefined) {
handleAddExclusionPattern(result.exclusionPattern);
} else {
handleEditExclusionPattern(result.exclusionPattern, patternIndexToEdit);
}
break;
}
case 'delete': {
handleDeleteExclusionPattern(patternIndexToEdit);
break;
}
}
};
const onsubmit = (event: Event) => {
event.preventDefault();
onSubmit(library);
};
</script>
<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4">
<table class="w-full text-start">
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each exclusionPatterns as exclusionPattern, listIndex (exclusionPattern)}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-3/4 text-ellipsis px-4 text-sm">{exclusionPattern}</td>
<td class="w-1/4 text-ellipsis flex justify-center">
<IconButton
shape="round"
color="primary"
icon={mdiPencilOutline}
title={$t('edit_exclusion_pattern')}
onclick={() => onEditExclusionPattern(listIndex)}
aria-label={$t('edit_exclusion_pattern')}
size="small"
/>
</td>
</tr>
{/each}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-3/4 text-ellipsis px-4 text-sm">
{#if exclusionPatterns.length === 0}
{$t('admin.no_pattern_added')}
{/if}
</td>
<td class="w-1/4 text-ellipsis px-4 text-sm flex justify-center">
<Button size="small" shape="round" onclick={() => onEditExclusionPattern()}>
{$t('add_exclusion_pattern')}
</Button>
</td>
</tr>
</tbody>
</table>
<div class="flex w-full justify-end gap-2">
<Button size="small" shape="round" color="secondary" onclick={onCancel}>{$t('cancel')}</Button>
<Button size="small" shape="round" type="submit">{$t('save')}</Button>
</div>
</form>

View File

@@ -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 @@
<svelte:element
this={onClick ? 'button' : 'div'}
onclick={onClick}
class="{width} m-auto mt-10 flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
class="{width} {className} flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
>
<img {src} alt="" width="500" draggable="false" />

View File

@@ -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<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void;

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { handleAddLibraryExclusionPattern } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
onClose: () => void;
};
const { library, onClose }: Props = $props();
let exclusionPattern = $state('');
const onsubmit = async () => {
const success = await handleAddLibraryExclusionPattern(library, exclusionPattern);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('add_exclusion_pattern')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-exclusion-pattern-form">
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
<Field label={$t('pattern')}>
<Input bind:value={exclusionPattern} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-exclusion-pattern-form">
{$t('add')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { handleEditExclusionPattern } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
exclusionPattern: string;
onClose: () => void;
};
const { library, exclusionPattern, onClose }: Props = $props();
let newExclusionPattern = $state(exclusionPattern);
const onsubmit = async () => {
const success = await handleEditExclusionPattern(library, exclusionPattern, newExclusionPattern);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('edit_exclusion_pattern')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-exclusion-pattern-form">
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
<Field label={$t('pattern')}>
<Input bind:value={newExclusionPattern} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-exclusion-pattern-form">
{$t('save')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -1,78 +0,0 @@
<script lang="ts">
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiFolderRemove } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
exclusionPattern: string;
exclusionPatterns?: string[];
isEditing?: boolean;
submitText?: string;
onClose: (data?: { action: 'delete' } | { action: 'submit'; exclusionPattern: string }) => void;
}
let {
exclusionPattern = $bindable(),
exclusionPatterns = $bindable([]),
isEditing = false,
submitText = $t('submit'),
onClose,
}: Props = $props();
onMount(() => {
if (isEditing) {
exclusionPatterns = exclusionPatterns.filter((pattern) => pattern !== exclusionPattern);
}
});
let isDuplicate = $derived(exclusionPattern !== null && exclusionPatterns.includes(exclusionPattern));
let canSubmit = $derived(exclusionPattern && !exclusionPatterns.includes(exclusionPattern));
const onsubmit = (event: Event) => {
event.preventDefault();
if (canSubmit) {
onClose({ action: 'submit', exclusionPattern });
}
};
</script>
<Modal size="small" title={$t('add_exclusion_pattern')} icon={mdiFolderRemove} {onClose}>
<ModalBody>
<form {onsubmit} autocomplete="off" id="add-exclusion-pattern-form">
<p class="py-5 text-sm">
{$t('admin.exclusion_pattern_description')}
<br /><br />
{$t('admin.add_exclusion_pattern_description')}
</p>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="exclusionPattern">{$t('pattern')}</label>
<input
class="immich-form-input"
id="exclusionPattern"
name="exclusionPattern"
type="text"
bind:value={exclusionPattern}
/>
</div>
<div class="mt-8 flex w-full gap-4">
{#if isDuplicate}
<p class="text-red-500 text-sm">{$t('errors.exclusion_pattern_already_exists')}</p>
{/if}
</div>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
{#if isEditing}
<Button shape="round" color="danger" fullWidth onclick={() => onClose({ action: 'delete' })}
>{$t('delete')}</Button
>
{/if}
<Button shape="round" type="submit" disabled={!canSubmit} fullWidth form="add-exclusion-pattern-form">
{submitText}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { handleAddLibraryFolder } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
onClose: () => void;
};
const { library, onClose }: Props = $props();
let folder = $state('');
const onsubmit = async () => {
const success = await handleAddLibraryFolder(library, folder);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('library_add_folder')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-import-path-form">
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
<Field label={$t('path')}>
<Input bind:value={folder} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-import-path-form">
{$t('add')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { handleEditLibraryFolder } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
folder: string;
onClose: () => void;
};
const { library, folder, onClose }: Props = $props();
let newFolder = $state(folder);
const onsubmit = async () => {
const success = await handleEditLibraryFolder(library, folder, newFolder);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('library_edit_folder')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-import-path-form">
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
<Field label={$t('path')}>
<Input bind:value={newFolder} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-import-path-form">
{$t('save')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -1,75 +0,0 @@
<script lang="ts">
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
importPath: string | null;
importPaths?: string[];
title?: string;
cancelText?: string;
submitText?: string;
isEditing?: boolean;
onClose: (data?: { action: 'delete' } | { action: 'submit'; importPath: string | null }) => void;
}
let {
importPath = $bindable(),
importPaths = $bindable([]),
title = $t('import_path'),
cancelText = $t('cancel'),
submitText = $t('save'),
isEditing = false,
onClose,
}: Props = $props();
onMount(() => {
if (isEditing) {
importPaths = importPaths.filter((path) => path !== importPath);
}
});
let isDuplicate = $derived(importPath !== null && importPaths.includes(importPath));
let canSubmit = $derived(importPath !== '' && importPath !== null && !importPaths.includes(importPath));
const onsubmit = (event: Event) => {
event.preventDefault();
if (canSubmit) {
onClose({ action: 'submit', importPath });
}
};
</script>
<Modal {title} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-import-path-form">
<p class="py-5 text-sm">{$t('admin.library_import_path_description')}</p>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="path">{$t('path')}</label>
<input class="immich-form-input" id="path" name="path" type="text" bind:value={importPath} />
</div>
<div class="mt-8 flex w-full gap-4">
{#if isDuplicate}
<p class="text-red-500 text-sm">{$t('errors.import_path_already_exists')}</p>
{/if}
</div>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{cancelText}</Button>
{#if isEditing}
<Button shape="round" color="danger" fullWidth onclick={() => onClose({ action: 'delete' })}>
{$t('delete')}
</Button>
{/if}
<Button shape="round" type="submit" disabled={!canSubmit} fullWidth form="library-import-path-form">
{submitText}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -1,21 +1,25 @@
<script lang="ts">
import { handleRenameLibrary } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiRenameOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
library: Partial<LibraryResponseDto>;
onClose: (library?: Partial<LibraryResponseDto>) => void;
}
type Props = {
library: LibraryResponseDto;
onClose: () => void;
};
let { library, onClose }: Props = $props();
let newName = $state(library.name);
const onsubmit = (event: Event) => {
event.preventDefault();
onClose({ ...library, name: newName });
const onsubmit = async () => {
const success = await handleRenameLibrary(library, newName);
if (success) {
onClose();
}
};
</script>

View File

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

View File

@@ -52,7 +52,7 @@
bind:albumGroupIds={albumGroups}
>
{#snippet empty()}
<EmptyPlaceholder text={$t('no_albums_message')} onClick={() => createAlbumAndRedirect()} />
<EmptyPlaceholder text={$t('no_albums_message')} onClick={() => createAlbumAndRedirect()} class="mt-10 mx-auto" />
{/snippet}
</Albums>
</UserPageLayout>

View File

@@ -54,7 +54,7 @@
onEscape={handleEscape}
>
{#snippet empty()}
<EmptyPlaceholder text={$t('no_archived_assets_message')} />
<EmptyPlaceholder text={$t('no_archived_assets_message')} class="mt-10 mx-auto" />
{/snippet}
</Timeline>
</UserPageLayout>

View File

@@ -114,6 +114,6 @@
{/if}
{#if !hasPeople && places.length === 0}
<EmptyPlaceholder text={$t('no_explore_results_message')} />
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mt-10 mx-auto" />
{/if}
</UserPageLayout>

View File

@@ -59,7 +59,7 @@
onEscape={handleEscape}
>
{#snippet empty()}
<EmptyPlaceholder text={$t('no_favorites_message')} />
<EmptyPlaceholder text={$t('no_favorites_message')} class="mt-10 mx-auto" />
{/snippet}
</Timeline>
</UserPageLayout>

View File

@@ -65,7 +65,7 @@
removeAction={AssetAction.SET_VISIBILITY_TIMELINE}
>
{#snippet empty()}
<EmptyPlaceholder text={$t('no_locked_photos_message')} title={$t('nothing_here_yet')} />
<EmptyPlaceholder text={$t('no_locked_photos_message')} title={$t('nothing_here_yet')} class="mt-10 mx-auto" />
{/snippet}
</Timeline>
</UserPageLayout>

View File

@@ -101,7 +101,7 @@
<MemoryLane />
{/if}
{#snippet empty()}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} />
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" />
{/snippet}
</Timeline>
</UserPageLayout>

View File

@@ -94,7 +94,7 @@
<Albums sharedAlbums={data.sharedAlbums} userSettings={settings} showOwner>
<!-- Empty List -->
{#snippet empty()}
<EmptyPlaceholder text={$t('no_shared_albums_message')} src={empty2Url} />
<EmptyPlaceholder text={$t('no_shared_albums_message')} src={empty2Url} class="mt-10 mx-auto" />
{/snippet}
</Albums>
</div>

View File

@@ -104,7 +104,7 @@
})}
</p>
{#snippet empty()}
<EmptyPlaceholder text={$t('trash_no_results_message')} src={empty3Url} />
<EmptyPlaceholder text={$t('trash_no_results_message')} src={empty3Url} class="mt-10 mx-auto" />
{/snippet}
</Timeline>
</UserPageLayout>

View File

@@ -208,7 +208,7 @@
{/if}
{/snippet}
{#snippet empty()}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => {}} />
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => {}} class="mt-10 mx-auto" />
{/snippet}
</Timeline>
</UserPageLayout>

View File

@@ -1,36 +1,17 @@
<script lang="ts">
import LibraryImportPathsForm from '$lib/components/forms/library-import-paths-form.svelte';
import LibraryScanSettingsForm from '$lib/components/forms/library-scan-settings-form.svelte';
import { goto } from '$app/navigation';
import HeaderButton from '$lib/components/HeaderButton.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.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 OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import LibraryImportPathModal from '$lib/modals/LibraryImportPathModal.svelte';
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
import LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte';
import { AppRoute } from '$lib/constants';
import { getLibrariesActions, handleCreateLibrary, handleViewLibrary } from '$lib/services/library.service';
import { locale } from '$lib/stores/preferences.store';
import { ByteUnit, getBytesWithUnit } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import {
createLibrary,
deleteLibrary,
getAllLibraries,
getLibraryStatistics,
getUserAdmin,
QueueCommand,
QueueName,
runQueueCommandLegacy,
scanLibrary,
updateLibrary,
type LibraryResponseDto,
type LibraryStatsResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { Button, LoadingSpinner, modalManager, Text, toastManager } from '@immich/ui';
import { mdiDotsVertical, mdiPlusBoxOutline, mdiSync } from '@mdi/js';
import { onMount } from 'svelte';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getLibrary, getLibraryStatistics, getUserAdmin, type LibraryResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade, slide } from 'svelte/transition';
import { fade } from 'svelte/transition';
import type { PageData } from './$types';
interface Props {
@@ -39,230 +20,57 @@
let { data }: Props = $props();
let libraries: LibraryResponseDto[] = $state([]);
let libraries = $state(data.libraries);
let statistics = $state(data.statistics);
let owners = $state(data.owners);
let stats: LibraryStatsResponseDto[] = [];
let owner: UserResponseDto[] = $state([]);
let photos: number[] = $state([]);
let videos: number[] = $state([]);
let totalCount: number[] = $state([]);
let diskUsage: number[] = $state([]);
let diskUsageUnit: ByteUnit[] = $state([]);
let editImportPaths: number | undefined = $state();
let editScanSettings: number | undefined = $state();
let dropdownOpen: boolean[] = [];
const handleLibraryAdd = async (library: LibraryResponseDto) => {
statistics[library.id] = await getLibraryStatistics({ id: library.id });
owners[library.id] = await getUserAdmin({ id: library.ownerId });
libraries.push(library);
onMount(async () => {
await readLibraryList();
});
const closeAll = () => {
editImportPaths = undefined;
editScanSettings = undefined;
for (let index = 0; index < dropdownOpen.length; index++) {
dropdownOpen[index] = false;
}
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
};
const refreshStats = async (listIndex: number) => {
stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id });
owner[listIndex] = await getUserAdmin({ id: libraries[listIndex].ownerId });
photos[listIndex] = stats[listIndex].photos;
videos[listIndex] = stats[listIndex].videos;
totalCount[listIndex] = stats[listIndex].total;
[diskUsage[listIndex], diskUsageUnit[listIndex]] = getBytesWithUnit(stats[listIndex].usage, 0);
};
const handleLibraryUpdate = async (library: LibraryResponseDto) => {
const index = libraries.findIndex(({ id }) => id === library.id);
async function readLibraryList() {
libraries = await getAllLibraries();
dropdownOpen.length = libraries.length;
for (let index = 0; index < libraries.length; index++) {
await refreshStats(index);
dropdownOpen[index] = false;
}
}
const handleCreate = async (ownerId: string) => {
let createdLibrary: LibraryResponseDto | undefined;
try {
createdLibrary = await createLibrary({ createLibraryDto: { ownerId } });
toastManager.success($t('admin.library_created', { values: { library: createdLibrary.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_create_library'));
} finally {
await readLibraryList();
}
if (createdLibrary) {
// Open the import paths form for the newly created library
const createdLibraryIndex = libraries.findIndex((library) => library.id === createdLibrary.id);
const result = await modalManager.show(LibraryImportPathModal, {
title: $t('add_import_path'),
submitText: $t('add'),
importPath: null,
});
if (!result) {
if (createdLibraryIndex !== null) {
onEditImportPathClicked(createdLibraryIndex);
}
return;
}
switch (result.action) {
case 'submit': {
handleAddImportPath(result.importPath, createdLibraryIndex);
break;
}
case 'delete': {
await handleDelete(libraries[createdLibraryIndex], createdLibraryIndex);
break;
}
}
}
};
const handleAddImportPath = (newImportPath: string | null, libraryIndex: number) => {
if ((libraryIndex !== 0 && !libraryIndex) || !newImportPath) {
if (index === -1) {
return;
}
try {
onEditImportPathClicked(libraryIndex);
libraries[libraryIndex].importPaths.push(newImportPath);
} catch (error) {
handleError(error, $t('errors.unable_to_add_import_path'));
}
libraries[index] = await getLibrary({ id: library.id });
statistics[library.id] = await getLibraryStatistics({ id: library.id });
};
const handleUpdate = async (library: Partial<LibraryResponseDto>, libraryIndex: number) => {
try {
const libraryId = libraries[libraryIndex].id;
await updateLibrary({ id: libraryId, updateLibraryDto: library });
closeAll();
await readLibraryList();
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
}
const handleDeleteLibrary = ({ id }: { id: string }) => {
libraries = libraries.filter((library) => library.id !== id);
delete statistics[id];
delete owners[id];
};
const handleScanAll = async () => {
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 handleScan = async (libraryId: string) => {
try {
await scanLibrary({ id: libraryId });
toastManager.info($t('admin.scanning_library'));
} catch (error) {
handleError(error, $t('errors.unable_to_scan_library'));
}
};
const onRenameClicked = async (index: number) => {
closeAll();
const result = await modalManager.show(LibraryRenameModal, {
library: libraries[index],
});
if (result) {
await handleUpdate(result, index);
}
};
const onEditImportPathClicked = (index: number) => {
closeAll();
editImportPaths = index;
};
const onScanClicked = async (library: LibraryResponseDto) => {
closeAll();
if (library) {
await handleScan(library.id);
}
};
const onCreateNewLibraryClicked = async () => {
const result = await modalManager.show(LibraryUserPickerModal);
if (result) {
await handleCreate(result);
}
};
const onScanSettingClicked = (index: number) => {
closeAll();
editScanSettings = index;
};
const handleDelete = async (library: LibraryResponseDto, index: number) => {
closeAll();
if (!library) {
return;
}
const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_delete_library', { values: { library: library.name } }),
});
if (!isConfirmed) {
return;
}
await refreshStats(index);
const assetCount = totalCount[index];
if (assetCount > 0) {
const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_delete_library_assets', { values: { count: assetCount } }),
});
if (!isConfirmed) {
return;
}
}
try {
await deleteLibrary({ id: library.id });
toastManager.success($t('admin.library_deleted'));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_library'));
} finally {
await readLibraryList();
}
};
const { Create, ScanAll } = $derived(getLibrariesActions($t));
</script>
<OnEvents
onLibraryCreate={handleLibraryAdd}
onLibraryUpdate={handleLibraryUpdate}
onLibraryDelete={handleDeleteLibrary}
/>
<AdminPageLayout title={data.meta.title}>
{#snippet buttons()}
<div class="flex justify-end gap-2">
{#if libraries.length > 0}
<Button leadingIcon={mdiSync} onclick={handleScanAll} size="small" variant="ghost" color="secondary">
<Text class="hidden md:block">{$t('scan_all_libraries')}</Text>
</Button>
<HeaderButton action={ScanAll} />
{/if}
<Button
leadingIcon={mdiPlusBoxOutline}
onclick={onCreateNewLibraryClicked}
size="small"
variant="ghost"
color="secondary"
>
<Text class="hidden md:block">{$t('create_library')}</Text>
</Button>
<HeaderButton action={Create} />
</div>
{/snippet}
<section class="my-4">
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
<div class="flex flex-col items-center gap-2" in:fade={{ duration: 500 }}>
{#if libraries.length > 0}
<table class="w-full text-start">
<table class="w-3/4 text-start">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
@@ -276,91 +84,36 @@
</tr>
</thead>
<tbody class="block overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#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)}
<tr
class="grid grid-cols-6 h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="text-ellipsis px-4 text-sm">{library.name}</td>
<td class="text-ellipsis px-4 text-sm">
{#if owner[index] == undefined}
<LoadingSpinner size="large" />
{:else}{owner[index].name}{/if}
{owners[library.id].name}
</td>
<td class="text-ellipsis px-4 text-sm">
{#if photos[index] == undefined}
<LoadingSpinner size="large" />
{:else}
{photos[index].toLocaleString($locale)}
{/if}
{photos.toLocaleString($locale)}
</td>
<td class="text-ellipsis px-4 text-sm">
{#if videos[index] == undefined}
<LoadingSpinner size="large" />
{:else}
{videos[index].toLocaleString($locale)}
{/if}
{videos.toLocaleString($locale)}
</td>
<td class="text-ellipsis px-4 text-sm">
{#if diskUsage[index] == undefined}
<LoadingSpinner size="large" />
{:else}
{diskUsage[index]}
{diskUsageUnit[index]}
{/if}
{diskUsage}
{diskUsageUnit}
</td>
<td class="text-ellipsis px-4 text-sm">
<ButtonContextMenu
align="top-right"
direction="left"
color="primary"
size="medium"
icon={mdiDotsVertical}
title={$t('library_options')}
variant="filled"
>
<MenuOption onClick={() => onScanClicked(library)} text={$t('scan_library')} />
<hr />
<MenuOption onClick={() => onRenameClicked(index)} text={$t('rename')} />
<MenuOption onClick={() => onEditImportPathClicked(index)} text={$t('edit_import_paths')} />
<MenuOption onClick={() => onScanSettingClicked(index)} text={$t('scan_settings')} />
<hr />
<MenuOption
onClick={() => handleDelete(library, index)}
activeColor="bg-red-200"
textColor="text-red-600"
text={$t('delete_library')}
/>
</ButtonContextMenu>
<td class="flex gap-2 text-ellipsis px-4 text-sm">
<Button size="small" onclick={() => handleViewLibrary(library)}>{$t('view')}</Button>
</td>
</tr>
{#if editImportPaths === index}
<!-- svelte-ignore node_invalid_placement_ssr -->
<div transition:slide={{ duration: 250 }}>
<LibraryImportPathsForm
{library}
onSubmit={(lib) => handleUpdate(lib, index)}
onCancel={() => (editImportPaths = undefined)}
/>
</div>
{/if}
{#if editScanSettings === index}
<!-- svelte-ignore node_invalid_placement_ssr -->
<div transition:slide={{ duration: 250 }} class="mb-4 ms-4 me-4">
<LibraryScanSettingsForm
{library}
onSubmit={(lib) => handleUpdate(lib, index)}
onCancel={() => (editScanSettings = undefined)}
/>
</div>
{/if}
{/each}
</tbody>
</table>
<!-- Empty message -->
{:else}
<EmptyPlaceholder text={$t('no_libraries_message')} onClick={onCreateNewLibraryClicked} />
<EmptyPlaceholder text={$t('no_libraries_message')} onClick={handleCreateLibrary} class="mt-10 mx-auto" />
{/if}
</div>
</section>

View File

@@ -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'),
},

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import { goto } from '$app/navigation';
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
import HeaderButton from '$lib/components/HeaderButton.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { AppRoute } from '$lib/constants';
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import {
getLibraryActions,
getLibraryExclusionPatternActions,
getLibraryFolderActions,
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { Card, CardBody, CardHeader, CardTitle, Code, Container, Heading, Icon, modalManager } from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
const statistics = data.statistics;
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
let library = $derived(data.library);
const { Rename, Delete, AddFolder, AddExclusionPattern, Scan } = $derived(getLibraryActions($t, library));
</script>
<OnEvents
onLibraryUpdate={(newLibrary) => (library = newLibrary)}
onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)}
/>
<AdminPageLayout title={data.meta.title}>
{#snippet buttons()}
<div class="flex justify-end gap-2">
<HeaderButton action={Scan} />
<HeaderButton action={Rename} />
<HeaderButton action={Delete} />
</div>
{/snippet}
<Container size="large" center>
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
<div class="flex flex-col lg:flex-row gap-4 col-span-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={statistics.photos} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
</div>
<Card color="secondary">
<CardHeader>
<div class="flex w-full justify-between items-center px-4 py-2">
<div class="flex gap-2 text-primary">
<Icon icon={mdiFolderOutline} size="1.5rem" />
<CardTitle>{$t('folders')}</CardTitle>
</div>
<HeaderButton action={AddFolder} />
</div>
</CardHeader>
<CardBody>
<div class="px-4 pb-7">
{#if library.importPaths.length === 0}
<EmptyPlaceholder
src={emptyFoldersUrl}
text={$t('admin.library_folder_description')}
fullWidth
onClick={() => modalManager.show(LibraryFolderAddModal, { library })}
/>
{:else}
<table class="w-full">
<tbody>
{#each library.importPaths as folder (folder)}
{@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
<tr class="h-12">
<td>
<Code>{folder}</Code>
</td>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} />
<TableButton action={Delete} />
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</CardBody>
</Card>
<Card color="secondary">
<CardHeader>
<div class="flex w-full justify-between items-center px-4 py-2">
<div class="flex gap-2 text-primary">
<Icon icon={mdiFilterMinusOutline} size="1.5rem" />
<CardTitle>{$t('exclusion_pattern')}</CardTitle>
</div>
<HeaderButton action={AddExclusionPattern} />
</div>
</CardHeader>
<CardBody>
<div class="px-4 pb-7">
<table class="w-full">
<tbody>
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
{@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
<tr class="h-12">
<td>
<Code>{exclusionPattern}</Code>
</td>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} />
<TableButton action={Delete} />
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</CardBody>
</Card>
</div>
</Container>
</AdminPageLayout>

View File

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

View File

@@ -77,36 +77,34 @@
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#if allUsers}
{#each allUsers as user (user.id)}
{@const UserAdminActions = getUserAdminActions($t, user)}
<tr
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
? 'bg-red-300 dark:bg-red-900'
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
{#each allUsers as user (user.id)}
{@const UserAdminActions = getUserAdminActions($t, user)}
<tr
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
? 'bg-red-300 dark:bg-red-900'
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
>
<td class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-ellipsis break-all px-2 text-sm">
{user.email}
</td>
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{user.name}</td>
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
<div class="container mx-auto flex flex-wrap justify-center">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
{getByteUnitString(user.quotaSizeInBytes, $locale)}
{:else}
<Icon icon={mdiInfinity} size="16" />
{/if}
</div>
</td>
<td
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
>
<td class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-ellipsis break-all px-2 text-sm">
{user.email}
</td>
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{user.name}</td>
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
<div class="container mx-auto flex flex-wrap justify-center">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
{getByteUnitString(user.quotaSizeInBytes, $locale)}
{:else}
<Icon icon={mdiInfinity} size="16" />
{/if}
</div>
</td>
<td
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
>
<TableButton action={UserAdminActions.View} />
<TableButton action={UserAdminActions.ContextMenu} />
</td>
</tr>
{/each}
{/if}
<TableButton action={UserAdminActions.View} />
<TableButton action={UserAdminActions.ContextMenu} />
</td>
</tr>
{/each}
</tbody>
</table>
</section>

View File

@@ -102,7 +102,7 @@
<Alert color="danger" class="my-4" title={$t('user_has_been_deleted')} icon={mdiTrashCanOutline} />
{/if}
<div class="grid gap-4 grod-cols-1 lg:grid-cols-2 w-full">
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<div class="col-span-full flex gap-4 items-center my-4">
<UserAvatar {user} size="md" />
<Heading tag="h1" size="large">{user.name}</Heading>