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
+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>
@@ -0,0 +1,22 @@
<script lang="ts">
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import { AppRoute } from '$lib/constants';
import { NavbarItem } from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull, mdiWrench } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
<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_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} />
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
</div>
<div class="mb-2 me-4">
<BottomInfo />
</div>
</div>
@@ -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>