merge: remote-tracking branch 'immich/main' into feat/integrity-checks-izzy

This commit is contained in:
izzy
2026-01-06 14:19:34 +00:00
368 changed files with 18243 additions and 6152 deletions
+1 -1
View File
@@ -1 +1 @@
24.11.1
24.12.0
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.3.1",
"version": "2.4.1",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -19,16 +19,16 @@
"format": "prettier --check .",
"format:fix": "prettier --write . && pnpm run format:i18n",
"format:i18n": "pnpm dlx sort-json ../i18n/*.json",
"test": "vitest --run",
"test": "vitest",
"test:cov": "vitest --coverage",
"test:watch": "vitest dev",
"prepare": "svelte-kit sync"
},
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.50.1",
"@immich/ui": "^0.52.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
@@ -47,7 +47,7 @@
"geojson": "^0.5.0",
"handlebars": "^4.7.8",
"happy-dom": "^20.0.0",
"intl-messageformat": "^10.7.11",
"intl-messageformat": "^11.0.0",
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
@@ -58,6 +58,7 @@
"socket.io-client": "~4.8.0",
"svelte-gestures": "^5.2.2",
"svelte-i18n": "^4.0.1",
"svelte-jsoneditor": "^3.10.0",
"svelte-maplibre": "^1.2.5",
"svelte-persisted-store": "^0.12.0",
"tabbable": "^6.2.0",
@@ -98,7 +99,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.43.3",
"svelte": "5.46.1",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",
@@ -108,6 +109,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.11.1"
"node": "24.12.0"
}
}
-19
View File
@@ -1,19 +0,0 @@
import { tick } from 'svelte';
import type { Action } from 'svelte/action';
type Parameters = {
height?: string;
value: string; // added to enable reactivity
};
export const autoGrowHeight: Action<HTMLTextAreaElement, Parameters> = (textarea, { height = 'auto' }) => {
const update = () => {
void tick().then(() => {
textarea.style.height = height;
textarea.style.height = `${textarea.scrollHeight}px`;
});
};
update();
return { update };
};
+118
View File
@@ -0,0 +1,118 @@
export interface DragAndDropOptions {
index: number;
onDragStart?: (index: number) => void;
onDragEnter?: (index: number) => void;
onDrop?: (e: DragEvent, index: number) => void;
onDragEnd?: () => void;
isDragging?: boolean;
isDragOver?: boolean;
}
export function dragAndDrop(node: HTMLElement, options: DragAndDropOptions) {
let { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
const isFormElement = (element: HTMLElement) => {
return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT';
};
const handleDragStart = (e: DragEvent) => {
// Prevent drag if it originated from an input, textarea, or select element
const target = e.target as HTMLElement;
if (isFormElement(target)) {
e.preventDefault();
return;
}
onDragStart?.(index);
};
const handleDragEnter = () => {
onDragEnter?.(index);
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: DragEvent) => {
onDrop?.(e, index);
};
const handleDragEnd = () => {
onDragEnd?.();
};
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
const handleFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
node.setAttribute('draggable', 'false');
}
};
const handleFocusOut = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
node.setAttribute('draggable', 'true');
}
};
node.setAttribute('draggable', 'true');
node.setAttribute('role', 'button');
node.setAttribute('tabindex', '0');
node.addEventListener('dragstart', handleDragStart);
node.addEventListener('dragenter', handleDragEnter);
node.addEventListener('dragover', handleDragOver);
node.addEventListener('drop', handleDrop);
node.addEventListener('dragend', handleDragEnd);
node.addEventListener('focusin', handleFocusIn);
node.addEventListener('focusout', handleFocusOut);
// Update classes based on drag state
const updateClasses = (dragging: boolean, dragOver: boolean) => {
// Remove all drag-related classes first
node.classList.remove('opacity-50', 'border-gray-400', 'dark:border-gray-500', 'border-solid');
// Add back only the active ones
if (dragging) {
node.classList.add('opacity-50');
}
if (dragOver) {
node.classList.add('border-gray-400', 'dark:border-gray-500', 'border-solid');
node.classList.remove('border-transparent');
} else {
node.classList.add('border-transparent');
}
};
updateClasses(isDragging || false, isDragOver || false);
return {
update(newOptions: DragAndDropOptions) {
index = newOptions.index;
onDragStart = newOptions.onDragStart;
onDragEnter = newOptions.onDragEnter;
onDrop = newOptions.onDrop;
onDragEnd = newOptions.onDragEnd;
const newIsDragging = newOptions.isDragging || false;
const newIsDragOver = newOptions.isDragOver || false;
if (newIsDragging !== isDragging || newIsDragOver !== isDragOver) {
isDragging = newIsDragging;
isDragOver = newIsDragOver;
updateClasses(isDragging, isDragOver);
}
},
destroy() {
node.removeEventListener('dragstart', handleDragStart);
node.removeEventListener('dragenter', handleDragEnter);
node.removeEventListener('dragover', handleDragOver);
node.removeEventListener('drop', handleDrop);
node.removeEventListener('dragend', handleDragEnd);
node.removeEventListener('focusin', handleFocusIn);
node.removeEventListener('focusout', handleFocusOut);
},
};
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,105 @@
import type { Attachment } from 'svelte/attachments';
export interface DragAndDropOptions {
index: number;
onDragStart?: (index: number) => void;
onDragEnter?: (index: number) => void;
onDrop?: (e: DragEvent, index: number) => void;
onDragEnd?: () => void;
isDragging?: boolean;
isDragOver?: boolean;
}
export function dragAndDrop(options: DragAndDropOptions): Attachment {
return (node: Element) => {
const element = node as HTMLElement;
const { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
const isFormElement = (el: HTMLElement) => {
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT';
};
const handleDragStart = (e: DragEvent) => {
// Prevent drag if it originated from an input, textarea, or select element
const target = e.target as HTMLElement;
if (isFormElement(target)) {
e.preventDefault();
return;
}
onDragStart?.(index);
};
const handleDragEnter = () => {
onDragEnter?.(index);
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: DragEvent) => {
onDrop?.(e, index);
};
const handleDragEnd = () => {
onDragEnd?.();
};
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
const handleFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
element.setAttribute('draggable', 'false');
}
};
const handleFocusOut = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
element.setAttribute('draggable', 'true');
}
};
// Update classes based on drag state
const updateClasses = (dragging: boolean, dragOver: boolean) => {
// Remove all drag-related classes first
element.classList.remove('opacity-50', 'border-light-500', 'border-solid');
// Add back only the active ones
if (dragging) {
element.classList.add('opacity-50');
}
if (dragOver) {
element.classList.add('border-light-500', 'border-solid');
element.classList.remove('border-transparent');
} else {
element.classList.add('border-transparent');
}
};
element.setAttribute('draggable', 'true');
element.setAttribute('role', 'button');
element.setAttribute('tabindex', '0');
element.addEventListener('dragstart', handleDragStart);
element.addEventListener('dragenter', handleDragEnter);
element.addEventListener('dragover', handleDragOver);
element.addEventListener('drop', handleDrop);
element.addEventListener('dragend', handleDragEnd);
element.addEventListener('focusin', handleFocusIn);
element.addEventListener('focusout', handleFocusOut);
updateClasses(isDragging || false, isDragOver || false);
return () => {
element.removeEventListener('dragstart', handleDragStart);
element.removeEventListener('dragenter', handleDragEnter);
element.removeEventListener('dragover', handleDragOver);
element.removeEventListener('drop', handleDrop);
element.removeEventListener('dragend', handleDragEnd);
element.removeEventListener('focusin', handleFocusIn);
element.removeEventListener('focusout', handleFocusOut);
};
};
}
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import { Card, CardBody, CardHeader, CardTitle, Icon, type ActionItem, type IconLike } from '@immich/ui';
import type { Snippet } from 'svelte';
type Props = {
icon: IconLike;
title: string;
headerAction?: ActionItem;
children?: Snippet;
};
const { icon, title, headerAction, children }: Props = $props();
</script>
<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} size="1.5rem" />
<CardTitle>{title}</CardTitle>
</div>
{#if headerAction}
<HeaderActionButton action={headerAction} />
{/if}
</div>
</CardHeader>
<CardBody>
<div class="px-4 pb-7">
{@render children?.()}
</div>
</CardBody>
</Card>
@@ -9,7 +9,7 @@
<div class="h-full flex flex-col justify-between gap-2">
<div class="flex flex-col pt-8 pe-4 gap-1">
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARIES} icon={mdiBookshelf} />
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<NavbarItem title={$t('admin.maintenance_settings')} href={AppRoute.ADMIN_MAINTENANCE_SETTINGS} icon={mdiWrench} />
@@ -1,7 +1,7 @@
<script lang="ts">
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { locale } from '$lib/stores/preferences.store';
import { minBy } from 'lodash-es';
import { minBy, uniqBy } from 'lodash-es';
import { DateTime, Duration } from 'luxon';
import { t } from 'svelte-i18n';
@@ -68,9 +68,8 @@
<SettingSelect
bind:value={expirationOption}
{onSelect}
options={[...new Set([...expiredDateOptions, getExpirationOption(createdAt, expiresAt)])]}
options={uniqBy([...expiredDateOptions, getExpirationOption(createdAt, expiresAt)], 'value')}
label={$t('expire_after')}
disabled={expiresAt !== null && DateTime.fromISO(expiresAt) < DateTime.now()}
number={true}
/>
</div>
+4 -3
View File
@@ -1,14 +1,15 @@
<script lang="ts">
import { IconButton, type ActionItem } from '@immich/ui';
import { IconButton, type ActionItem, type Size } from '@immich/ui';
type Props = {
action: ActionItem;
size?: Size;
};
const { action }: Props = $props();
const { action, size }: Props = $props();
const { title, icon, onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
<IconButton shape="round" color="primary" {icon} aria-label={title} onclick={() => onAction(action)} />
<IconButton {size} shape="round" color="primary" {icon} aria-label={title} onclick={() => onAction(action)} />
{/if}
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts" generics="T extends Record<string, unknown>">
import { TooltipProvider } from '@immich/ui';
import type { Component } from 'svelte';
type Props = {
component: Component<T>;
componentProps: T;
};
const { component: Test, componentProps }: Props = $props();
</script>
<TooltipProvider>
<Test {...componentProps} />
</TooltipProvider>
@@ -60,6 +60,9 @@
assetId: 'a8312960-e277-447d-b4ea-56717ccba856',
assetIdShort: '56717ccba856',
album: $t('album_name'),
make: 'FUJIFILM',
model: 'X-T50',
lensModel: 'XF27mm F2.8 R WR',
};
const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString());
@@ -24,10 +24,8 @@
</ul>
</div>
<div>
<p class="uppercase font-medium text-primary">{$t('other')}</p>
<p class="uppercase font-medium text-primary">{$t('album')}</p>
<ul>
<li>{`{{assetId}}`} - Asset ID</li>
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
<li>{`{{album}}`} - Album Name</li>
<li>
{`{{album-startDate-x}}`} - Album Start Date and Time (e.g. album-startDate-yy).
@@ -39,5 +37,20 @@
</li>
</ul>
</div>
<div>
<p class="uppercase font-medium text-primary">{$t('camera')}</p>
<ul>
<li>{`{{make}}`} - FUJIFILM</li>
<li>{`{{model}}`} - X-T50</li>
<li>{`{{lensModel}}`} - XF27mm F2.8 R WR</li>
</ul>
</div>
<div>
<p class="uppercase font-medium text-primary">{$t('other')}</p>
<ul>
<li>{`{{assetId}}`} - Asset ID</li>
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
</ul>
</div>
</div>
</div>
@@ -1,4 +1,5 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { renderWithTooltips } from '$tests/helpers';
import { albumFactory } from '@test-data/factories/album-factory';
import '@testing-library/jest-dom';
import { render, waitFor, type RenderResult } from '@testing-library/svelte';
@@ -88,7 +89,7 @@ describe('AlbumCard component', () => {
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
beforeEach(async () => {
sut = render(AlbumCard, { album, onShowContextMenu });
sut = renderWithTooltips(AlbumCard, { album, onShowContextMenu });
const albumImgElement = sut.getByTestId('album-image');
await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
@@ -65,7 +65,6 @@
<div class="grid grid-auto-fill-56 gap-y-4" transition:slide={{ duration: 300 }}>
{#each albums as album, index (album.id)}
<a
data-sveltekit-preload-data="hover"
href={resolve(`${AppRoute.ALBUMS}/${album.id}`)}
animate:flip={{ duration: 400 }}
oncontextmenu={(event) => oncontextmenu(event, album)}
@@ -1,8 +1,10 @@
<script lang="ts">
import { updateAlbumInfo } from '@immich/sdk';
import { shortcut } from '$lib/actions/shortcut';
import { handleError } from '$lib/utils/handle-error';
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
import { updateAlbumInfo } from '@immich/sdk';
import { Textarea } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fromAction } from 'svelte/attachments';
interface Props {
id: string;
@@ -12,27 +14,34 @@
let { id, description = $bindable(), isOwned }: Props = $props();
const handleUpdateDescription = async (newDescription: string) => {
const handleFocusOut = async () => {
try {
await updateAlbumInfo({
id,
updateAlbumDto: {
description: newDescription,
description,
},
});
} catch (error) {
handleError(error, $t('errors.unable_to_save_album'));
}
description = newDescription;
};
</script>
{#if isOwned}
<AutogrowTextarea
content={description}
class="w-full mt-2 text-black dark:text-white border-b-2 border-transparent border-gray-500 bg-transparent text-base outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:focus:border-immich-dark-primary hover:border-gray-400"
onContentUpdate={handleUpdateDescription}
<Textarea
bind:value={description}
class="outline-none border-b border-gray-500 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
rows={1}
grow
shape="rectangle"
onfocusout={handleFocusOut}
placeholder={$t('add_a_description')}
data-testid="autogrow-textarea"
{@attach fromAction(shortcut, () => ({
shortcut: { key: 'Enter', ctrl: true },
onShortcut: (e) => e.currentTarget.blur(),
}))}
/>
{:else if description}
<p class="break-words whitespace-pre-line w-full text-black dark:text-white text-base">
@@ -1,13 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
import ToastAction from '$lib/components/ToastAction.svelte';
import { AppRoute } from '$lib/constants';
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
@@ -17,8 +13,8 @@
AlbumGroupBy,
AlbumSortBy,
AlbumViewMode,
SortOrder,
locale,
SortOrder,
type AlbumViewSettings,
} from '$lib/stores/preferences.store';
import { user } from '$lib/stores/user.store';
@@ -27,8 +23,13 @@
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import { handleError } from '$lib/utils/handle-error';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { addUsersToAlbum, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import {
addUsersToAlbum,
type AlbumResponseDto,
type AlbumUserAddDto,
type SharedLinkResponseDto,
} from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
import { groupBy } from 'lodash-es';
import { onMount, type Snippet } from 'svelte';
@@ -199,10 +200,7 @@
switch (action) {
case 'edit': {
const editedAlbum = await modalManager.show(AlbumEditModal, { album: selectedAlbum });
if (editedAlbum) {
successEditAlbumInfo(editedAlbum);
}
await modalManager.show(AlbumEditModal, { album: selectedAlbum });
break;
}
@@ -215,12 +213,7 @@
}
case 'sharedLink': {
const success = await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id });
if (success) {
selectedAlbum.shared = true;
selectedAlbum.hasSharedLink = true;
updateAlbumInfo(selectedAlbum);
}
await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id });
break;
}
}
@@ -244,39 +237,18 @@
await Promise.allSettled(albumsToRemove.map((album) => handleDeleteAlbum(album, { prompt: false, notify: false })));
};
const updateAlbumInfo = (album: AlbumResponseDto) => {
ownedAlbums[ownedAlbums.findIndex(({ id }) => id === album.id)] = album;
sharedAlbums[sharedAlbums.findIndex(({ id }) => id === album.id)] = album;
};
const updateRecentAlbumInfo = (album: AlbumResponseDto) => {
for (const cachedAlbum of userInteraction.recentAlbums || []) {
if (cachedAlbum.id === album.id) {
Object.assign(cachedAlbum, { ...cachedAlbum, ...album });
break;
}
const findAndUpdate = (albums: AlbumResponseDto[], album: AlbumResponseDto) => {
const target = albums.find(({ id }) => id === album.id);
if (target) {
Object.assign(target, album);
}
return albums;
};
const successEditAlbumInfo = (album: AlbumResponseDto) => {
toastManager.custom({
component: ToastAction,
props: {
color: 'primary',
title: $t('success'),
description: $t('album_info_updated'),
button: {
text: $t('view_album'),
color: 'primary',
onClick() {
return goto(resolve(`${AppRoute.ALBUMS}/${album.id}`));
},
},
},
});
updateAlbumInfo(album);
updateRecentAlbumInfo(album);
const onUpdate = (album: AlbumResponseDto) => {
ownedAlbums = findAndUpdate(ownedAlbums, album);
sharedAlbums = findAndUpdate(sharedAlbums, album);
};
const handleAddUsers = async (album: AlbumResponseDto, albumUsers: AlbumUserAddDto[]) => {
@@ -287,19 +259,30 @@
albumUsers,
},
});
updateAlbumInfo(updatedAlbum);
onUpdate(updatedAlbum);
} catch (error) {
handleError(error, $t('errors.unable_to_add_album_users'));
}
};
const onAlbumUpdate = (album: AlbumResponseDto) => {
onUpdate(album);
userInteraction.recentAlbums = findAndUpdate(userInteraction.recentAlbums || [], album);
};
const onAlbumDelete = (album: AlbumResponseDto) => {
ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id);
sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id);
};
const onSharedLinkCreate = (sharedLink: SharedLinkResponseDto) => {
if (sharedLink.album) {
onUpdate(sharedLink.album);
}
};
</script>
<OnEvents {onAlbumDelete} />
<OnEvents {onAlbumUpdate} {onAlbumDelete} {onSharedLinkCreate} />
{#if albums.length > 0}
{#if userSettings.view === AlbumViewMode.Cover}
@@ -20,6 +20,7 @@ type ActionMap = {
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
[AssetAction.RATING]: { asset: TimelineAsset; rating: number | null };
};
export type Action = {
@@ -1,7 +1,7 @@
import { renderWithTooltips } from '$tests/helpers';
import type { AssetResponseDto } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import '@testing-library/jest-dom';
import { render } from '@testing-library/svelte';
import DeleteAction from './delete-action.svelte';
let asset: AssetResponseDto;
@@ -13,8 +13,12 @@ describe('DeleteAction component', () => {
});
it('displays a button to move the asset to the trash bin', () => {
const { getByTitle, queryByTitle } = render(DeleteAction, { asset, onAction: vi.fn() });
expect(getByTitle('delete')).toBeInTheDocument();
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('delete')).toBeInTheDocument();
expect(queryByTitle('deletePermanently')).toBeNull();
});
});
@@ -25,8 +29,12 @@ describe('DeleteAction component', () => {
});
it('displays a button to permanently delete the asset', () => {
const { getByTitle, queryByTitle } = render(DeleteAction, { asset, onAction: vi.fn() });
expect(getByTitle('permanently_delete')).toBeInTheDocument();
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('permanently_delete')).toBeInTheDocument();
expect(queryByTitle('delete')).toBeNull();
});
});
@@ -5,6 +5,7 @@
import Portal from '$lib/elements/Portal.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { deleteAssets as deleteAssetsUtil, type OnUndoDelete } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
@@ -17,9 +18,10 @@
asset: AssetResponseDto;
onAction: OnAction;
preAction: PreAction;
onUndoDelete?: OnUndoDelete;
}
let { asset, onAction, preAction }: Props = $props();
let { asset, onAction, preAction, onUndoDelete = undefined }: Props = $props();
let showConfirmModal = $state(false);
@@ -38,14 +40,14 @@
};
const trashAsset = async () => {
try {
preAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } });
onAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
toastManager.success($t('moved_to_trash'));
} catch (error) {
handleError(error, $t('errors.unable_to_trash_asset'));
}
const timelineAsset = toTimelineAsset(asset);
preAction({ type: AssetAction.TRASH, asset: timelineAsset });
await deleteAssetsUtil(
false,
() => onAction({ type: AssetAction.TRASH, asset: timelineAsset }),
[timelineAsset],
onUndoDelete,
);
};
const deleteAsset = async () => {
@@ -0,0 +1,55 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
import { AssetAction } from '$lib/constants';
import { preferences } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
type Props = {
asset: AssetResponseDto;
onAction: OnAction;
};
let { asset, onAction }: Props = $props();
const rateAsset = async (rating: number | null) => {
try {
const updateAssetDto = rating === null ? {} : { rating };
await updateAsset({
id: asset.id,
updateAssetDto,
});
asset = {
...asset,
exifInfo: {
...asset.exifInfo,
rating,
},
};
onAction({
type: AssetAction.RATING,
asset: toTimelineAsset(asset),
rating,
});
} catch (error) {
handleError(error, $t('errors.unable_to_set_rating'));
}
};
</script>
<svelte:document
use:shortcuts={$preferences?.ratings.enabled
? [
{ shortcut: { key: '0' }, onShortcut: () => rateAsset(null) },
...[1, 2, 3, 4, 5].map((rating) => ({
shortcut: { key: String(rating) },
onShortcut: () => rateAsset(rating),
})),
]
: []}
/>
@@ -1,26 +0,0 @@
<script lang="ts">
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import type { AssetResponseDto } from '@immich/sdk';
import { IconButton, modalManager } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
}
let { asset }: Props = $props();
const handleClick = async () => {
await modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] });
};
</script>
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={mdiShareVariantOutline}
onclick={handleClick}
aria-label={$t('share')}
/>
@@ -1,23 +1,22 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { IconButton } from '@immich/ui';
import { mdiInformationOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onShowDetail: () => void;
}
let { onShowDetail }: Props = $props();
const onAction = () => {
assetViewerManager.toggleDetailPanel();
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onShowDetail }} />
<svelte:document use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onAction }} />
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={mdiInformationOutline}
onclick={onShowDetail}
onclick={onAction}
aria-label={$t('info')}
/>
@@ -1,4 +1,5 @@
<script lang="ts">
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { locale } from '$lib/stores/preferences.store';
import type { ActivityResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
@@ -9,11 +10,10 @@
numberOfComments: number | undefined;
numberOfLikes: number | undefined;
disabled: boolean;
onOpenActivityTab: () => void;
onFavorite: () => void;
}
let { isLiked, numberOfComments, numberOfLikes, disabled, onOpenActivityTab, onFavorite }: Props = $props();
let { isLiked, numberOfComments, numberOfLikes, disabled, onFavorite }: Props = $props();
</script>
<div class="w-full flex p-4 items-center justify-center rounded-full gap-5 bg-subtle border bg-opacity-60">
@@ -25,7 +25,7 @@
{/if}
</div>
</button>
<button type="button" onclick={onOpenActivityTab}>
<button type="button" onclick={() => assetViewerManager.toggleActivityPanel()}>
<div class="flex gap-2 items-center justify-center">
<Icon icon={mdiCommentOutline} class="scale-x-[-1]" size="24" />
{#if numberOfComments}
@@ -1,21 +1,22 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { autoGrowHeight } from '$lib/actions/autogrow';
import { shortcut } from '$lib/actions/shortcut';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetType } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { isTenMinutesApart } from '$lib/utils/timesince';
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, toastManager } from '@immich/ui';
import { Icon, IconButton, LoadingSpinner, Textarea, toastManager } from '@immich/ui';
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js';
import * as luxon from 'luxon';
import { t } from 'svelte-i18n';
import { fromAction } from 'svelte/attachments';
import UserAvatar from '../shared-components/user-avatar.svelte';
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
@@ -44,10 +45,9 @@
assetType?: AssetTypeEnum | undefined;
albumOwnerId: string;
disabled: boolean;
onClose: () => void;
}
let { user, assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled, onClose }: Props = $props();
let { user, assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled }: Props = $props();
let innerHeight: number = $state(0);
let activityHeight: number = $state(0);
@@ -117,7 +117,7 @@
shape="round"
variant="ghost"
color="secondary"
onclick={onClose}
onclick={() => assetViewerManager.closeActivityPanel()}
icon={mdiClose}
aria-label={$t('close')}
/>
@@ -243,37 +243,34 @@
<div>
<UserAvatar {user} size="md" noTitle />
</div>
<form class="flex w-full max-h-56 gap-1" {onsubmit}>
<div class="flex w-full items-center gap-4">
<textarea
{disabled}
bind:value={message}
use:autoGrowHeight={{ height: '5px', value: message }}
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
use:shortcut={{
shortcut: { key: 'Enter' },
onShortcut: () => handleSendComment(),
}}
class="h-4.5 {disabled
? 'cursor-not-allowed'
: ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
></textarea>
</div>
<form class="flex w-full items-center max-h-56 gap-1" {onsubmit}>
<Textarea
{disabled}
bind:value={message}
rows={1}
grow
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
{@attach fromAction(shortcut, () => ({
shortcut: { key: 'Enter' },
onShortcut: () => handleSendComment(),
}))}
class="{disabled
? 'cursor-not-allowed'
: ''} ring-0! w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200 dark:bg-gray-200"
/>
{#if isSendingMessage}
<div class="flex items-end place-items-center pb-2 ms-0">
<div class="flex place-items-center pb-2 ms-0">
<div class="flex w-full place-items-center">
<LoadingSpinner />
<LoadingSpinner size="large" />
</div>
</div>
{:else if message}
<div class="flex items-end w-fit ms-0">
<div class="flex items-center w-fit ms-0 light">
<IconButton
shape="round"
aria-label={$t('send_message')}
size="small"
variant="ghost"
icon={mdiSend}
class="dark:text-immich-dark-gray"
onclick={() => handleSendComment()}
/>
</div>
@@ -1,27 +1,26 @@
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import { preferencesFactory } from '@test-data/factories/preferences-factory';
import { userAdminFactory } from '@test-data/factories/user-factory';
import '@testing-library/jest-dom';
import { render } from '@testing-library/svelte';
import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
describe('AssetViewerNavBar component', () => {
const additionalProps = {
showCopyButton: false,
showZoomButton: false,
showDetailButton: false,
showDownloadButton: false,
showMotionPlayButton: false,
showShareButton: false,
preAction: () => {},
onZoomImage: () => {},
onCopyImage: () => {},
onAction: () => {},
onRunJob: () => {},
onPlaySlideshow: () => {},
onShowDetail: () => {},
onClose: () => {},
playOriginalVideo: false,
setPlayOriginalVideo: () => Promise.resolve(),
};
beforeAll(() => {
@@ -51,8 +50,8 @@ describe('AssetViewerNavBar component', () => {
preferencesStore.set(prefs);
const asset = assetFactory.build({ isTrashed: false });
const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps });
expect(getByTitle('go_back')).toBeInTheDocument();
const { getByLabelText } = renderWithTooltips(AssetViewerNavBar, { asset, ...additionalProps });
expect(getByLabelText('go_back')).toBeInTheDocument();
});
describe('if the current user owns the asset', () => {
@@ -65,8 +64,8 @@ describe('AssetViewerNavBar component', () => {
const prefs = preferencesFactory.build({ cast: { gCastEnabled: false } });
preferencesStore.set(prefs);
const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps });
expect(getByTitle('delete')).toBeInTheDocument();
const { getByLabelText } = renderWithTooltips(AssetViewerNavBar, { asset, ...additionalProps });
expect(getByLabelText('delete')).toBeInTheDocument();
});
});
});
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import CastButton from '$lib/cast/cast-button.svelte';
import ActionButton from '$lib/components/ActionButton.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
@@ -11,6 +12,7 @@
import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte';
import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte';
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
@@ -18,18 +20,19 @@
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleReplaceAsset } from '$lib/services/asset.service';
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
@@ -67,15 +70,14 @@
person?: PersonResponseDto | null;
stack?: StackResponseDto | null;
showCloseButton?: boolean;
showDetailButton: boolean;
showSlideshow?: boolean;
onZoomImage: () => void;
onCopyImage?: () => Promise<void>;
preAction: PreAction;
onAction: OnAction;
onUndoDelete?: OnUndoDelete;
onRunJob: (name: AssetJobName) => void;
onPlaySlideshow: () => void;
onShowDetail: () => void;
// export let showEditorHandler: () => void;
onClose: () => void;
motionPhoto?: Snippet;
@@ -89,15 +91,14 @@
person = null,
stack = null,
showCloseButton = true,
showDetailButton,
showSlideshow = false,
onZoomImage,
onCopyImage,
preAction,
onAction,
onUndoDelete = undefined,
onRunJob,
onPlaySlideshow,
onShowDetail,
onClose,
motionPhoto,
playOriginalVideo = false,
@@ -110,6 +111,8 @@
let isLocked = $derived(asset.visibility === AssetVisibility.Locked);
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
const { Share } = $derived(getAssetActions($t, asset));
// $: showEditorButton =
// isOwner &&
// asset.type === AssetTypeEnum.Image &&
@@ -132,15 +135,13 @@
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
<CastButton />
{#if !asset.isTrashed && $user && !isLocked}
<ShareAction {asset} />
{/if}
<ActionButton action={Share} />
{#if asset.isOffline}
<IconButton
shape="round"
color="danger"
icon={mdiAlertOutline}
onclick={onShowDetail}
onclick={() => assetViewerManager.toggleDetailPanel()}
aria-label={$t('asset_offline')}
/>
{/if}
@@ -173,16 +174,17 @@
<DownloadAction asset={toTimelineAsset(asset)} />
{/if}
{#if showDetailButton}
<ShowDetailAction {onShowDetail} />
{#if asset.hasMetadata}
<ShowDetailAction />
{/if}
{#if isOwner}
<FavoriteAction {asset} {onAction} />
<RatingAction {asset} {onAction} />
{/if}
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} />
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
{#if showSlideshow && !isLocked}
@@ -9,16 +9,18 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import { AppRoute, AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo, isShowDetail } from '$lib/stores/preferences.store';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -62,6 +64,7 @@
person?: PersonResponseDto | null;
preAction?: PreAction | undefined;
onAction?: OnAction | undefined;
onUndoDelete?: OnUndoDelete | undefined;
showCloseButton?: boolean;
onClose: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
@@ -80,6 +83,7 @@
person = null,
preAction = undefined,
onAction = undefined,
onUndoDelete = undefined,
showCloseButton,
onClose,
onNext,
@@ -102,11 +106,7 @@
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let shouldPlayMotionPhoto = $state(false);
let sharedLink = getSharedLink();
let enableDetailPanel = asset.hasMetadata;
let slideshowStateUnsubscribe: () => void;
let shuffleSlideshowUnsubscribe: () => void;
let previewStackedAsset: AssetResponseDto | undefined = $state();
let isShowActivity = $state(false);
let isShowEditor = $state(false);
let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = [];
@@ -160,39 +160,29 @@
unsubscribes.push(
websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow());
}
}),
slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
}
}),
);
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow());
}
});
shuffleSlideshowUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
}
});
if (!sharedLink) {
await handleGetAllAlbums();
}
});
onDestroy(() => {
if (slideshowStateUnsubscribe) {
slideshowStateUnsubscribe();
}
if (shuffleSlideshowUnsubscribe) {
shuffleSlideshowUnsubscribe();
}
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
@@ -212,18 +202,6 @@
}
};
const handleOpenActivity = () => {
if ($isShowDetail) {
$isShowDetail = false;
}
isShowActivity = !isShowActivity;
};
const toggleDetailPanel = () => {
isShowActivity = false;
$isShowDetail = !$isShowDetail;
};
const closeViewer = () => {
onClose(asset);
};
@@ -353,6 +331,16 @@
asset = { ...asset, people: assetInfo.people };
break;
}
case AssetAction.RATING: {
asset = {
...asset,
exifInfo: {
...asset.exifInfo,
rating: action.rating,
},
};
break;
}
case AssetAction.KEEP_THIS_DELETE_OTHERS:
case AssetAction.UNSTACK: {
closeViewer();
@@ -386,7 +374,7 @@
});
$effect(() => {
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
isShowActivity = false;
assetViewerManager.closeActivityPanel();
}
});
$effect(() => {
@@ -424,15 +412,14 @@
{person}
{stack}
{showCloseButton}
showDetailButton={enableDetailPanel}
showSlideshow={true}
onZoomImage={zoomToggle}
onCopyImage={copyImage}
preAction={handlePreAction}
onAction={handleAction}
{onUndoDelete}
onRunJob={handleRunJob}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onShowDetail={toggleDetailPanel}
onClose={closeViewer}
{playOriginalVideo}
{setPlayOriginalVideo}
@@ -451,6 +438,7 @@
<div class="absolute w-full flex">
<SlideshowBar
{isFullScreen}
assetType={previewStackedAsset?.type ?? asset.type}
onSetToFullScreen={() => assetViewerHtmlElement?.requestFullscreen?.()}
onPrevious={() => navigateAsset('previous')}
onNext={() => navigateAsset('next')}
@@ -550,7 +538,6 @@
numberOfComments={activityManager.commentCount}
numberOfLikes={activityManager.likeCount}
onFavorite={handleFavorite}
onOpenActivityTab={handleOpenActivity}
/>
</div>
{/if}
@@ -570,14 +557,14 @@
</div>
{/if}
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !isShowEditor}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="row-start-1 row-span-4 w-[360px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
translate="yes"
>
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} onClose={() => ($isShowDetail = false)} />
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
</div>
{/if}
@@ -628,7 +615,7 @@
</div>
{/if}
{#if isShared && album && isShowActivity && $user}
{#if isShared && album && assetViewerManager.isShowActivityPanel && $user}
<div
transition:fly={{ duration: 150 }}
id="activity-panel"
@@ -642,7 +629,6 @@
albumOwnerId={album.ownerId}
albumId={album.id}
assetId={asset.id}
onClose={() => (isShowActivity = false)}
/>
</div>
{/if}
@@ -1,9 +1,10 @@
<script lang="ts">
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
import { shortcut } from '$lib/actions/shortcut';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { Textarea, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fromAction } from 'svelte/attachments';
interface Props {
asset: AssetResponseDto;
@@ -12,15 +13,17 @@
let { asset, isOwner }: Props = $props();
let description = $derived(asset.exifInfo?.description || '');
let currentDescription = asset.exifInfo?.description ?? '';
let draftDescription = $state(currentDescription);
const handleFocusOut = async (newDescription: string) => {
const handleFocusOut = async () => {
if (draftDescription === currentDescription) {
return;
}
try {
await updateAsset({ id: asset.id, updateAssetDto: { description: newDescription } });
asset.exifInfo = { ...asset.exifInfo, description: newDescription };
await updateAsset({ id: asset.id, updateAssetDto: { description: draftDescription } });
toastManager.success($t('asset_description_updated'));
currentDescription = draftDescription;
} catch (error) {
handleError(error, $t('cannot_update_the_description'));
}
@@ -29,15 +32,23 @@
{#if isOwner}
<section class="px-4 mt-10">
<AutogrowTextarea
content={description}
class="max-h-125 w-full border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary immich-scrollbar"
onContentUpdate={handleFocusOut}
<Textarea
bind:value={draftDescription}
class="max-h-40 outline-none border-b border-gray-500 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
rows={1}
grow
shape="rectangle"
onfocusout={handleFocusOut}
placeholder={$t('add_a_description')}
data-testid="autogrow-textarea"
{@attach fromAction(shortcut, () => ({
shortcut: { key: 'Enter', ctrl: true },
onShortcut: (e) => e.currentTarget.blur(),
}))}
/>
</section>
{:else if description}
{:else if draftDescription}
<section class="px-4 mt-6">
<p class="wrap-break-word whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
<p class="wrap-break-word whitespace-pre-line w-full text-black dark:text-white text-base">{draftDescription}</p>
</section>
{/if}
@@ -6,6 +6,7 @@
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
@@ -45,10 +46,9 @@
asset: AssetResponseDto;
albums?: AlbumResponseDto[];
currentAlbum?: AlbumResponseDto | null;
onClose: () => void;
}
let { asset, albums = [], currentAlbum = null, onClose }: Props = $props();
let { asset, albums = [], currentAlbum = null }: Props = $props();
let showAssetPath = $state(false);
let showEditFaces = $state(false);
@@ -127,7 +127,7 @@
<IconButton
icon={mdiClose}
aria-label={$t('close')}
onclick={onClose}
onclick={() => assetViewerManager.closeDetailPanel()}
shape="round"
color="secondary"
variant="ghost"
@@ -1,9 +1,10 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import ProgressBar from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
import { ProgressBarStatus } from '$lib/constants';
import SlideshowSettingsModal from '$lib/modals/SlideshowSettingsModal.svelte';
import { SlideshowNavigation, slideshowStore } from '$lib/stores/slideshow.store';
import { AssetTypeEnum } from '@immich/sdk';
import { IconButton, modalManager } from '@immich/ui';
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiFullscreen, mdiPause, mdiPlay } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
@@ -13,6 +14,7 @@
interface Props {
isFullScreen: boolean;
assetType: AssetTypeEnum;
onNext?: () => void;
onPrevious?: () => void;
onClose?: () => void;
@@ -21,6 +23,7 @@
let {
isFullScreen,
assetType,
onNext = () => {},
onPrevious = () => {},
onClose = () => {},
@@ -35,6 +38,7 @@
let showControls = $state(true);
let timer: NodeJS.Timeout;
let isOverControls = $state(false);
const isVideoSlide = $derived(assetType === AssetTypeEnum.Video);
let unsubscribeRestart: () => void;
let unsubscribeStop: () => void;
@@ -132,27 +136,34 @@
{ onswipedown: showControlBar },
true,
);
const shortcutBindings = $derived.by((): ShortcutOptions[] => {
const bindings: ShortcutOptions[] = [
{ shortcut: { key: 'Escape' }, onShortcut: onClose },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPrevious },
{ shortcut: { key: 'ArrowRight' }, onShortcut: onNext },
];
// For videos, allow the native HTML5 element to handle space for play/pause
if (!isVideoSlide) {
bindings.push({
shortcut: { key: ' ' },
onShortcut: () => {
if (progressBarStatus === ProgressBarStatus.Paused) {
progressBar?.play();
} else {
progressBar?.pause();
}
},
preventDefault: true,
});
}
return bindings;
});
</script>
<svelte:document
onmousemove={showControlBar}
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onClose },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPrevious },
{ shortcut: { key: 'ArrowRight' }, onShortcut: onNext },
{
shortcut: { key: ' ' },
onShortcut: () => {
if (progressBarStatus === ProgressBarStatus.Paused) {
progressBar?.play();
} else {
progressBar?.pause();
}
},
preventDefault: true,
},
]}
/>
<svelte:document onmousemove={showControlBar} use:shortcuts={shortcutBindings} />
{/* @ts-expect-error https://github.com/Rezi/svelte-gestures/issues/38#issuecomment-3315953573 */ null}
<svelte:body {@attach swipe} {onswipe} {onswipedown} />
@@ -174,14 +185,16 @@
aria-label={$t('exit_slideshow')}
/>
<IconButton
variant="ghost"
shape="round"
color="secondary"
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
onclick={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar?.play() : progressBar?.pause())}
aria-label={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')}
/>
{#if !isVideoSlide}
<IconButton
variant="ghost"
shape="round"
color="secondary"
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
onclick={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar?.play() : progressBar?.pause())}
aria-label={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')}
/>
{/if}
<IconButton
variant="ghost"
shape="round"
@@ -219,11 +232,13 @@
</div>
{/if}
<ProgressBar
autoplay={$slideshowAutoplay}
hidden={!$showProgressBar}
duration={$slideshowDelay}
bind:this={progressBar}
bind:status={progressBarStatus}
onDone={handleDone}
/>
{#if !isVideoSlide}
<ProgressBar
autoplay={$slideshowAutoplay}
hidden={!$showProgressBar}
duration={$slideshowDelay}
bind:this={progressBar}
bind:status={progressBarStatus}
onDone={handleDone}
/>
{/if}
@@ -1,119 +0,0 @@
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte';
import type { PersonResponseDto } from '@immich/sdk';
import { personFactory } from '@test-data/factories/person-factory';
import { render } from '@testing-library/svelte';
import { tick } from 'svelte';
describe('ManagePeopleVisibility Component', () => {
let personVisible: PersonResponseDto;
let personHidden: PersonResponseDto;
let personWithoutName: PersonResponseDto;
beforeAll(() => {
// Prevents errors from `img.decode()` in ImageThumbnail
Object.defineProperty(HTMLImageElement.prototype, 'decode', {
value: vi.fn(),
});
});
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
personVisible = personFactory.build({ isHidden: false });
personHidden = personFactory.build({ isHidden: true });
personWithoutName = personFactory.build({ isHidden: false, name: undefined });
sdkMock.updatePeople.mockResolvedValue([]);
});
afterEach(() => {
vi.resetAllMocks();
});
it('does not update people when no changes are made', () => {
const { getByText } = render(ManagePeopleVisibility, {
props: {
people: [personVisible, personHidden, personWithoutName],
totalPeopleCount: 3,
onClose: vi.fn(),
loadNextPage: vi.fn(),
},
});
const saveButton = getByText('done');
saveButton.click();
expect(sdkMock.updatePeople).not.toHaveBeenCalled();
});
// svelte animations require a real browser
it.skip('hides unnamed people on first button press', () => {
const { getByText, getByTitle } = render(ManagePeopleVisibility, {
props: {
people: [personVisible, personHidden, personWithoutName],
totalPeopleCount: 3,
onClose: vi.fn(),
loadNextPage: vi.fn(),
},
});
getByTitle('hide_unnamed_people').click();
getByText('done').click();
expect(sdkMock.updatePeople).toHaveBeenCalledWith({
peopleUpdateDto: {
people: [{ id: personWithoutName.id, isHidden: true }],
},
});
});
// svelte animations require a real browser
it.skip('hides all people on second button press', async () => {
const { getByText, getByTitle } = render(ManagePeopleVisibility, {
props: {
people: [personVisible, personHidden, personWithoutName],
totalPeopleCount: 3,
onClose: vi.fn(),
loadNextPage: vi.fn(),
},
});
getByTitle('hide_unnamed_people').click();
await tick();
getByTitle('hide_all_people').click();
getByText('done').click();
expect(sdkMock.updatePeople).toHaveBeenCalledWith({
peopleUpdateDto: {
people: expect.arrayContaining([
{ id: personVisible.id, isHidden: true },
{ id: personWithoutName.id, isHidden: true },
]),
},
});
});
// svelte animations require a real browser
it.skip('shows all people on third button press', async () => {
const { getByText, getByTitle } = render(ManagePeopleVisibility, {
props: {
people: [personVisible, personHidden, personWithoutName],
totalPeopleCount: 3,
onClose: vi.fn(),
loadNextPage: vi.fn(),
},
});
getByTitle('hide_unnamed_people').click();
await tick();
getByTitle('hide_all_people').click();
await tick();
getByTitle('show_all_people').click();
getByText('done').click();
expect(sdkMock.updatePeople).toHaveBeenCalledWith({
peopleUpdateDto: {
people: [{ id: personHidden.id, isHidden: false }],
},
});
});
});
@@ -1,7 +1,7 @@
<script lang="ts">
import AdminSidebar from '$lib/components/AdminSidebar.svelte';
import PageContent from '$lib/components/layouts/PageContent.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import AdminSidebar from '$lib/sidebars/AdminSidebar.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import type { HeaderButtonActionItem } from '$lib/types';
import {
@@ -28,6 +28,12 @@
};
let { breadcrumbs, actions = [], children }: Props = $props();
const enabledActions = $derived(
actions
.filter((action): action is HeaderButtonActionItem => !isMenuItemType(action))
.filter((action) => action.$if?.() ?? true),
);
</script>
<AppShell>
@@ -42,22 +48,20 @@
<div class="flex h-16 w-full justify-between items-center border-b py-2 px-4 md:px-2">
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
{#if actions.length > 0}
{#if enabledActions.length > 0}
<div class="hidden md:block">
<HStack gap={0}>
{#each actions as action, i (i)}
{#if !isMenuItemType(action) && (action.$if?.() ?? true)}
<Button
variant="ghost"
size="small"
color={action.color ?? 'secondary'}
leadingIcon={action.icon}
onclick={() => action.onAction(action)}
title={action.data?.title}
>
{action.title}
</Button>
{/if}
{#each enabledActions as action, i (i)}
<Button
variant="ghost"
size="small"
color={action.color ?? 'secondary'}
leadingIcon={action.icon}
onclick={() => action.onAction(action)}
title={action.data?.title}
>
{action.title}
</Button>
{/each}
</HStack>
</div>
@@ -6,8 +6,11 @@
import { useActions, type ActionArray } from '$lib/actions/use-actions';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
import type { HeaderButtonActionItem } from '$lib/types';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
hideNavbar?: boolean;
@@ -16,6 +19,7 @@
description?: string | undefined;
scrollbar?: boolean;
use?: ActionArray;
actions?: Array<HeaderButtonActionItem | MenuItemType>;
header?: Snippet;
sidebar?: Snippet;
buttons?: Snippet;
@@ -29,12 +33,19 @@
description = undefined,
scrollbar = true,
use = [],
actions = [],
header,
sidebar,
buttons,
children,
}: Props = $props();
const enabledActions = $derived(
actions
.filter((action): action is HeaderButtonActionItem => !isMenuItemType(action))
.filter((action) => action.$if?.() ?? true),
);
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
</script>
@@ -74,7 +85,29 @@
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>
{/if}
</div>
{@render buttons?.()}
{#if enabledActions.length > 0}
<div class="hidden md:block">
<HStack gap={0}>
{#each enabledActions as action, i (i)}
<Button
variant="ghost"
size="small"
color={action.color ?? 'secondary'}
leadingIcon={action.icon}
onclick={() => action.onAction(action)}
title={action.data?.title}
>
{action.title}
</Button>
{/each}
</HStack>
</div>
<ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" />
{/if}
</div>
{/if}
</main>
@@ -1,7 +1,7 @@
<script lang="ts">
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { OnboardingRole } from '$lib/models/onboarding-role';
import { user } from '$lib/stores/user.store';
import { OnboardingRole } from '$lib/types';
import { Logo } from '@immich/ui';
import { t } from 'svelte-i18n';
@@ -3,7 +3,7 @@
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString, getBytesWithUnit } from '$lib/utils/byte-units';
import type { ServerStatsResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { Code, Heading, Icon, Text } from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -25,52 +25,53 @@
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0));
</script>
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-5 my-4">
<div>
<p class="text-sm dark:text-immich-dark-fg uppercase">{$t('total_usage')}</p>
<Heading size="tiny" class="mb-2">{$t('total_usage')}</Heading>
<div class="mt-5 hidden justify-between lg:flex gap-4">
<div class="hidden justify-between lg:flex gap-4">
<StatsCard icon={mdiCameraIris} title={$t('photos')} value={stats.photos} />
<StatsCard icon={mdiPlayCircle} title={$t('videos')} value={stats.videos} />
<StatsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
</div>
<div class="mt-5 flex lg:hidden">
<div class="flex flex-col justify-between rounded-3xl bg-subtle p-5 dark:bg-immich-dark-gray">
<div class="flex flex-wrap gap-x-12">
<div class="flex place-items-center gap-4 text-primary">
<div class="flex flex-1 place-items-center gap-4 text-primary">
<Icon icon={mdiCameraIris} size="25" />
<p class="uppercase">{$t('photos')}</p>
<Text fontWeight="bold" class="uppercase">{$t('photos')}</Text>
</div>
<div class="relative text-center font-mono text-2xl font-semibold">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.photos)}</span><span class="text-primary"
<span class="text-gray-400 dark:text-gray-600">{zeros(stats.photos)}</span><span class="text-primary"
>{stats.photos}</span
>
</div>
</div>
<div class="flex flex-wrap gap-x-12">
<div class="flex place-items-center gap-4 text-primary">
<div class="flex flex-1 place-items-center gap-4 text-primary">
<Icon icon={mdiPlayCircle} size="25" />
<p class="uppercase">{$t('videos')}</p>
<Text fontWeight="bold" class="uppercase">{$t('videos')}</Text>
</div>
<div class="relative text-center font-mono text-2xl font-semibold">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.videos)}</span><span class="text-primary"
<span class="text-gray-400 dark:text-gray-600">{zeros(stats.videos)}</span><span class="text-primary"
>{stats.videos}</span
>
</div>
</div>
<div class="flex flex-wrap gap-x-7">
<div class="flex place-items-center gap-4 text-primary">
<div class="flex flex-wrap gap-x-5">
<div class="flex flex-1 flex-nowrap place-items-center gap-4 text-primary">
<Icon icon={mdiChartPie} size="25" />
<p class="uppercase">{$t('storage')}</p>
<Text fontWeight="bold" class="uppercase">{$t('storage')}</Text>
</div>
<div class="relative flex text-center font-mono text-2xl font-semibold">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(statsUsage)}</span><span class="text-primary"
<span class="text-gray-400 dark:text-gray-600">{zeros(statsUsage)}</span><span class="text-primary"
>{statsUsage}</span
>
<span class="my-auto ms-2 text-center text-base font-light text-gray-400">{statsUsageUnit}</span>
<Code color="muted" class="font-light">{statsUsageUnit}</Code>
</div>
</div>
</div>
@@ -78,7 +79,7 @@
</div>
<div>
<p class="text-sm dark:text-immich-dark-fg uppercase">{$t('user_usage_detail')}</p>
<Heading size="tiny" class="mb-2">{$t('user_usage_detail')}</Heading>
<table class="mt-5 w-full 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"
@@ -1,30 +0,0 @@
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
import Combobox from '$lib/components/shared-components/combobox.svelte';
import { render, screen } from '@testing-library/svelte';
describe('Combobox component', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.stubGlobal('visualViewport', getVisualViewportMock());
});
it('shows selected option', () => {
render(Combobox, {
label: 'test',
selectedOption: { label: 'option-1', value: 'option-1' },
});
expect(screen.getByRole('combobox')).toHaveValue('option-1');
});
it('clears the selected option when set to undefined', async () => {
const { rerender } = render(Combobox, {
label: 'test',
selectedOption: { label: 'option-1', value: 'option-1' },
});
await rerender({ selectedOption: undefined });
expect(screen.getByRole('combobox')).toHaveValue('');
});
});
@@ -1,60 +0,0 @@
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
import { render, screen, waitFor } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
describe('AutogrowTextarea component', () => {
const getTextarea = () => screen.getByTestId('autogrow-textarea') as HTMLTextAreaElement;
it('should render correctly', () => {
render(AutogrowTextarea);
const textarea = getTextarea();
expect(textarea).toBeInTheDocument();
});
it('should show the content passed to the component', () => {
render(AutogrowTextarea, { content: 'stuff' });
const textarea = getTextarea();
expect(textarea.value).toBe('stuff');
});
it('should show the placeholder passed to the component', () => {
render(AutogrowTextarea, { placeholder: 'asdf' });
const textarea = getTextarea();
expect(textarea.placeholder).toBe('asdf');
});
it('should execute the passed callback on blur', async () => {
const user = userEvent.setup();
const update = vi.fn();
render(AutogrowTextarea, { content: 'existing', onContentUpdate: update });
const textarea = getTextarea();
await user.click(textarea);
await user.keyboard('extra');
textarea.blur();
await waitFor(() => expect(update).toHaveBeenCalledWith('existingextra'));
});
it('should execute the passed callback when pressing ctrl+enter in the textarea', async () => {
const user = userEvent.setup();
const update = vi.fn();
render(AutogrowTextarea, { onContentUpdate: update });
const textarea = getTextarea();
await user.click(textarea);
const string = 'content';
await user.keyboard(string);
await user.keyboard('{Control>}{Enter}{/Control}');
await waitFor(() => expect(update).toHaveBeenCalledWith(string));
});
it('should not execute the passed callback if the text has not changed', async () => {
const user = userEvent.setup();
const update = vi.fn();
render(AutogrowTextarea, { content: 'initial', onContentUpdate: update });
const textarea = getTextarea();
await user.click(textarea);
await user.clear(textarea);
await user.keyboard('initial');
await user.keyboard('{Control>}{Enter}{/Control}');
await waitFor(() => expect(update).not.toHaveBeenCalled());
});
});
@@ -1,35 +0,0 @@
<script lang="ts">
import { autoGrowHeight } from '$lib/actions/autogrow';
import { shortcut } from '$lib/actions/shortcut';
interface Props {
content?: string;
class?: string;
onContentUpdate?: (newContent: string) => void;
placeholder?: string;
}
let { content = '', class: className = '', onContentUpdate = () => null, placeholder = '' }: Props = $props();
let newContent = $derived(content);
const updateContent = () => {
if (content === newContent) {
return;
}
onContentUpdate(newContent);
};
</script>
<textarea
bind:value={newContent}
class="resize-none {className}"
onfocusout={updateContent}
{placeholder}
use:shortcut={{
shortcut: { key: 'Enter', ctrl: true },
onShortcut: (e) => e.currentTarget.blur(),
}}
use:autoGrowHeight={{ value: newContent }}
data-testid="autogrow-textarea">{content}</textarea
>
@@ -86,6 +86,27 @@
return;
}
// Try to parse coordinate pair from search input in the format `LATITUDE, LONGITUDE` as floats
const coordinateParts = searchWord.split(',').map((part) => part.trim());
if (coordinateParts.length === 2) {
const coordinateLat = Number.parseFloat(coordinateParts[0]);
const coordinateLng = Number.parseFloat(coordinateParts[1]);
if (
!Number.isNaN(coordinateLat) &&
!Number.isNaN(coordinateLng) &&
coordinateLat >= -90 &&
coordinateLat <= 90 &&
coordinateLng >= -180 &&
coordinateLng <= 180
) {
places = [];
showLoadingSpinner = false;
handleUseSuggested(coordinateLat, coordinateLng);
return;
}
}
searchPlaces({ name: searchWord })
.then((searchResult) => {
// skip result when a newer search is happening
@@ -78,7 +78,7 @@
</Button>
{#if $user.isAdmin}
<Button
href={AppRoute.ADMIN_USERS}
href={AppRoute.ADMIN_SETTINGS}
onclick={onClose}
shape="round"
variant="ghost"
@@ -111,6 +111,7 @@
if (close) {
await close();
close = undefined;
searchStore.isSearchEnabled = false;
return;
}
@@ -120,6 +121,7 @@
const searchResult = await result.onClose;
close = undefined;
searchStore.isSearchEnabled = false;
// Refresh search type after modal closes
getSearchType();
@@ -308,18 +310,6 @@
/>
</div>
<div class="absolute inset-y-0 {showClearIcon ? 'end-14' : 'end-2'} flex items-center ps-6 transition-all">
<IconButton
aria-label={$t('show_search_options')}
shape="round"
icon={mdiTune}
onclick={onFilterClick}
size="medium"
color="secondary"
variant="ghost"
/>
</div>
{#if searchStore.isSearchEnabled}
<div
id={searchTypeId}
@@ -327,7 +317,7 @@
class:max-md:hidden={value}
class:end-28={value.length > 0}
>
<div class="relative">
<div class="relative" use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}>
<Button
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
onclick={toggleSearchTypeDropdown}
@@ -340,11 +330,11 @@
{#if showSearchTypeDropdown}
<div
class="absolute top-full right-0 mt-1 bg-white dark:bg-immich-dark-gray border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-32 z-9999"
use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}
>
{#each searchTypes as searchType (searchType.value)}
<button
type="button"
tabindex="0"
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors
{currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}"
onclick={() => selectSearchType(searchType.value)}
@@ -384,4 +374,16 @@
/>
</div>
</form>
<div class="absolute inset-y-0 {showClearIcon ? 'end-14' : 'end-2'} flex items-center ps-6 transition-all">
<IconButton
aria-label={$t('show_search_options')}
shape="round"
icon={mdiTune}
onclick={onFilterClick}
size="medium"
color="secondary"
variant="ghost"
/>
</div>
</div>
@@ -1,9 +1,9 @@
<script lang="ts">
import { AppRoute } from '$lib/constants';
import type { UploadAsset } from '$lib/models/upload-asset';
import { UploadState } from '$lib/models/upload-asset';
import { locale } from '$lib/stores/preferences.store';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { UploadAsset } from '$lib/types';
import { UploadState } from '$lib/types';
import { getByteUnitString } from '$lib/utils/byte-units';
import { fileUploadHandler } from '$lib/utils/file-uploader';
import { Icon } from '@immich/ui';
@@ -13,6 +13,7 @@
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
@@ -25,7 +26,7 @@
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { onDestroy, onMount, type Snippet } from 'svelte';
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
interface Props {
@@ -226,6 +227,9 @@
if (!scrolled) {
// if the asset is not found, scroll to the top
timelineManager.scrollTo(0);
} else if (scrollTarget) {
await tick();
focusAsset(scrollTarget);
}
invisible = false;
};
@@ -3,6 +3,7 @@
import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigate } from '$lib/utils/navigation';
@@ -163,6 +164,15 @@
}
}
};
const handleUndoDelete = async (assets: TimelineAsset[]) => {
timelineManager.upsertAssets(assets);
if (assets.length > 0) {
const restoredAsset = assets[0];
const asset = await getAssetInfo({ ...authManager.params, id: restoredAsset.id });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
}
};
</script>
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
@@ -175,6 +185,7 @@
{person}
preAction={handlePreAction}
onAction={handleAction}
onUndoDelete={handleUndoDelete}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
@@ -2,6 +2,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import AssetSelectionChangeDateModal from '$lib/modals/AssetSelectionChangeDateModal.svelte';
import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util';
import { modalManager } from '@immich/ui';
import { mdiCalendarEditOutline } from '@mdi/js';
import { DateTime } from 'luxon';
@@ -14,9 +15,11 @@
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleChangeDate = async () => {
const assets = getOwnedAssets();
const initialDate = assets.length === 1 ? fromTimelinePlainDateTime(assets[0].localDateTime) : DateTime.now();
const success = await modalManager.show(AssetSelectionChangeDateModal, {
initialDate: DateTime.now(),
assets: getOwnedAssets(),
initialDate,
assets,
});
if (success) {
clearSelect();
@@ -21,11 +21,15 @@ export const focusPreviousAsset = () =>
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
export const focusAsset = (assetId: string) => {
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
element?.focus();
};
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
const scrolled = scrollToAsset(asset);
if (scrolled) {
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
element?.focus();
focusAsset(asset.id);
}
};
@@ -71,8 +75,7 @@ export const setFocusTo = async (
if (!invocation.isStillValid()) {
return;
}
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
element?.focus();
focusAsset(asset.id);
}
invocation.endInvocation();
@@ -1,68 +1,47 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { dateFormats } from '$lib/constants';
import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte';
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte';
import { getApiKeyActions, getApiKeysActions } from '$lib/services/api-key.service';
import { locale } from '$lib/stores/preferences.store';
import { handleError } from '$lib/utils/handle-error';
import { deleteApiKey, getApiKeys, type ApiKeyResponseDto } from '@immich/sdk';
import { Button, IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import { getApiKeys, type ApiKeyResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
type Props = {
keys: ApiKeyResponseDto[];
}
};
let { keys = $bindable() }: Props = $props();
async function refreshKeys() {
const onApiKeyCreate = async () => {
keys = await getApiKeys();
}
const handleCreate = async () => {
const secret = await modalManager.show(ApiKeyCreateModal);
if (!secret) {
return;
}
await modalManager.show(ApiKeySecretModal, { secret });
await refreshKeys();
};
const handleUpdate = async (key: ApiKeyResponseDto) => {
const success = await modalManager.show(ApiKeyUpdateModal, {
apiKey: key,
});
if (success) {
await refreshKeys();
const onApiKeyUpdate = (update: ApiKeyResponseDto) => {
for (const key of keys) {
if (key.id === update.id) {
Object.assign(key, update);
}
}
};
const handleDelete = async (key: ApiKeyResponseDto) => {
const isConfirmed = await modalManager.showDialog({ prompt: $t('delete_api_key_prompt') });
if (!isConfirmed) {
return;
}
try {
await deleteApiKey({ id: key.id });
toastManager.success($t('removed_api_key', { values: { name: key.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_api_key'));
} finally {
await refreshKeys();
}
const onApiKeyDelete = ({ id }: ApiKeyResponseDto) => {
keys = keys.filter((apiKey) => apiKey.id !== id);
};
const { Create } = $derived(getApiKeysActions($t));
</script>
<OnEvents {onApiKeyCreate} {onApiKeyUpdate} {onApiKeyDelete} />
<section class="my-4">
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
<div class="mb-2 flex justify-end">
<Button shape="round" size="small" onclick={() => handleCreate()}>{$t('new_api_key')}</Button>
<Button leadingIcon={Create.icon} shape="round" size="small" onclick={() => Create.onAction(Create)}
>{Create.title}</Button
>
</div>
{#if keys.length > 0}
@@ -79,6 +58,7 @@
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each keys as key (key.id)}
{@const { Update, Delete } = getApiKeyActions($t, key)}
<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"
>
@@ -91,22 +71,8 @@
>{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)}
</td>
<td class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-1/4">
<IconButton
shape="round"
color="primary"
icon={mdiPencilOutline}
aria-label={$t('edit_key')}
size="small"
onclick={() => handleUpdate(key)}
/>
<IconButton
shape="round"
color="primary"
icon={mdiTrashCanOutline}
aria-label={$t('delete_key')}
size="small"
onclick={() => handleDelete(key)}
/>
<TableButton action={Update} size="small" />
<TableButton action={Delete} size="small" />
</td>
</tr>
{/each}
@@ -101,7 +101,7 @@
}
</script>
<div class="min-w-60 transition-colors border rounded-lg">
<div class="min-w-60 transition-colors border rounded-lg flex-1">
<div class="relative w-full">
<button
type="button"
@@ -168,7 +168,11 @@
? 'bg-success/15 dark:bg-[#001a06]'
: 'bg-transparent'}"
>
<InfoRow icon={mdiImageOutline} highlight={hasDifferentValues.fileName} title={$t('file_name')}>
<InfoRow
icon={mdiImageOutline}
highlight={hasDifferentValues.fileName}
title={$t('file_name', { values: { file_name: asset.originalFileName ?? '' } })}
>
{asset.originalFileName}
</InfoRow>
@@ -170,7 +170,7 @@
</div>
<div class="overflow-x-auto p-2">
<div class="flex flex-nowrap gap-1 place-items-center justify-center min-w-full w-fit mx-auto">
<div class="flex flex-nowrap gap-1 place-items-start justify-center min-w-full w-fit mx-auto">
{#each assets as asset (asset.id)}
<DuplicateAsset {assets} {asset} {onSelectAsset} isSelected={selectedAssetIds.has(asset.id)} {onViewAsset} />
{/each}
@@ -15,8 +15,8 @@
<div class="grid grid-cols-[25px_1fr] w-full px-1 py-0.5" class:border-b={borderBottom} {title}>
<Icon {icon} size="18" class="text-dark/25 {highlight ? 'text-primary/75' : ''}" />
<div class="justify-self-end text-end rounded px-1 transition-colors">
<Text size="tiny" class={highlight ? 'font-semibold text-primary' : ''}>
<div class="justify-self-end text-end rounded px-1 transition-colors w-full overflow-hidden">
<Text size="tiny" class={`${highlight ? 'font-semibold text-primary' : ''} text-ellipsis w-full overflow-hidden`}>
{@render children?.()}
</Text>
</div>
@@ -9,6 +9,7 @@
mdiCrosshairsGps,
mdiImageSizeSelectLarge,
mdiLinkEdit,
mdiStateMachine,
} from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -16,6 +17,7 @@
{ href: AppRoute.DUPLICATES, icon: mdiContentDuplicate, label: $t('review_duplicates') },
{ href: AppRoute.LARGE_FILES, icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
{ href: AppRoute.GEOLOCATION, icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
{ href: AppRoute.WORKFLOWS, icon: mdiStateMachine, label: $t('workflows') },
];
</script>
@@ -0,0 +1,171 @@
<script lang="ts">
import { getComponentDefaultValue, getComponentFromSchema } from '$lib/utils/workflow';
import { Field, Input, MultiSelect, Select, Switch, Text } from '@immich/ui';
import WorkflowPickerField from './WorkflowPickerField.svelte';
type Props = {
schema: object | null;
config: Record<string, unknown>;
configKey?: string;
};
let { schema = null, config = $bindable({}), configKey }: Props = $props();
const components = $derived(getComponentFromSchema(schema));
// Get the actual config object to work with
const actualConfig = $derived(configKey ? (config[configKey] as Record<string, unknown>) || {} : config);
// Update function that handles nested config
const updateConfig = (key: string, value: unknown) => {
config = configKey ? { ...config, [configKey]: { ...actualConfig, [key]: value } } : { ...config, [key]: value };
};
const updateConfigBatch = (updates: Record<string, unknown>) => {
config = configKey ? { ...config, [configKey]: { ...actualConfig, ...updates } } : { ...config, ...updates };
};
// Derive which keys need initialization (missing from actualConfig)
const uninitializedKeys = $derived.by(() => {
if (!components) {
return [];
}
return Object.entries(components)
.filter(([key]) => actualConfig[key] === undefined)
.map(([key, component]) => ({ key, component, defaultValue: getComponentDefaultValue(component) }));
});
// Derive the batch updates needed
const pendingUpdates = $derived.by(() => {
const updates: Record<string, unknown> = {};
for (const { key, defaultValue } of uninitializedKeys) {
updates[key] = defaultValue;
}
return updates;
});
// Initialize config namespace if needed
$effect(() => {
if (configKey && !config[configKey]) {
config = { ...config, [configKey]: {} };
}
});
// Apply pending config updates
$effect(() => {
if (Object.keys(pendingUpdates).length > 0) {
updateConfigBatch(pendingUpdates);
}
});
const isPickerField = (subType: string | undefined) => subType === 'album-picker' || subType === 'people-picker';
</script>
{#if components}
<div class="flex flex-col gap-2">
{#each Object.entries(components) as [key, component] (key)}
{@const label = component.title || component.label || key}
<div class="flex flex-col gap-1 border bg-light p-4 rounded-xl">
<!-- Select component -->
{#if component.type === 'select'}
{#if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
{@const currentValue = actualConfig[key]}
{@const selectedItem = options.find((opt) => opt.value === String(currentValue)) ?? options[0]}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<Select data={options} onChange={(opt) => updateConfig(key, opt.value)} value={selectedItem} />
</Field>
{/if}
<!-- MultiSelect component -->
{:else if component.type === 'multiselect'}
{#if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
{@const currentValues = (actualConfig[key] as string[]) ?? []}
{@const selectedItems = options.filter((opt) => currentValues.includes(opt.value))}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<MultiSelect
data={options}
onChange={(opt) =>
updateConfig(
key,
opt.map((o) => o.value),
)}
values={selectedItems}
/>
</Field>
{/if}
<!-- Switch component -->
{:else if component.type === 'switch'}
{@const checked = Boolean(actualConfig[key])}
<Field
{label}
description={component.description}
requiredIndicator={component.required}
required={component.required}
>
<Switch {checked} onCheckedChange={(check) => updateConfig(key, check)} />
</Field>
<!-- Text input -->
{:else if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
<Field
{label}
description={component.description}
requiredIndicator={component.required}
required={component.required}
>
<Input
id={key}
value={actualConfig[key] as string}
oninput={(e) => updateConfig(key, e.currentTarget.value)}
required={component.required}
/>
</Field>
{/if}
</div>
{/each}
</div>
{:else}
<Text size="small" color="muted">No configuration required</Text>
{/if}
@@ -0,0 +1,42 @@
<script lang="ts">
type Props = {
animated?: boolean;
};
let { animated = true }: Props = $props();
</script>
<div class="flex justify-center py-2">
<div class="relative h-12 w-0.5">
<div class="absolute inset-0 bg-linear-to-b from-primary/30 via-primary/50 to-primary/30"></div>
{#if animated}
<div class="absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent flow-pulse"></div>
{/if}
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
</div>
<div class="absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2">
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
</div>
</div>
</div>
<style>
@keyframes flow {
0% {
transform: translateY(-25%);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: translateY(25%);
opacity: 0;
}
}
.flow-pulse {
animation: flow 2s ease-in-out infinite;
}
</style>
@@ -0,0 +1,69 @@
<script lang="ts">
import { themeManager } from '$lib/managers/theme-manager.svelte';
import type { WorkflowPayload } from '$lib/services/workflow.service';
import { Button, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, VStack } from '@immich/ui';
import { mdiCodeJson } from '@mdi/js';
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
type Props = {
jsonContent: WorkflowPayload;
onApply: () => void;
onContentChange: (content: WorkflowPayload) => void;
};
let { jsonContent, onApply, onContentChange }: Props = $props();
let content: Content = $derived({ json: jsonContent });
let canApply = $state(false);
let editorClass = $derived(themeManager.isDark ? 'jse-theme-dark' : '');
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
if (status.contentErrors) {
return;
}
canApply = true;
if ('text' in updated && updated.text !== undefined) {
try {
const parsed = JSON.parse(updated.text);
onContentChange(parsed);
} catch (error_) {
console.error('Invalid JSON in text mode:', error_);
}
}
};
const handleApply = () => {
onApply();
canApply = false;
};
</script>
<VStack gap={4}>
<Card>
<CardHeader>
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3">
<Icon icon={mdiCodeJson} size="20" class="mt-1" />
<div class="flex flex-col">
<CardTitle>Workflow JSON</CardTitle>
<CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
</div>
</div>
<Button size="small" color="primary" onclick={handleApply} disabled={!canApply}>Apply Changes</Button>
</div>
</CardHeader>
<CardBody>
<VStack gap={2}>
<div class="w-full h-[600px] rounded-lg overflow-hidden border {editorClass}">
<JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
</div>
</VStack>
</CardBody>
</Card>
</VStack>
<style>
@import 'svelte-jsoneditor/themes/jse-theme-dark.css';
</style>
@@ -0,0 +1,104 @@
<script lang="ts">
import WorkflowPickerItemCard from '$lib/components/workflows/WorkflowPickerItemCard.svelte';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
import { fetchPickerMetadata, type PickerMetadata } from '$lib/services/workflow.service';
import type { ComponentConfig } from '$lib/utils/workflow';
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import { Button, Field, modalManager } from '@immich/ui';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
component: ComponentConfig;
configKey: string;
value: string | string[] | undefined;
onchange: (value: string | string[]) => void;
};
let { component, configKey, value = $bindable(), onchange }: Props = $props();
const label = $derived(component.title || component.label || configKey);
const subType = $derived(component.subType as 'album-picker' | 'people-picker');
const isAlbum = $derived(subType === 'album-picker');
const multiple = $derived(component.type === 'multiselect' || Array.isArray(value));
let pickerMetadata = $state<PickerMetadata | undefined>();
$effect(() => {
if (!value) {
pickerMetadata = undefined;
return;
}
if (!pickerMetadata) {
void loadMetadata();
}
});
const loadMetadata = async () => {
pickerMetadata = await fetchPickerMetadata(value, subType);
};
const handlePicker = async () => {
if (isAlbum) {
const albums = await modalManager.show(AlbumPickerModal, { shared: false });
if (albums && albums.length > 0) {
const newValue = multiple ? albums.map((album) => album.id) : albums[0].id;
onchange(newValue);
pickerMetadata = multiple ? albums : albums[0];
}
} else {
const currentIds = (Array.isArray(value) ? value : []) as string[];
const excludedIds = multiple ? currentIds : [];
const people = await modalManager.show(PeoplePickerModal, { multiple, excludedIds });
if (people && people.length > 0) {
const newValue = multiple ? people.map((person) => person.id) : people[0].id;
onchange(newValue);
pickerMetadata = multiple ? people : people[0];
}
}
};
const removeSelection = () => {
onchange(multiple ? [] : '');
pickerMetadata = undefined;
};
const removeItemFromSelection = (itemId: string) => {
if (!Array.isArray(value)) {
return;
}
const newValue = value.filter((id) => id !== itemId);
onchange(newValue);
if (Array.isArray(pickerMetadata)) {
pickerMetadata = pickerMetadata.filter((item) => item.id !== itemId) as AlbumResponseDto[] | PersonResponseDto[];
}
};
const getButtonText = () => {
if (isAlbum) {
return multiple ? $t('select_albums') : $t('select_album');
}
return multiple ? $t('select_people') : $t('select_person');
};
</script>
<Field {label} required={component.required} description={component.description} requiredIndicator={component.required}>
<div class="flex flex-col gap-3">
{#if pickerMetadata && !Array.isArray(pickerMetadata)}
<WorkflowPickerItemCard item={pickerMetadata} {isAlbum} onRemove={removeSelection} />
{:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
<div class="flex flex-col gap-2">
{#each pickerMetadata as item (item.id)}
<WorkflowPickerItemCard {item} {isAlbum} onRemove={() => removeItemFromSelection(item.id)} />
{/each}
</div>
{/if}
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={handlePicker}>
{getButtonText()}
</Button>
</div>
</Field>
@@ -0,0 +1,57 @@
<script lang="ts">
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import { Card, CardBody, IconButton, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
item: AlbumResponseDto | PersonResponseDto;
isAlbum: boolean;
onRemove: () => void;
};
let { item, isAlbum, onRemove }: Props = $props();
</script>
<Card color="secondary">
<CardBody class="flex items-center gap-3">
<div class="shrink-0">
{#if isAlbum && 'albumThumbnailAssetId' in item}
{#if item.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
alt={item.albumName}
class="h-12 w-12 rounded-lg object-cover"
/>
{:else}
<div class="h-12 w-12 rounded-lg"></div>
{/if}
{:else if !isAlbum && 'name' in item}
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="h-12 w-12 rounded-full object-cover" />
{/if}
</div>
<div class="min-w-0 flex-1">
<Text class="font-semibold truncate">
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
</Text>
{#if isAlbum && 'assetCount' in item}
<Text size="small" color="muted">
{$t('items_count', { values: { count: item.assetCount } })}
</Text>
{/if}
</div>
<IconButton
type="button"
onclick={onRemove}
class="shrink-0"
shape="round"
aria-label={$t('remove')}
icon={mdiClose}
size="small"
variant="ghost"
color="secondary"
/>
</CardBody>
</Card>
@@ -0,0 +1,184 @@
<script lang="ts">
import {
PluginTriggerType,
type PluginActionResponseDto,
type PluginFilterResponseDto,
type PluginTriggerResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, Text } from '@immich/ui';
import { mdiClose, mdiFilterOutline, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
trigger: PluginTriggerResponseDto;
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
};
let { trigger, filters, actions }: Props = $props();
const getTriggerName = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return $t('trigger_asset_uploaded');
}
case PluginTriggerType.PersonRecognized: {
return $t('trigger_person_recognized');
}
default: {
return triggerType;
}
}
};
let isOpen = $state(false);
let position = $state({ x: 0, y: 0 });
let isDragging = $state(false);
let dragOffset = $state({ x: 0, y: 0 });
let containerEl: HTMLDivElement | undefined = $state();
const handleMouseDown = (e: MouseEvent) => {
if (!containerEl) {
return;
}
isDragging = true;
const rect = containerEl.getBoundingClientRect();
dragOffset = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) {
return;
}
position = {
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y,
};
};
const handleMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
$effect(() => {
// Initialize position to bottom-right on mount
if (globalThis.window && position.x === 0 && position.y === 0) {
position = {
x: globalThis.innerWidth - 280,
y: globalThis.innerHeight - 400,
};
}
});
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerEl}
class="hidden sm:block fixed w-64 hover:cursor-grab select-none"
style="left: {position.x}px; top: {position.y}px;"
class:cursor-grabbing={isDragging}
onmousedown={handleMouseDown}
>
<div
class="rounded-xl border-transparent border-2 hover:shadow-xl hover:border-dashed bg-light-50 shadow-sm p-4 hover:border-light-300 transition-all"
>
<div class="flex items-center justify-between mb-4 cursor-grab select-none">
<Text size="small" class="font-semibold">{$t('workflow_summary')}</Text>
<div class="flex items-center gap-1">
<IconButton
icon={mdiClose}
size="small"
variant="ghost"
color="secondary"
title="Close summary"
aria-label="Close summary"
onclick={(e: MouseEvent) => {
e.stopPropagation();
isOpen = false;
}}
/>
</div>
</div>
<div class="space-y-2">
<!-- Trigger -->
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-1">
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('trigger')}</span>
</div>
<p class="text-sm truncate pl-5">{getTriggerName(trigger.type)}</p>
</div>
<!-- Connector -->
<div class="flex justify-center">
<div class="w-0.5 h-3 bg-light-400"></div>
</div>
<!-- Filters -->
{#if filters.length > 0}
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-2">
<Icon icon={mdiFilterOutline} size="18" class="text-warning" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('filters')}</span>
</div>
<div class="space-y-1 pl-5">
{#each filters as filter, index (index)}
<div class="flex items-center gap-2">
<span
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
>{index + 1}</span
>
<p class="text-sm truncate">{filter.title}</p>
</div>
{/each}
</div>
</div>
<!-- Connector -->
<div class="flex justify-center">
<div class="w-0.5 h-3 bg-light-400"></div>
</div>
{/if}
<!-- Actions -->
{#if actions.length > 0}
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-2">
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('actions')}</span>
</div>
<div class="space-y-1 pl-5">
{#each actions as action, index (index)}
<div class="flex items-center gap-2">
<span
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
>{index + 1}</span
>
<p class="text-sm truncate">{action.title}</p>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{:else}
<button
type="button"
class="hidden sm:flex fixed right-6 bottom-6 h-14 w-14 items-center justify-center rounded-full bg-primary text-light shadow-lg hover:bg-primary/90 transition-colors"
title={$t('workflow_summary')}
onclick={() => (isOpen = true)}
>
<Icon icon={mdiViewDashboardOutline} size="24" />
</button>
{/if}
@@ -0,0 +1,80 @@
<script lang="ts">
import { PluginTriggerType, type PluginTriggerResponseDto } from '@immich/sdk';
import { Icon, Text } from '@immich/ui';
import { mdiFaceRecognition, mdiFileUploadOutline, mdiLightningBolt } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
trigger: PluginTriggerResponseDto;
selected: boolean;
onclick: () => void;
};
let { trigger, selected, onclick }: Props = $props();
const getTriggerIcon = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return mdiFileUploadOutline;
}
case PluginTriggerType.PersonRecognized: {
return mdiFaceRecognition;
}
default: {
return mdiLightningBolt;
}
}
};
const getTriggerName = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return $t('trigger_asset_uploaded');
}
case PluginTriggerType.PersonRecognized: {
return $t('trigger_person_recognized');
}
default: {
return triggerType;
}
}
};
const getTriggerDescription = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return $t('trigger_asset_uploaded_description');
}
case PluginTriggerType.PersonRecognized: {
return $t('trigger_person_recognized_description');
}
default: {
return '';
}
}
};
</script>
<button
type="button"
{onclick}
class="group rounded-xl p-4 w-full text-left cursor-pointer border-2 {selected
? 'border-primary text-primary'
: 'border-light-100 hover:border-light-200 text-light-400 hover:text-light-700'}"
>
<div class="flex items-center gap-3">
<div
class="rounded-xl p-2 {selected
? 'bg-primary text-light'
: 'text-light-100 bg-light-300 group-hover:bg-light-500'}"
>
<Icon icon={getTriggerIcon(trigger.type)} size="24" />
</div>
<div class="flex-1">
<Text class="font-semibold mb-1">{getTriggerName(trigger.type)}</Text>
{#if getTriggerDescription(trigger.type)}
<Text size="small">{getTriggerDescription(trigger.type)}</Text>
{/if}
</div>
</div>
</button>
+8 -1
View File
@@ -1,3 +1,5 @@
export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/;
export enum AssetAction {
ARCHIVE = 'archive',
UNARCHIVE = 'unarchive',
@@ -16,11 +18,14 @@ export enum AssetAction {
SET_VISIBILITY_LOCKED = 'set-visibility-locked',
SET_VISIBILITY_TIMELINE = 'set-visibility-timeline',
SET_PERSON_FEATURED_PHOTO = 'set-person-featured-photo',
RATING = 'rating',
}
export enum AppRoute {
ADMIN_USERS = '/admin/users',
ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
ADMIN_USERS_NEW = '/admin/users/new',
ADMIN_LIBRARIES = '/admin/library-management',
ADMIN_LIBRARIES_NEW = '/admin/library-management/new',
ADMIN_SETTINGS = '/admin/system-settings',
ADMIN_MAINTENANCE_SETTINGS = '/admin/maintenance',
ADMIN_MAINTENANCE_INTEGRITY_REPORT = '/admin/maintenance/integrity-report/',
@@ -57,6 +62,7 @@ export enum AppRoute {
DUPLICATES = '/utilities/duplicates',
LARGE_FILES = '/utilities/large-files',
GEOLOCATION = '/utilities/geolocation',
WORKFLOWS = '/utilities/workflows',
FOLDERS = '/folders',
TAGS = '/tags',
@@ -308,6 +314,7 @@ export const langs: Lang[] = [
{ name: 'Chuvash', code: 'cv', loader: () => import('$i18n/cv.json') },
{ name: 'Danish', code: 'da', loader: () => import('$i18n/da.json') },
{ name: 'German', code: 'de', loader: () => import('$i18n/de.json') },
{ name: 'German (Switzerland)', code: 'de-CH', weblateCode: 'de_CH', loader: () => import('$i18n/de_CH.json') },
defaultLang,
{ name: 'Greek', code: 'el', loader: () => import('$i18n/el.json') },
{ name: 'Esperanto', code: 'eo', loader: () => import('$i18n/eo.json') },
@@ -0,0 +1,43 @@
import { PersistedLocalStorage } from '$lib/utils/persisted';
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
export class AssetViewerManager {
#isShowActivityPanel = $state(false);
get isShowActivityPanel() {
return this.#isShowActivityPanel;
}
private set isShowActivityPanel(value: boolean) {
this.#isShowActivityPanel = value;
}
get isShowDetailPanel() {
return isShowDetailPanel.current;
}
private set isShowDetailPanel(value: boolean) {
isShowDetailPanel.current = value;
}
toggleActivityPanel() {
this.closeDetailPanel();
this.isShowActivityPanel = !this.isShowActivityPanel;
}
closeActivityPanel() {
this.isShowActivityPanel = false;
}
toggleDetailPanel() {
this.closeActivityPanel();
this.isShowDetailPanel = !this.isShowDetailPanel;
}
closeDetailPanel() {
this.isShowDetailPanel = false;
}
}
export const assetViewerManager = new AssetViewerManager();
@@ -2,12 +2,14 @@ import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
import type { ReleaseEvent } from '$lib/types';
import type {
AlbumResponseDto,
ApiKeyResponseDto,
LibraryResponseDto,
LoginResponseDto,
QueueResponseDto,
SharedLinkResponseDto,
SystemConfigDto,
UserAdminResponseDto,
WorkflowResponseDto,
} from '@immich/sdk';
export type Events = {
@@ -18,8 +20,13 @@ export type Events = {
LanguageChange: [{ name: string; code: string; rtl?: boolean }];
ThemeChange: [ThemeSetting];
ApiKeyCreate: [ApiKeyResponseDto];
ApiKeyUpdate: [ApiKeyResponseDto];
ApiKeyDelete: [ApiKeyResponseDto];
AssetReplace: [{ oldAssetId: string; newAssetId: string }];
AlbumUpdate: [AlbumResponseDto];
AlbumDelete: [AlbumResponseDto];
QueueUpdate: [QueueResponseDto];
@@ -42,6 +49,9 @@ export type Events = {
LibraryUpdate: [LibraryResponseDto];
LibraryDelete: [{ id: string }];
WorkflowUpdate: [WorkflowResponseDto];
WorkflowDelete: [WorkflowResponseDto];
ReleaseEvent: [ReleaseEvent];
};
@@ -346,7 +346,7 @@ export class TimelineManager extends VirtualScrollManager {
async findMonthGroupForAsset(asset: AssetDescriptor | AssetResponseDto) {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
await this.initTask.waitUntilExecution();
}
const { id } = asset;
@@ -0,0 +1,80 @@
<script lang="ts">
import type { PluginActionResponseDto, PluginFilterResponseDto } from '@immich/sdk';
import { Modal, ModalBody, Text } from '@immich/ui';
import { mdiFilterOutline, mdiPlayCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
onClose: (result?: { type: 'filter' | 'action'; item: PluginFilterResponseDto | PluginActionResponseDto }) => void;
type?: 'filter' | 'action';
};
let { filters, actions, onClose, type }: Props = $props();
type StepType = 'filter' | 'action';
const handleSelect = (type: StepType, item: PluginFilterResponseDto | PluginActionResponseDto) => {
onClose({ type, item });
};
const getModalTitle = () => {
if (type === 'filter') {
return $t('add_filter');
} else if (type === 'action') {
return $t('add_action');
} else {
return $t('add_workflow_step');
}
};
const getModalIcon = () => {
if (type === 'filter') {
return mdiFilterOutline;
} else if (type === 'action') {
return mdiPlayCircleOutline;
} else {
return false;
}
};
</script>
{#snippet stepButton(title: string, description?: string, onclick?: () => void)}
<button
type="button"
{onclick}
class="flex items-start gap-3 p-3 rounded-lg text-left bg-light-100 hover:border-primary border text-dark"
>
<div class="flex-1">
<Text color="primary" class="font-medium">{title}</Text>
{#if description}
<Text size="small" class="mt-1">{description}</Text>
{/if}
</div>
</button>
{/snippet}
<Modal title={getModalTitle()} icon={getModalIcon()} onClose={() => onClose()}>
<ModalBody>
<div class="space-y-6">
<!-- Filters Section -->
{#if filters.length > 0 && (!type || type === 'filter')}
<div class="grid grid-cols-1 gap-2">
{#each filters as filter (filter.id)}
{@render stepButton(filter.title, filter.description, () => handleSelect('filter', filter))}
{/each}
</div>
{/if}
<!-- Actions Section -->
{#if actions.length > 0 && (!type || type === 'action')}
<div class="grid grid-cols-1 gap-2">
{#each actions as action (action.id)}
{@render stepButton(action.title, action.description, () => handleSelect('action', action))}
{/each}
</div>
{/if}
</div>
</ModalBody>
</Modal>
+22 -44
View File
@@ -1,63 +1,41 @@
<script lang="ts">
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Textarea } from '@immich/ui';
import { handleUpdateAlbum } from '$lib/services/album.service';
import { type AlbumResponseDto } from '@immich/sdk';
import { Field, FormModal, Input, Textarea } from '@immich/ui';
import { mdiRenameOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
album: AlbumResponseDto;
onClose: (album?: AlbumResponseDto) => void;
onClose: () => void;
};
let { album = $bindable(), onClose }: Props = $props();
let { album, onClose }: Props = $props();
let albumName = $state(album.albumName);
let description = $state(album.description);
let isSubmitting = $state(false);
const handleSubmit = async (event: Event) => {
event.preventDefault();
isSubmitting = true;
try {
await updateAlbumInfo({ id: album.id, updateAlbumDto: { albumName, description } });
album.albumName = albumName;
album.description = description;
onClose(album);
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_info'));
} finally {
isSubmitting = false;
const onSubmit = async () => {
const success = await handleUpdateAlbum(album, { albumName, description });
if (success) {
onClose();
}
};
</script>
<Modal icon={mdiRenameOutline} title={$t('edit_album')} size="medium" {onClose}>
<ModalBody>
<form onsubmit={handleSubmit} autocomplete="off" id="edit-album-form">
<div class="flex items-center gap-8 m-4">
<AlbumCover {album} class="h-50 w-50 shadow-lg hidden sm:flex" />
<FormModal icon={mdiRenameOutline} title={$t('edit_album')} size="medium" {onClose} {onSubmit}>
<div class="flex items-center gap-8 m-4">
<AlbumCover {album} class="h-50 w-50 shadow-lg hidden sm:flex" />
<div class="grow flex flex-col gap-4">
<Field label={$t('name')}>
<Input bind:value={albumName} />
</Field>
<div class="grow flex flex-col gap-4">
<Field label={$t('name')}>
<Input bind:value={albumName} />
</Field>
<Field label={$t('description')}>
<Textarea bind:value={description} />
</Field>
</div>
</div>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth disabled={isSubmitting} form="edit-album-form">{$t('save')}</Button>
</HStack>
</ModalFooter>
</Modal>
<Field label={$t('description')}>
<Textarea bind:value={description} />
</Field>
</div>
</div>
</FormModal>
+19 -44
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte';
import { handleError } from '$lib/utils/handle-error';
import { createApiKey, Permission } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, toastManager } from '@immich/ui';
import { handleCreateApiKey } from '$lib/services/api-key.service';
import { Permission } from '@immich/sdk';
import { Field, FormModal, Input } from '@immich/ui';
import { mdiKeyVariant } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = { onClose: (secret?: string) => void };
type Props = { onClose: () => void };
const { onClose }: Props = $props();
@@ -14,47 +14,22 @@
let selectedPermissions = $state<Permission[]>([]);
const isAllPermissions = $derived(selectedPermissions.length === Object.keys(Permission).length - 1);
const onsubmit = async () => {
if (!name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (selectedPermissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
try {
const { secret } = await createApiKey({
apiKeyCreateDto: {
name,
permissions: isAllPermissions ? [Permission.All] : selectedPermissions,
},
});
onClose(secret);
} catch (error) {
handleError(error, $t('errors.unable_to_create_api_key'));
const onSubmit = async () => {
const success = await handleCreateApiKey({
name,
permissions: isAllPermissions ? [Permission.All] : selectedPermissions,
});
if (success) {
onClose();
}
};
</script>
<Modal title={$t('new_api_key')} icon={mdiKeyVariant} {onClose} size="giant">
<ModalBody>
<form {onsubmit} autocomplete="off" id="api-key-form">
<div class="mb-4 flex flex-col gap-2">
<Field label={$t('name')}>
<Input bind:value={name} />
</Field>
</div>
<ApiKeyPermissionsPicker bind:selectedPermissions />
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="api-key-form">{$t('create')}</Button>
</HStack>
</ModalFooter>
</Modal>
<FormModal title={$t('new_api_key')} icon={mdiKeyVariant} {onClose} {onSubmit} submitText={$t('create')} size="giant">
<div class="mb-4 flex flex-col gap-2">
<Field label={$t('name')}>
<Input bind:value={name} />
</Field>
</div>
<ApiKeyPermissionsPicker bind:selectedPermissions />
</FormModal>
+1 -1
View File
@@ -15,7 +15,7 @@
<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="small">
<ModalBody>
<Text size="small" class="mb-4">{$t('api_key_description')}</Text>
<Textarea bind:value={secret} readonly />
<Textarea bind:value={secret} readonly class="font-mono" />
</ModalBody>
<ModalFooter>
+23 -48
View File
@@ -1,69 +1,44 @@
<script lang="ts">
import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte';
import { handleError } from '$lib/utils/handle-error';
import { Permission, updateApiKey } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, toastManager } from '@immich/ui';
import { handleUpdateApiKey } from '$lib/services/api-key.service';
import { Permission } from '@immich/sdk';
import { Field, FormModal, Input } from '@immich/ui';
import { mdiKeyVariant } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
apiKey: { id: string; name: string; permissions: Permission[] };
onClose: (success?: true) => void;
}
onClose: () => void;
};
let { apiKey, onClose }: Props = $props();
const isAllPermissions = (permissions: Permission[]) => permissions.length === Object.keys(Permission).length - 1;
const mapPermissions = (permissions: Permission[]) =>
permissions.includes(Permission.All)
? Object.values(Permission).filter((permission) => permission !== Permission.All)
: permissions;
const isAllPermissions = (permissions: Permission[]) => permissions.length === Object.keys(Permission).length - 1;
let name = $state(apiKey.name);
let selectedPermissions = $state<Permission[]>(mapPermissions(apiKey.permissions));
const onsubmit = async () => {
if (!name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (selectedPermissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
try {
await updateApiKey({
id: apiKey.id,
apiKeyUpdateDto: {
name,
permissions: isAllPermissions(selectedPermissions) ? [Permission.All] : selectedPermissions,
},
});
toastManager.success($t('saved_api_key'));
onClose(true);
} catch (error) {
handleError(error, $t('errors.unable_to_save_api_key'));
const onSubmit = async () => {
const success = await handleUpdateApiKey(apiKey, {
name,
permissions: isAllPermissions(selectedPermissions) ? [Permission.All] : selectedPermissions,
});
if (success) {
onClose();
}
};
</script>
<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="giant">
<ModalBody>
<form {onsubmit} autocomplete="off" id="api-key-form">
<div class="mb-4 flex flex-col gap-2">
<Field label={$t('name')}>
<Input bind:value={name} />
</Field>
</div>
<ApiKeyPermissionsPicker bind:selectedPermissions />
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="api-key-form">{$t('save')}</Button>
</HStack>
</ModalFooter>
</Modal>
<FormModal title={$t('api_key')} icon={mdiKeyVariant} {onClose} {onSubmit} size="giant">
<div class="mb-4 flex flex-col gap-2">
<Field label={$t('name')}>
<Input bind:value={name} />
</Field>
</div>
<ApiKeyPermissionsPicker bind:selectedPermissions />
</FormModal>
@@ -71,6 +71,34 @@ describe('DateSelectionModal component', () => {
expect(onClose).toHaveBeenCalled();
});
test('does not fall back to UTC when datetime-local value has no seconds', async () => {
render(AssetSelectionChangeDateModal, {
props: { initialDate, initialTimeZone, assets: [], onClose },
});
await fireEvent.input(getDateInput(), { target: { value: '2024-01-01T00:00' } });
await fireEvent.blur(getDateInput());
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
await fireEvent.focus(getTimeZoneInput());
expect(screen.queryByText('no_results')).not.toBeInTheDocument();
});
test('does not fall back to UTC when datetime-local value has no milliseconds', async () => {
render(AssetSelectionChangeDateModal, {
props: { initialDate, initialTimeZone, assets: [], onClose },
});
await fireEvent.input(getDateInput(), { target: { value: '2024-01-01T00:00:00' } });
await fireEvent.blur(getDateInput());
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
await fireEvent.focus(getTimeZoneInput());
expect(screen.queryByText('no_results')).not.toBeInTheDocument();
});
describe('when date is in daylight saving time', () => {
const dstDate = DateTime.fromISO('2024-07-01');
+51 -55
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { tagAssets } from '$lib/utils/asset-utils';
import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk';
import { Button, HStack, Icon, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { FormModal, Icon } from '@immich/ui';
import { mdiClose, mdiTag } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -25,7 +25,11 @@
allTags = await getAllTags();
});
const handleSubmit = async () => {
const onSubmit = async () => {
if (selectedIds.size === 0) {
return;
}
await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
onClose(true);
};
@@ -47,60 +51,52 @@
const handleRemove = (tag: string) => {
selectedIds.delete(tag);
};
const onsubmit = async (event: Event) => {
event.preventDefault();
await handleSubmit();
};
</script>
<Modal size="small" title={$t('tag_assets')} icon={mdiTag} {onClose}>
<ModalBody>
<form {onsubmit} autocomplete="off" id="create-tag-form">
<div class="my-4 flex flex-col gap-2">
<Combobox
onSelect={handleSelect}
label={$t('tag')}
{allowCreate}
defaultFirstOption
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
placeholder={$t('search_tags')}
forceFocus
/>
</div>
</form>
<FormModal
size="small"
title={$t('tag_assets')}
icon={mdiTag}
{onClose}
{onSubmit}
submitText={$t('tag_assets')}
{disabled}
>
<div class="my-4 flex flex-col gap-2">
<Combobox
onSelect={handleSelect}
label={$t('tag')}
{allowCreate}
defaultFirstOption
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
placeholder={$t('search_tags')}
forceFocus
/>
</div>
<section class="flex flex-wrap pt-2 gap-1">
{#each selectedIds as tagId (tagId)}
{@const tag = tagMap[tagId]}
{#if tag}
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{tag.value}
</p>
</span>
<section class="flex flex-wrap pt-2 gap-1">
{#each selectedIds as tagId (tagId)}
{@const tag = tagMap[tagId]}
{#if tag}
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{tag.value}
</p>
</span>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title={$t('remove_tag')}
onclick={() => handleRemove(tagId)}
>
<Icon icon={mdiClose} />
</button>
</div>
{/if}
{/each}
</section>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" fullWidth color="secondary" onclick={() => onClose()}>{$t('cancel')}</Button>
<Button type="submit" shape="round" fullWidth form="create-tag-form" {disabled}>{$t('tag_assets')}</Button>
</HStack>
</ModalFooter>
</Modal>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title={$t('remove_tag')}
onclick={() => handleRemove(tagId)}
>
<Icon icon={mdiClose} />
</button>
</div>
{/if}
{/each}
</section>
</FormModal>
@@ -1,7 +1,7 @@
<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 { Field, FormModal, Input, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -11,33 +11,26 @@
};
const { library, onClose }: Props = $props();
let exclusionPattern = $state('');
let value = $state('');
const onsubmit = async () => {
const success = await handleAddLibraryExclusionPattern(library, exclusionPattern);
const onSubmit = async () => {
const success = await handleAddLibraryExclusionPattern(library, value);
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>
<FormModal
title={$t('add_exclusion_pattern')}
icon={mdiFolderSync}
{onClose}
{onSubmit}
submitText={$t('add')}
size="small"
>
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
<Field label={$t('pattern')}>
<Input bind:value />
</Field>
</FormModal>
@@ -1,7 +1,7 @@
<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 { Field, FormModal, Input, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -11,35 +11,20 @@
onClose: () => void;
};
const { library, exclusionPattern, onClose }: Props = $props();
const { library, exclusionPattern: oldValue, onClose }: Props = $props();
let newValue = $state(oldValue);
let newExclusionPattern = $state(exclusionPattern);
const onsubmit = async () => {
const success = await handleEditExclusionPattern(library, exclusionPattern, newExclusionPattern);
const onSubmit = async () => {
const success = await handleEditExclusionPattern(library, oldValue, newValue);
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>
<FormModal title={$t('edit_exclusion_pattern')} icon={mdiFolderSync} {onClose} {onSubmit} size="small">
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
<Field label={$t('pattern')}>
<Input bind:value={newValue} />
</Field>
</FormModal>
+17 -25
View File
@@ -1,7 +1,7 @@
<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 { Field, FormModal, Input, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -11,34 +11,26 @@
};
const { library, onClose }: Props = $props();
let folder = $state('');
const onsubmit = async () => {
const success = await handleAddLibraryFolder(library, folder);
let value = $state('');
const onSubmit = async () => {
const success = await handleAddLibraryFolder(library, value);
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>
<FormModal
title={$t('library_add_folder')}
icon={mdiFolderSync}
{onClose}
{onSubmit}
size="small"
submitText={$t('add')}
>
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
<Field label={$t('path')}>
<Input bind:value />
</Field>
</FormModal>
@@ -1,7 +1,7 @@
<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 { Field, FormModal, Input, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -11,35 +11,21 @@
onClose: () => void;
};
const { library, folder, onClose }: Props = $props();
const { library, folder: oldValue, onClose }: Props = $props();
let newFolder = $state(folder);
let newValue = $state(oldValue);
const onsubmit = async () => {
const success = await handleEditLibraryFolder(library, folder, newFolder);
const onSubmit = async () => {
const success = await handleEditLibraryFolder(library, oldValue, newValue);
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>
<FormModal title={$t('library_edit_folder')} icon={mdiFolderSync} {onClose} {onSubmit} size="small">
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
<Field label={$t('path')}>
<Input bind:value={newValue} />
</Field>
</FormModal>
@@ -1,41 +0,0 @@
<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';
type Props = {
library: LibraryResponseDto;
onClose: () => void;
};
let { library, onClose }: Props = $props();
let newName = $state(library.name);
const onsubmit = async () => {
const success = await handleRenameLibrary(library, newName);
if (success) {
onClose();
}
};
</script>
<Modal icon={mdiRenameOutline} title={$t('rename')} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="rename-library-form">
<Field label={$t('name')}>
<Input bind:value={newName} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" fullWidth color="secondary" onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" fullWidth type="submit" form="rename-library-form">{$t('save')}</Button>
</HStack>
</ModalFooter>
</Modal>
@@ -1,46 +0,0 @@
<script lang="ts">
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { user } from '$lib/stores/user.store';
import { searchUsersAdmin } from '@immich/sdk';
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 {
onClose: (ownerId?: string) => void;
}
let { onClose }: Props = $props();
let ownerId: string = $state($user.id);
let userOptions: { value: string; text: string }[] = $state([]);
onMount(async () => {
const users = await searchUsersAdmin({});
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
});
const onsubmit = (event: Event) => {
event.preventDefault();
onClose(ownerId);
};
</script>
<Modal title={$t('select_library_owner')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="select-library-owner-form">
<p class="p-5 text-sm">{$t('admin.note_cannot_be_changed_later')}</p>
<SettingSelect bind:value={ownerId} options={userOptions} name="user" />
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="select-library-owner-form">{$t('create')}</Button>
</HStack>
</ModalFooter>
</Modal>
+100 -112
View File
@@ -2,7 +2,7 @@
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import DateInput from '$lib/elements/DateInput.svelte';
import type { MapSettings } from '$lib/stores/preferences.store';
import { Button, Field, HStack, Modal, ModalBody, ModalFooter, Stack, Switch } from '@immich/ui';
import { Button, Field, FormModal, Stack, Switch } from '@immich/ui';
import { Duration } from 'luxon';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -17,119 +17,107 @@
let customDateRange = $state(!!settings.dateAfter || !!settings.dateBefore);
const onsubmit = (event: Event) => {
event.preventDefault();
const onSubmit = () => {
onClose(settings);
};
</script>
<Modal title={$t('map_settings')} {onClose} size="small">
<ModalBody>
<form {onsubmit} id="map-settings-form">
<Stack gap={4}>
<Field label={$t('allow_dark_mode')}>
<Switch bind:checked={settings.allowDarkMode} />
</Field>
<Field label={$t('only_favorites')}>
<Switch bind:checked={settings.onlyFavorites} />
</Field>
<Field label={$t('include_archived')}>
<Switch bind:checked={settings.includeArchived} />
</Field>
<Field label={$t('include_shared_partner_assets')}>
<Switch bind:checked={settings.withPartners} />
</Field>
<Field label={$t('include_shared_albums')}>
<Switch bind:checked={settings.withSharedAlbums} />
</Field>
<FormModal title={$t('map_settings')} {onClose} {onSubmit} size="small">
<Stack gap={4}>
<Field label={$t('allow_dark_mode')}>
<Switch bind:checked={settings.allowDarkMode} />
</Field>
<Field label={$t('only_favorites')}>
<Switch bind:checked={settings.onlyFavorites} />
</Field>
<Field label={$t('include_archived')}>
<Switch bind:checked={settings.includeArchived} />
</Field>
<Field label={$t('include_shared_partner_assets')}>
<Switch bind:checked={settings.withPartners} />
</Field>
<Field label={$t('include_shared_albums')}>
<Switch bind:checked={settings.withSharedAlbums} />
</Field>
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-after">{$t('date_after')}</label>
<DateInput
class="immich-form-input w-40"
type="date"
id="date-after"
max={settings.dateBefore}
bind:value={settings.dateAfter}
/>
</div>
<div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-before">{$t('date_before')}</label>
<DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
</div>
<div class="flex justify-center text-xs">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = false;
settings.dateAfter = '';
settings.dateBefore = '';
}}
>
{$t('remove_custom_date_range')}
</Button>
</div>
</div>
{:else}
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
<SettingSelect
label={$t('date_range')}
name="date-range"
bind:value={settings.relativeDate}
options={[
{
value: '',
text: $t('all'),
},
{
value: Duration.fromObject({ hours: 24 }).toISO() || '',
text: $t('past_durations.hours', { values: { hours: 24 } }),
},
{
value: Duration.fromObject({ days: 7 }).toISO() || '',
text: $t('past_durations.days', { values: { days: 7 } }),
},
{
value: Duration.fromObject({ days: 30 }).toISO() || '',
text: $t('past_durations.days', { values: { days: 30 } }),
},
{
value: Duration.fromObject({ years: 1 }).toISO() || '',
text: $t('past_durations.years', { values: { years: 1 } }),
},
{
value: Duration.fromObject({ years: 3 }).toISO() || '',
text: $t('past_durations.years', { values: { years: 3 } }),
},
]}
/>
<div class="text-xs">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
{$t('use_custom_date_range')}
</Button>
</div>
</div>
{/if}
</Stack>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button color="secondary" shape="round" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button type="submit" shape="round" fullWidth form="map-settings-form">{$t('save')}</Button>
</HStack>
</ModalFooter>
</Modal>
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-after">{$t('date_after')}</label>
<DateInput
class="immich-form-input w-40"
type="date"
id="date-after"
max={settings.dateBefore}
bind:value={settings.dateAfter}
/>
</div>
<div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-before">{$t('date_before')}</label>
<DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
</div>
<div class="flex justify-center text-xs">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = false;
settings.dateAfter = '';
settings.dateBefore = '';
}}
>
{$t('remove_custom_date_range')}
</Button>
</div>
</div>
{:else}
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
<SettingSelect
label={$t('date_range')}
name="date-range"
bind:value={settings.relativeDate}
options={[
{
value: '',
text: $t('all'),
},
{
value: Duration.fromObject({ hours: 24 }).toISO() || '',
text: $t('past_durations.hours', { values: { hours: 24 } }),
},
{
value: Duration.fromObject({ days: 7 }).toISO() || '',
text: $t('past_durations.days', { values: { days: 7 } }),
},
{
value: Duration.fromObject({ days: 30 }).toISO() || '',
text: $t('past_durations.days', { values: { days: 30 } }),
},
{
value: Duration.fromObject({ years: 1 }).toISO() || '',
text: $t('past_durations.years', { values: { years: 1 } }),
},
{
value: Duration.fromObject({ years: 3 }).toISO() || '',
text: $t('past_durations.years', { values: { years: 3 } }),
},
]}
/>
<div class="text-xs">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
{$t('use_custom_date_range')}
</Button>
</div>
</div>
{/if}
</Stack>
</FormModal>
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, HStack, LoadingSpinner, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
multiple?: boolean;
excludedIds?: string[];
onClose: (people?: PersonResponseDto[]) => void;
};
let { multiple = false, excludedIds = [], onClose }: Props = $props();
let people: PersonResponseDto[] = $state([]);
let loading = $state(true);
let searchName = $state('');
let selectedPeople: PersonResponseDto[] = $state([]);
const filteredPeople = $derived(
people
.filter((person) => !excludedIds.includes(person.id))
.filter((person) => !searchName || person.name.toLowerCase().includes(searchName.toLowerCase())),
);
onMount(async () => {
try {
loading = true;
const result = await getAllPeople({ withHidden: false });
people = result.people;
loading = false;
} catch (error) {
handleError(error, $t('get_people_error'));
}
});
const togglePerson = (person: PersonResponseDto) => {
if (multiple) {
const index = selectedPeople.findIndex((p) => p.id === person.id);
selectedPeople = index === -1 ? [...selectedPeople, person] : selectedPeople.filter((p) => p.id !== person.id);
} else {
onClose([person]);
}
};
const handleSubmit = () => {
if (selectedPeople.length > 0) {
onClose(selectedPeople);
} else {
onClose();
}
};
</script>
<Modal title={multiple ? $t('select_people') : $t('select_person')} {onClose} size="small">
<ModalBody>
<div class="flex flex-col gap-4">
<SearchBar bind:name={searchName} placeholder={$t('search_people')} showLoadingSpinner={false} />
<div class="immich-scrollbar max-h-96 overflow-y-auto">
{#if loading}
<div class="flex justify-center p-8">
<LoadingSpinner />
</div>
{:else if filteredPeople.length > 0}
<div class="grid grid-cols-3 gap-4 p-2">
{#each filteredPeople as person (person.id)}
{@const isSelected = selectedPeople.some((p) => p.id === person.id)}
<button
type="button"
onclick={() => togglePerson(person)}
class="flex flex-col items-center gap-2 rounded-xl p-2 transition-all hover:bg-subtle {isSelected
? 'bg-primary/10 ring-2 ring-primary'
: ''}"
>
<ImageThumbnail
circle
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
widthStyle="100%"
/>
<p class="line-clamp-2 text-center text-sm font-medium">{person.name}</p>
</button>
{/each}
</div>
{:else}
<p class="py-8 text-center text-sm text-gray-500">{$t('no_people_found')}</p>
{/if}
</div>
</div>
</ModalBody>
{#if multiple}
<ModalFooter>
<HStack fullWidth gap={4}>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" fullWidth onclick={handleSubmit} disabled={selectedPeople.length === 0}>
{$t('select_count', { values: { count: selectedPeople.length } })}
</Button>
</HStack>
</ModalFooter>
{/if}
</Modal>
+52 -57
View File
@@ -2,29 +2,27 @@
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import { handleCreateSharedLink } from '$lib/services/shared-link.service';
import { SharedLinkType } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, PasswordInput, Switch, Text } from '@immich/ui';
import { Field, FormModal, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { mdiLink } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
onClose: (success?: boolean) => void;
onClose: () => void;
albumId?: string;
assetIds?: string[];
}
let { onClose, albumId = $bindable(), assetIds = $bindable([]) }: Props = $props();
let { onClose, albumId, assetIds }: Props = $props();
let description = $state('');
let allowDownload = $state(true);
let allowUpload = $state(false);
let showMetadata = $state(true);
let expirationOption: number = $state(0);
let password = $state('');
let slug = $state('');
let expiresAt = $state<string | null>(null);
let shareType = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual);
let type = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual);
$effect(() => {
if (!showMetadata) {
@@ -32,12 +30,12 @@
}
});
const onCreate = async () => {
const onSubmit = async () => {
const success = await handleCreateSharedLink({
type: shareType,
type,
albumId,
assetIds,
expiresAt: expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : undefined,
expiresAt,
allowUpload,
description,
password,
@@ -45,61 +43,58 @@
showMetadata,
slug,
});
if (success) {
onClose(true);
onClose();
}
};
</script>
<Modal title={$t('create_link_to_share')} icon={mdiLink} size="small" {onClose}>
<ModalBody>
{#if shareType === SharedLinkType.Album}
<div>{$t('album_with_link_access')}</div>
{/if}
<FormModal
title={$t('create_link_to_share')}
icon={mdiLink}
size="small"
{onClose}
{onSubmit}
submitText={$t('create_link')}
>
{#if type === SharedLinkType.Album}
<div>{$t('album_with_link_access')}</div>
{/if}
{#if shareType === SharedLinkType.Individual}
<div>{$t('create_link_to_share_description')}</div>
{/if}
{#if type === SharedLinkType.Individual}
<div>{$t('create_link_to_share_description')}</div>
{/if}
<div class="flex flex-col gap-4 mt-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2 break-all">/s/{encodeURIComponent(slug)}</Text>
{/if}
</div>
<Field label={$t('password')} description={$t('shared_link_password_description')}>
<PasswordInput bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
<Switch bind:checked={allowDownload} />
</Field>
<Field label={$t('allow_public_user_to_upload')}>
<Switch bind:checked={allowUpload} />
<div class="flex flex-col gap-4 mt-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2 break-all">/s/{encodeURIComponent(slug)}</Text>
{/if}
</div>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button color="secondary" shape="round" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button fullWidth shape="round" onclick={onCreate}>{$t('create_link')}</Button>
</HStack>
</ModalFooter>
</Modal>
<Field label={$t('password')} description={$t('shared_link_password_description')}>
<PasswordInput bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
<Switch bind:checked={allowDownload} />
</Field>
<Field label={$t('allow_public_user_to_upload')}>
<Switch bind:checked={allowUpload} />
</Field>
</div>
</FormModal>
@@ -1,98 +0,0 @@
<script lang="ts">
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import { handleUpdateSharedLink } from '$lib/services/shared-link.service';
import { SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, PasswordInput, Switch, Text } from '@immich/ui';
import { mdiLink } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onClose: (success?: boolean) => void;
sharedLink: SharedLinkResponseDto;
}
let { onClose, sharedLink }: Props = $props();
let description = $state(sharedLink.description ?? '');
let allowDownload = $state(sharedLink.allowDownload);
let allowUpload = $state(sharedLink.allowUpload);
let showMetadata = $state(sharedLink.showMetadata);
let password = $state(sharedLink.password ?? '');
let slug = $state(sharedLink.slug ?? '');
let shareType = sharedLink.album ? SharedLinkType.Album : SharedLinkType.Individual;
let expiresAt = $state(sharedLink.expiresAt);
const onUpdate = async () => {
const success = await handleUpdateSharedLink(sharedLink, {
description,
password: password ?? null,
expiresAt,
allowUpload,
allowDownload,
showMetadata,
slug: slug.trim() ?? null,
});
if (success) {
onClose(true);
}
};
</script>
<Modal title={$t('edit_link')} icon={mdiLink} size="small" {onClose}>
<ModalBody>
{#if shareType === SharedLinkType.Album}
<div class="text-sm">
{$t('public_album')} |
<span class="text-primary">{sharedLink.album?.albumName}</span>
</div>
{/if}
{#if shareType === SharedLinkType.Individual}
<div class="text-sm">
{$t('individual_share')} |
<span class="text-primary">{sharedLink.description || ''}</span>
</div>
{/if}
<div class="flex flex-col gap-4 mt-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2">/s/{encodeURIComponent(slug)}</Text>
{/if}
</div>
<Field label={$t('password')} description={$t('shared_link_password_description')}>
<PasswordInput bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration createdAt={sharedLink.createdAt} bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
<Switch bind:checked={allowDownload} />
</Field>
<Field label={$t('allow_public_user_to_upload')}>
<Switch bind:checked={allowUpload} />
</Field>
</div>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button color="secondary" shape="round" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button fullWidth shape="round" onclick={onUpdate}>{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>
+4
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { preferences } from '$lib/stores/user.store';
import { Icon, Modal, ModalBody } from '@immich/ui';
import { mdiInformationOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -44,6 +45,9 @@
{ key: ['⇧', 'd'], action: $t('download') },
{ key: ['Space'], action: $t('play_or_pause_video') },
{ key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
...($preferences?.ratings.enabled
? [{ key: ['1-5'], action: $t('rate_asset'), info: $t('zero_to_clear_rating') }]
: []),
],
},
}: Props = $props();
@@ -1,9 +1,6 @@
<script lang="ts">
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import type { RenderedOption } from '$lib/elements/Dropdown.svelte';
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { Field, FormModal, HelperText, NumberInput, Switch } from '@immich/ui';
import {
mdiArrowDownThin,
mdiArrowUpThin,
@@ -14,7 +11,7 @@
} from '@mdi/js';
import { t } from 'svelte-i18n';
import SettingDropdown from '../components/shared-components/settings/setting-dropdown.svelte';
import { SlideshowLook, SlideshowNavigation, slideshowStore } from '../stores/slideshow.store';
import { SlideshowLook, SlideshowNavigation, SlideshowState, slideshowStore } from '../stores/slideshow.store';
const {
slideshowDelay,
@@ -23,11 +20,12 @@
slideshowLook,
slideshowTransition,
slideshowAutoplay,
slideshowState,
} = slideshowStore;
interface Props {
type Props = {
onClose: () => void;
}
};
let { onClose }: Props = $props();
@@ -62,52 +60,53 @@
}
};
const applyChanges = () => {
const onSubmit = () => {
$slideshowDelay = tempSlideshowDelay;
$showProgressBar = tempShowProgressBar;
$slideshowNavigation = tempSlideshowNavigation;
$slideshowLook = tempSlideshowLook;
$slideshowTransition = tempSlideshowTransition;
$slideshowAutoplay = tempSlideshowAutoplay;
$slideshowState = SlideshowState.PlaySlideshow;
onClose();
};
</script>
<Modal size="small" title={$t('slideshow_settings')} onClose={() => onClose()}>
<ModalBody>
<div class="flex flex-col gap-4 text-primary">
<SettingDropdown
title={$t('direction')}
options={Object.values(navigationOptions)}
selectedOption={navigationOptions[tempSlideshowNavigation]}
onToggle={(option) => {
tempSlideshowNavigation = handleToggle(option, navigationOptions) || tempSlideshowNavigation;
}}
/>
<SettingDropdown
title={$t('look')}
options={Object.values(lookOptions)}
selectedOption={lookOptions[tempSlideshowLook]}
onToggle={(option) => {
tempSlideshowLook = handleToggle(option, lookOptions) || tempSlideshowLook;
}}
/>
<SettingSwitch title={$t('autoplay_slideshow')} bind:checked={tempSlideshowAutoplay} />
<SettingSwitch title={$t('show_progress_bar')} bind:checked={tempShowProgressBar} />
<SettingSwitch title={$t('show_slideshow_transition')} bind:checked={tempSlideshowTransition} />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('duration')}
description={$t('admin.slideshow_duration_description')}
min={1}
bind:value={tempSlideshowDelay}
/>
</div>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button color="secondary" shape="round" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button fullWidth color="primary" shape="round" onclick={applyChanges}>{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>
<FormModal size="small" title={$t('slideshow_settings')} {onClose} {onSubmit}>
<div class="flex flex-col gap-4">
<SettingDropdown
title={$t('direction')}
options={Object.values(navigationOptions)}
selectedOption={navigationOptions[tempSlideshowNavigation]}
onToggle={(option) => {
tempSlideshowNavigation = handleToggle(option, navigationOptions) || tempSlideshowNavigation;
}}
/>
<SettingDropdown
title={$t('look')}
options={Object.values(lookOptions)}
selectedOption={lookOptions[tempSlideshowLook]}
onToggle={(option) => {
tempSlideshowLook = handleToggle(option, lookOptions) || tempSlideshowLook;
}}
/>
<Field label={$t('autoplay_slideshow')}>
<Switch bind:checked={tempSlideshowAutoplay} />
</Field>
<Field label={$t('show_progress_bar')}>
<Switch bind:checked={tempShowProgressBar} />
</Field>
<Field label={$t('show_slideshow_transition')}>
<Switch bind:checked={tempSlideshowTransition} />
</Field>
<Field label={$t('duration')}>
<NumberInput min={1} bind:value={tempSlideshowDelay} />
<HelperText>{$t('admin.slideshow_duration_description')}</HelperText>
</Field>
</div>
</FormModal>
+8 -1
View File
@@ -75,8 +75,15 @@ function zoneOptionForDate(zone: string, date: string) {
// Ignore milliseconds:
// - milliseconds are not relevant for TZ calculations
// - browsers strip insignificant .000 making string comparison with milliseconds more fragile.
//
// Also, some browsers emit `datetime-local` values without seconds when seconds are 00,
// e.g. `2024-01-01T00:00` instead of `2024-01-01T00:00:00.000`.
// In that case we must compare with minute precision (otherwise every zone looks "invalid").
const dateInTimezone = DateTime.fromISO(date, { zone });
const exists = date.replace(/\.\d+/, '') === dateInTimezone.toFormat("yyyy-MM-dd'T'HH:mm:ss");
const withoutMillis = date.replace(/\.\d+/, '');
const hasSeconds = /T\d{2}:\d{2}:\d{2}$/.test(withoutMillis);
const compareFormat = hasSeconds ? "yyyy-MM-dd'T'HH:mm:ss" : "yyyy-MM-dd'T'HH:mm";
const exists = withoutMillis === dateInTimezone.toFormat(compareFormat);
const valid = dateInTimezone.isValid && exists;
return {
value: zone,
-4
View File
@@ -1,4 +0,0 @@
export enum OnboardingRole {
SERVER = 'server',
USER = 'user',
}
-22
View File
@@ -1,22 +0,0 @@
export enum UploadState {
PENDING,
STARTED,
DONE,
ERROR,
DUPLICATED,
}
export type UploadAsset = {
id: string;
file: File;
assetId?: string;
isTrashed?: boolean;
albumId?: string;
progress?: number;
state?: UploadState;
startDate?: number;
eta?: number;
speed?: number;
error?: unknown;
message?: string;
};
+32 -1
View File
@@ -1,10 +1,41 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { downloadArchive } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { deleteAlbum, type AlbumResponseDto } from '@immich/sdk';
import { deleteAlbum, updateAlbumInfo, type AlbumResponseDto, type UpdateAlbumDto } from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
export const handleUpdateAlbum = async ({ id }: { id: string }, dto: UpdateAlbumDto) => {
const $t = await getFormatter();
try {
const response = await updateAlbumInfo({ id, updateAlbumDto: dto });
eventManager.emit('AlbumUpdate', response);
toastManager.custom({
component: ToastAction,
props: {
color: 'primary',
title: $t('success'),
description: $t('album_info_updated'),
button: {
text: $t('view_album'),
color: 'primary',
onClick() {
return goto(`${AppRoute.ALBUMS}/${id}`);
},
},
},
});
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_info'));
}
};
export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { prompt?: boolean; notify?: boolean }) => {
const $t = await getFormatter();
const { prompt = true, notify = true } = options ?? {};
+110
View File
@@ -0,0 +1,110 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte';
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
createApiKey,
deleteApiKey,
updateApiKey,
type ApiKeyCreateDto,
type ApiKeyResponseDto,
type ApiKeyUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getApiKeysActions = ($t: MessageFormatter) => {
const Create: ActionItem = {
title: $t('new_api_key'),
icon: mdiPlus,
onAction: () => modalManager.show(ApiKeyCreateModal, {}),
};
return { Create };
};
export const getApiKeyActions = ($t: MessageFormatter, apiKey: ApiKeyResponseDto) => {
const Update: ActionItem = {
title: $t('edit_key'),
icon: mdiPencilOutline,
onAction: () => modalManager.show(ApiKeyUpdateModal, { apiKey }),
};
const Delete: ActionItem = {
title: $t('delete_key'),
icon: mdiTrashCanOutline,
onAction: () => handleDeleteApiKey(apiKey),
};
return { Update, Delete };
};
export const handleCreateApiKey = async (dto: ApiKeyCreateDto) => {
const $t = await getFormatter();
try {
if (!dto.name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (dto.permissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
const { apiKey, secret } = await createApiKey({ apiKeyCreateDto: dto });
eventManager.emit('ApiKeyCreate', apiKey);
// no nested modal
void modalManager.show(ApiKeySecretModal, { secret });
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_create_api_key'));
}
};
export const handleUpdateApiKey = async (apiKey: { id: string }, dto: ApiKeyUpdateDto) => {
const $t = await getFormatter();
if (!dto.name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (dto.permissions && dto.permissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
try {
const response = await updateApiKey({ id: apiKey.id, apiKeyUpdateDto: dto });
eventManager.emit('ApiKeyUpdate', response);
toastManager.success($t('saved_api_key'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_save_api_key'));
}
};
export const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('delete_api_key_prompt') });
if (!confirmed) {
return;
}
try {
await deleteApiKey({ id: apiKey.id });
eventManager.emit('ApiKeyDelete', apiKey);
toastManager.success($t('removed_api_key', { values: { name: apiKey.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_api_key'));
}
};
+18 -1
View File
@@ -1,6 +1,23 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser } from '$lib/stores/user.store';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { copyAsset, deleteAssets } from '@immich/sdk';
import { AssetVisibility, copyAsset, deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { modalManager, type ActionItem } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
const Share: ActionItem = {
title: $t('share'),
icon: mdiShareVariantOutline,
$if: () => !!(get(authUser) && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
};
return { Share };
};
export const handleReplaceAsset = async (oldAssetId: string) => {
const [newAssetId] = await openFileUploadDialog({ multiple: false });
+22 -40
View File
@@ -5,8 +5,6 @@ import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPattern
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 { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
@@ -17,7 +15,9 @@ import {
runQueueCommandLegacy,
scanLibrary,
updateLibrary,
type CreateLibraryDto,
type LibraryResponseDto,
type UpdateLibraryDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
@@ -37,7 +37,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
title: $t('create_library'),
type: $t('command'),
icon: mdiPlusBoxOutline,
onAction: () => handleCreateLibrary(),
onAction: () => goto(AppRoute.ADMIN_LIBRARIES_NEW),
shortcuts: { shift: true, key: 'n' },
};
@@ -45,11 +45,11 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
};
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
const Rename: ActionItem = {
const Edit: ActionItem = {
icon: mdiPencilOutline,
type: $t('command'),
title: $t('rename'),
onAction: () => modalManager.show(LibraryRenameModal, { library }),
title: $t('edit'),
onAction: () => goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}/edit`),
shortcuts: { key: 'r' },
};
@@ -84,7 +84,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
shortcuts: { shift: true, key: 'r' },
};
return { Rename, Delete, AddFolder, AddExclusionPattern, Scan };
return { Edit, Delete, AddFolder, AddExclusionPattern, Scan };
};
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
@@ -149,46 +149,34 @@ const handleScanLibrary = async (library: LibraryResponseDto) => {
};
export const handleViewLibrary = async (library: LibraryResponseDto) => {
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
};
export const handleCreateLibrary = async () => {
export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
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 } }));
const library = await createLibrary({ createLibraryDto: dto });
eventManager.emit('LibraryCreate', library);
toastManager.success($t('admin.library_created', { values: { library: library.name } }));
return library;
} catch (error) {
handleError(error, $t('errors.unable_to_create_library'));
}
};
export const handleRenameLibrary = async (library: { id: string }, name?: string) => {
export const handleUpdateLibrary = async (library: LibraryResponseDto, dto: UpdateLibraryDto) => {
const $t = await getFormatter();
if (!name) {
return false;
}
try {
const updatedLibrary = await updateLibrary({
id: library.id,
updateLibraryDto: { name },
});
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: dto });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
const handleDeleteLibrary = async (library: LibraryResponseDto) => {
@@ -243,14 +231,14 @@ export const handleAddLibraryFolder = async (library: LibraryResponseDto, folder
return true;
};
export const handleEditLibraryFolder = async (library: LibraryResponseDto, oldFolder: string, newFolder: string) => {
export const handleEditLibraryFolder = async (library: LibraryResponseDto, oldValue: string, newValue: string) => {
const $t = await getFormatter();
if (oldFolder === newFolder) {
if (oldValue === newValue) {
return true;
}
const importPaths = library.importPaths.map((path) => (path === oldFolder ? newFolder : path));
const importPaths = library.importPaths.map((path) => (path === oldValue ? newValue : path));
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { importPaths } });
@@ -311,20 +299,14 @@ export const handleAddLibraryExclusionPattern = async (library: LibraryResponseD
return true;
};
export const handleEditExclusionPattern = async (
library: LibraryResponseDto,
oldExclusionPattern: string,
newExclusionPattern: string,
) => {
export const handleEditExclusionPattern = async (library: LibraryResponseDto, oldValue: string, newValue: string) => {
const $t = await getFormatter();
if (oldExclusionPattern === newExclusionPattern) {
if (oldValue === newValue) {
return true;
}
const exclusionPatterns = library.exclusionPatterns.map((pattern) =>
pattern === oldExclusionPattern ? newExclusionPattern : pattern,
);
const exclusionPatterns = library.exclusionPatterns.map((pattern) => (pattern === oldValue ? newValue : pattern));
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { exclusionPatterns } });
+1 -1
View File
@@ -241,7 +241,7 @@ export const asQueueItem = ($t: MessageFormatter, queue: { name: QueueName }): Q
},
[QueueName.Workflow]: {
icon: mdiStateMachine,
title: $t('workflow'),
title: $t('workflows'),
},
[QueueName.IntegrityCheck]: {
icon: '',
+7 -2
View File
@@ -9,6 +9,7 @@ import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
createSharedLink,
getSharedLinkById,
removeSharedLink,
removeSharedLinkAssets,
updateSharedLink,
@@ -24,7 +25,7 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin
const Edit: ActionItem = {
title: $t('edit_link'),
icon: mdiPencilOutline,
onAction: () => goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`),
onAction: () => goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}/edit`),
};
const Delete: ActionItem = {
@@ -58,7 +59,11 @@ export const handleCreateSharedLink = async (dto: SharedLinkCreateDto) => {
const $t = await getFormatter();
try {
const sharedLink = await createSharedLink({ sharedLinkCreateDto: dto });
let sharedLink = await createSharedLink({ sharedLinkCreateDto: dto });
if (dto.albumId) {
// fetch album details, for event
sharedLink = await getSharedLinkById({ id: sharedLink.id });
}
eventManager.emit('SharedLinkCreate', sharedLink);
@@ -96,7 +96,7 @@ export const handleDownloadConfig = (config: SystemConfigDto) => {
export const handleUploadConfig = () => {
const input = globalThis.document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'json');
input.setAttribute('accept', '.json');
input.setAttribute('style', 'display: none');
input.addEventListener('change', ({ target }) => {
@@ -109,8 +109,10 @@ export const handleUploadConfig = () => {
const newConfig = JSON.parse(text);
await handleSystemConfigSave(newConfig);
};
reader().catch((error) => console.error('Error handling JSON config upload', error));
globalThis.document.append(input);
reader()
.catch((error) => console.error('Error handling JSON config upload', error))
.finally(() => input.remove());
});
input.remove();
globalThis.document.body.append(input);
input.click();
};
+54
View File
@@ -0,0 +1,54 @@
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { emptyTrash, restoreTrash } from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiHistory } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getTrashActions = ($t: MessageFormatter) => {
const RestoreAll: ActionItem = {
title: $t('restore_all'),
icon: mdiHistory,
onAction: () => handleRestoreTrash(),
};
const Empty: ActionItem = {
title: $t('empty_trash'),
icon: mdiDeleteForeverOutline,
onAction: () => handleEmptyTrash(),
};
return { RestoreAll, Empty };
};
export const handleEmptyTrash = async () => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('empty_trash_confirmation') });
if (!confirmed) {
return;
}
try {
const { count } = await emptyTrash();
toastManager.success($t('assets_permanently_deleted_count', { values: { count } }));
} catch (error) {
handleError(error, $t('errors.unable_to_empty_trash'));
}
};
export const handleRestoreTrash = async () => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('assets_restore_confirmation') });
if (!confirmed) {
return;
}
try {
const { count } = await restoreTrash();
toastManager.success($t('assets_restored_count', { values: { count } }));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_trash'));
}
};
+4 -5
View File
@@ -1,10 +1,9 @@
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
import UserCreateModal from '$lib/modals/UserCreateModal.svelte';
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
import UserEditModal from '$lib/modals/UserEditModal.svelte';
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
import { user as authUser } from '$lib/stores/user.store';
import type { HeaderButtonActionItem } from '$lib/types';
@@ -39,7 +38,7 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
title: $t('create_user'),
type: $t('command'),
icon: mdiPlusBoxOutline,
onAction: () => modalManager.show(UserCreateModal, {}),
onAction: () => goto(AppRoute.ADMIN_USERS_NEW),
shortcuts: { shift: true, key: 'n' },
};
@@ -50,7 +49,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
const Update: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onAction: () => modalManager.show(UserEditModal, { user }),
onAction: () => goto(`${AppRoute.ADMIN_USERS}/${user.id}/edit`),
};
const Delete: ActionItem = {
@@ -103,7 +102,7 @@ export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
const response = await createUserAdmin({ userAdminCreateDto: dto });
eventManager.emit('UserAdminCreate', response);
toastManager.success();
return true;
return response;
} catch (error) {
handleError(error, $t('errors.unable_to_create_user'));
}
+451
View File
@@ -0,0 +1,451 @@
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
createWorkflow,
deleteWorkflow,
getAlbumInfo,
getPerson,
PluginTriggerType,
updateWorkflow,
type AlbumResponseDto,
type PersonResponseDto,
type PluginActionResponseDto,
type PluginContextType,
type PluginFilterResponseDto,
type PluginTriggerResponseDto,
type WorkflowActionItemDto,
type WorkflowFilterItemDto,
type WorkflowResponseDto,
type WorkflowUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export type PickerSubType = 'album-picker' | 'people-picker';
export type PickerMetadata = AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[];
export interface WorkflowPayload {
name: string;
description: string;
enabled: boolean;
triggerType: string;
filters: Record<string, unknown>[];
actions: Record<string, unknown>[];
}
/**
* Get filters that support the given context
*/
export const getFiltersByContext = (
availableFilters: PluginFilterResponseDto[],
context: PluginContextType,
): PluginFilterResponseDto[] => {
return availableFilters.filter((filter) => filter.supportedContexts.includes(context));
};
/**
* Get actions that support the given context
*/
export const getActionsByContext = (
availableActions: PluginActionResponseDto[],
context: PluginContextType,
): PluginActionResponseDto[] => {
return availableActions.filter((action) => action.supportedContexts.includes(context));
};
export const remapConfigsOnReorder = (
configs: Record<string, unknown>,
prefix: 'filter' | 'action',
fromIndex: number,
toIndex: number,
totalCount: number,
): Record<string, unknown> => {
const newConfigs: Record<string, unknown> = {};
// Create an array of configs in order
const configArray: unknown[] = [];
for (let i = 0; i < totalCount; i++) {
configArray.push(configs[`${prefix}_${i}`] ?? {});
}
// Move the item from fromIndex to toIndex
const [movedItem] = configArray.splice(fromIndex, 1);
configArray.splice(toIndex, 0, movedItem);
// Rebuild the configs object with new indices
for (let i = 0; i < configArray.length; i++) {
newConfigs[`${prefix}_${i}`] = configArray[i];
}
return newConfigs;
};
/**
* Remap configs when an item is removed
* Shifts all configs after the removed index down by one
*/
export const remapConfigsOnRemove = (
configs: Record<string, unknown>,
prefix: 'filter' | 'action',
removedIndex: number,
totalCount: number,
): Record<string, unknown> => {
const newConfigs: Record<string, unknown> = {};
let newIndex = 0;
for (let i = 0; i < totalCount; i++) {
if (i !== removedIndex) {
newConfigs[`${prefix}_${newIndex}`] = configs[`${prefix}_${i}`] ?? {};
newIndex++;
}
}
return newConfigs;
};
export const initializeConfigs = (
type: 'action' | 'filter',
workflow: WorkflowResponseDto,
): Record<string, unknown> => {
const configs: Record<string, unknown> = {};
if (workflow.filters && type == 'filter') {
for (const [index, workflowFilter] of workflow.filters.entries()) {
configs[`filter_${index}`] = workflowFilter.filterConfig ?? {};
}
}
if (workflow.actions && type == 'action') {
for (const [index, workflowAction] of workflow.actions.entries()) {
configs[`action_${index}`] = workflowAction.actionConfig ?? {};
}
}
return configs;
};
/**
* Build workflow payload from current state
* Uses index-based keys to support multiple filters/actions of the same type
*/
export const buildWorkflowPayload = (
name: string,
description: string,
enabled: boolean,
triggerType: string,
orderedFilters: PluginFilterResponseDto[],
orderedActions: PluginActionResponseDto[],
filterConfigs: Record<string, unknown>,
actionConfigs: Record<string, unknown>,
): WorkflowPayload => {
const filters = orderedFilters.map((filter, index) => ({
[filter.methodName]: filterConfigs[`filter_${index}`] ?? {},
}));
const actions = orderedActions.map((action, index) => ({
[action.methodName]: actionConfigs[`action_${index}`] ?? {},
}));
return {
name,
description,
enabled,
triggerType,
filters,
actions,
};
};
export const parseWorkflowJson = (
jsonString: string,
availableTriggers: PluginTriggerResponseDto[],
availableFilters: PluginFilterResponseDto[],
availableActions: PluginActionResponseDto[],
): {
success: boolean;
error?: string;
data?: {
name: string;
description: string;
enabled: boolean;
trigger?: PluginTriggerResponseDto;
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
filterConfigs: Record<string, unknown>;
actionConfigs: Record<string, unknown>;
};
} => {
try {
const parsed = JSON.parse(jsonString);
const trigger = availableTriggers.find((t) => t.type === parsed.triggerType);
const filters: PluginFilterResponseDto[] = [];
const filterConfigs: Record<string, unknown> = {};
if (Array.isArray(parsed.filters)) {
for (const [index, filterObj] of parsed.filters.entries()) {
const methodName = Object.keys(filterObj)[0];
const filter = availableFilters.find((f) => f.methodName === methodName);
if (filter) {
filters.push(filter);
filterConfigs[`filter_${index}`] = (filterObj as Record<string, unknown>)[methodName];
}
}
}
const actions: PluginActionResponseDto[] = [];
const actionConfigs: Record<string, unknown> = {};
if (Array.isArray(parsed.actions)) {
for (const [index, actionObj] of parsed.actions.entries()) {
const methodName = Object.keys(actionObj)[0];
const action = availableActions.find((a) => a.methodName === methodName);
if (action) {
actions.push(action);
actionConfigs[`action_${index}`] = (actionObj as Record<string, unknown>)[methodName];
}
}
}
return {
success: true,
data: {
name: parsed.name ?? '',
description: parsed.description ?? '',
enabled: parsed.enabled ?? false,
trigger,
filters,
actions,
filterConfigs,
actionConfigs,
},
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Invalid JSON',
};
}
};
export const hasWorkflowChanged = (
previousWorkflow: WorkflowResponseDto,
enabled: boolean,
name: string,
description: string,
triggerType: string,
orderedFilters: PluginFilterResponseDto[],
orderedActions: PluginActionResponseDto[],
filterConfigs: Record<string, unknown>,
actionConfigs: Record<string, unknown>,
): boolean => {
if (enabled !== previousWorkflow.enabled) {
return true;
}
if (name !== (previousWorkflow.name ?? '') || description !== (previousWorkflow.description ?? '')) {
return true;
}
if (triggerType !== previousWorkflow.triggerType) {
return true;
}
const previousFilterIds = previousWorkflow.filters?.map((f) => f.pluginFilterId) ?? [];
const currentFilterIds = orderedFilters.map((f) => f.id);
if (JSON.stringify(previousFilterIds) !== JSON.stringify(currentFilterIds)) {
return true;
}
const previousActionIds = previousWorkflow.actions?.map((a) => a.pluginActionId) ?? [];
const currentActionIds = orderedActions.map((a) => a.id);
if (JSON.stringify(previousActionIds) !== JSON.stringify(currentActionIds)) {
return true;
}
const previousFilterConfigs: Record<string, unknown> = {};
for (const [index, wf] of (previousWorkflow.filters ?? []).entries()) {
previousFilterConfigs[`filter_${index}`] = wf.filterConfig ?? {};
}
if (JSON.stringify(previousFilterConfigs) !== JSON.stringify(filterConfigs)) {
return true;
}
const previousActionConfigs: Record<string, unknown> = {};
for (const [index, wa] of (previousWorkflow.actions ?? []).entries()) {
previousActionConfigs[`action_${index}`] = wa.actionConfig ?? {};
}
if (JSON.stringify(previousActionConfigs) !== JSON.stringify(actionConfigs)) {
return true;
}
return false;
};
export const handleUpdateWorkflow = async (
workflowId: string,
name: string,
description: string,
enabled: boolean,
triggerType: PluginTriggerType,
orderedFilters: PluginFilterResponseDto[],
orderedActions: PluginActionResponseDto[],
filterConfigs: Record<string, unknown>,
actionConfigs: Record<string, unknown>,
): Promise<WorkflowResponseDto> => {
const filters = orderedFilters.map((filter, index) => ({
pluginFilterId: filter.id,
filterConfig: filterConfigs[`filter_${index}`] ?? {},
})) as WorkflowFilterItemDto[];
const actions = orderedActions.map((action, index) => ({
pluginActionId: action.id,
actionConfig: actionConfigs[`action_${index}`] ?? {},
})) as WorkflowActionItemDto[];
const updateDto: WorkflowUpdateDto = {
name,
description,
enabled,
filters,
actions,
triggerType,
};
return updateWorkflow({ id: workflowId, workflowUpdateDto: updateDto });
};
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
const ToggleEnabled: ActionItem = {
title: workflow.enabled ? $t('disable') : $t('enable'),
icon: workflow.enabled ? mdiPause : mdiPlay,
color: workflow.enabled ? 'danger' : 'primary',
onAction: async () => {
await handleToggleWorkflowEnabled(workflow);
},
};
const Edit: ActionItem = {
title: $t('edit'),
icon: mdiPencil,
onAction: () => handleNavigateToWorkflow(workflow),
};
const Delete: ActionItem = {
title: $t('delete'),
icon: mdiDelete,
color: 'danger',
onAction: async () => {
await handleDeleteWorkflow(workflow);
},
};
return { ToggleEnabled, Edit, Delete };
};
export const getWorkflowShowSchemaAction = (
$t: MessageFormatter,
isExpanded: boolean,
onToggle: () => void,
): ActionItem => ({
title: isExpanded ? $t('hide_schema') : $t('show_schema'),
icon: mdiCodeJson,
onAction: onToggle,
});
export const handleCreateWorkflow = async (): Promise<WorkflowResponseDto | undefined> => {
const $t = await getFormatter();
try {
const workflow = await createWorkflow({
workflowCreateDto: {
name: $t('untitled_workflow'),
triggerType: PluginTriggerType.AssetCreate,
filters: [],
actions: [],
enabled: false,
},
});
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
return workflow;
} catch (error) {
handleError(error, $t('errors.unable_to_create'));
}
};
export const handleToggleWorkflowEnabled = async (
workflow: WorkflowResponseDto,
): Promise<WorkflowResponseDto | undefined> => {
const $t = await getFormatter();
try {
const updated = await updateWorkflow({
id: workflow.id,
workflowUpdateDto: { enabled: !workflow.enabled },
});
eventManager.emit('WorkflowUpdate', updated);
toastManager.success($t('workflow_updated'));
return updated;
} catch (error) {
handleError(error, $t('errors.unable_to_update_workflow'));
}
};
export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promise<boolean> => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({
prompt: $t('workflow_delete_prompt'),
confirmColor: 'danger',
});
if (!confirmed) {
return false;
}
try {
await deleteWorkflow({ id: workflow.id });
eventManager.emit('WorkflowDelete', workflow);
toastManager.success($t('workflow_deleted'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_workflow'));
return false;
}
};
export const handleNavigateToWorkflow = async (workflow: WorkflowResponseDto): Promise<void> => {
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
};
export const fetchPickerMetadata = async (
value: string | string[] | undefined,
subType: PickerSubType,
): Promise<PickerMetadata | undefined> => {
if (!value) {
return undefined;
}
const isAlbum = subType === 'album-picker';
try {
if (Array.isArray(value) && value.length > 0) {
// Multiple selection
return isAlbum
? await Promise.all(value.map((id) => getAlbumInfo({ id })))
: await Promise.all(value.map((id) => getPerson({ id })));
} else if (typeof value === 'string' && value) {
// Single selection
return isAlbum ? await getAlbumInfo({ id: value }) : await getPerson({ id: value });
}
} catch (error) {
console.error(`Failed to fetch picker metadata:`, error);
}
return undefined;
};
-2
View File
@@ -59,8 +59,6 @@ export const mapSettings = persistedObject<MapSettings>('map-settings', defaultM
export const videoViewerVolume = persisted<number>('video-viewer-volume', 1, {});
export const videoViewerMuted = persisted<boolean>('video-viewer-muted', false, {});
export const isShowDetail = persisted<boolean>('info-opened', false, {});
export interface AlbumViewSettings {
view: string;
filter: string;
+1 -1
View File
@@ -1,5 +1,5 @@
import { UploadState, type UploadAsset } from '$lib/types';
import { derived, writable } from 'svelte/store';
import { UploadState, type UploadAsset } from '../models/upload-asset';
function createUploadStore() {
const uploadAssets = writable<Array<UploadAsset>>([]);
+28
View File
@@ -12,3 +12,31 @@ export interface ReleaseEvent {
export type QueueSnapshot = { timestamp: number; snapshot?: QueueResponseDto[] };
export type HeaderButtonActionItem = ActionItem & { data?: { title?: string } };
export enum UploadState {
PENDING,
STARTED,
DONE,
ERROR,
DUPLICATED,
}
export type UploadAsset = {
id: string;
file: File;
assetId?: string;
isTrashed?: boolean;
albumId?: string;
progress?: number;
state?: UploadState;
startDate?: number;
eta?: number;
speed?: number;
error?: unknown;
message?: string;
};
export enum OnboardingRole {
SERVER = 'server',
USER = 'user',
}
+1 -1
View File
@@ -162,7 +162,7 @@ export const getQueueName = derived(t, ($t) => {
[QueueName.Notifications]: $t('notifications'),
[QueueName.BackupDatabase]: $t('admin.backup_database'),
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
[QueueName.Workflow]: $t('workflow'),
[QueueName.Workflow]: $t('workflows'),
[QueueName.IntegrityCheck]: $t('integrity_checks'),
};
+1 -1
View File
@@ -353,7 +353,7 @@ const supportedImageMimeTypes = new Set([
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
if (isSafari) {
supportedImageMimeTypes.add('image/heic').add('image/heif');
supportedImageMimeTypes.add('image/heic').add('image/heif').add('image/jxl');
}
/**

Some files were not shown because too many files have changed in this diff Show More