Compare commits

..

3 Commits

Author SHA1 Message Date
bwees faaeaace1d fix: memory viewer bar 2026-05-22 16:50:18 -05:00
bwees 2837de2029 chore: ci fix 2026-05-22 14:01:11 -05:00
bwees eb27635f22 refactor: use ControlBar UI Library component 2026-05-22 11:35:16 -05:00
27 changed files with 459 additions and 963 deletions
@@ -536,7 +536,7 @@ test.describe('Timeline', () => {
force: false, force: false,
ids: [assetToTrash.id], ids: [assetToTrash.id],
}); });
await page.locator('#asset-selection-app-bar').getByLabel('Close').click(); await page.keyboard.press('Escape');
await page.getByText('Trash', { exact: true }).click(); await page.getByText('Trash', { exact: true }).click();
await timelineUtils.waitForTimelineLoad(page); await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToTrash.id); await thumbnailUtils.expectInViewport(page, assetToTrash.id);
@@ -676,7 +676,7 @@ test.describe('Timeline', () => {
ids: [assetToArchive.id], ids: [assetToArchive.id],
}); });
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id); await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click(); await page.keyboard.press('Escape');
await page.getByRole('link').getByText('Archive').click(); await page.getByRole('link').getByText('Archive').click();
await timelineUtils.waitForTimelineLoad(page); await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToArchive.id); await thumbnailUtils.expectInViewport(page, assetToArchive.id);
@@ -823,7 +823,7 @@ test.describe('Timeline', () => {
}); });
// ensure thumbnail still exists and has favorite icon // ensure thumbnail still exists and has favorite icon
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id); await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click(); await page.keyboard.press('Escape');
await page.getByRole('link').getByText('Favorites').click(); await page.getByRole('link').getByText('Favorites').click();
await timelineUtils.waitForTimelineLoad(page); await timelineUtils.waitForTimelineLoad(page);
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt); await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
-3
View File
@@ -976,7 +976,6 @@
"downloading_asset_filename": "Downloading asset {filename}", "downloading_asset_filename": "Downloading asset {filename}",
"downloading_from_icloud": "Downloading from iCloud", "downloading_from_icloud": "Downloading from iCloud",
"downloading_media": "Downloading media", "downloading_media": "Downloading media",
"drag_to_reorder": "Drag to reorder",
"drop_files_to_upload": "Drop files anywhere to upload", "drop_files_to_upload": "Drop files anywhere to upload",
"duplicates": "Duplicates", "duplicates": "Duplicates",
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.", "duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
@@ -2255,7 +2254,6 @@
"step_delete_confirm": "Are you sure you want to delete this step?", "step_delete_confirm": "Are you sure you want to delete this step?",
"step_details": "Step details", "step_details": "Step details",
"steps": "Steps", "steps": "Steps",
"steps_count": "{count, plural, one {# step} other {# steps}}",
"stop_casting": "Stop casting", "stop_casting": "Stop casting",
"stop_motion_photo": "Stop Motion Photo", "stop_motion_photo": "Stop Motion Photo",
"stop_photo_sharing": "Stop sharing your photos?", "stop_photo_sharing": "Stop sharing your photos?",
@@ -2478,7 +2476,6 @@
"week": "Week", "week": "Week",
"welcome": "Welcome", "welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich", "welcome_to_immich": "Welcome to Immich",
"when": "When",
"width": "Width", "width": "Width",
"wifi_name": "Wi-Fi Name", "wifi_name": "Wi-Fi Name",
"workflow": "Workflow", "workflow": "Workflow",
+5 -5
View File
@@ -758,8 +758,8 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../packages/sdk version: link:../packages/sdk
'@immich/ui': '@immich/ui':
specifier: ^0.77.0 specifier: ^0.79.0
version: 0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4)) version: 0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
'@mapbox/mapbox-gl-rtl-text': '@mapbox/mapbox-gl-rtl-text':
specifier: 0.4.0 specifier: 0.4.0
version: 0.4.0 version: 0.4.0
@@ -3204,8 +3204,8 @@ packages:
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==} resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
hasBin: true hasBin: true
'@immich/ui@0.77.3': '@immich/ui@0.79.0':
resolution: {integrity: sha512-h3jrYE3JyGDOwXF7A4tVUHenP0L7TsDV22FyFInBTdwlWjjXoknwE1HWeTvvLxLeMuO5SHCZ9Q2D2al84xVjNw==} resolution: {integrity: sha512-UEQZrP8CTc4Kth1xCV8/6Xmk1P51GQKISC7vKqcrM0BO0fxCaNwJK8Ocn6R8baVqH52JYfPb1yQR9bweBnCjXw==}
peerDependencies: peerDependencies:
'@sveltejs/kit': ^2.13.0 '@sveltejs/kit': ^2.13.0
svelte: ^5.0.0 svelte: ^5.0.0
@@ -15879,7 +15879,7 @@ snapshots:
pg-connection-string: 2.13.0 pg-connection-string: 2.13.0
postgres: 3.4.9 postgres: 3.4.9
'@immich/ui@0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))': '@immich/ui@0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
dependencies: dependencies:
'@internationalized/date': 3.12.1 '@internationalized/date': 3.12.1
'@mdi/js': 7.4.47 '@mdi/js': 7.4.47
+2 -2
View File
@@ -88,8 +88,8 @@ ENV NODE_ENV=production \
COPY --from=server /output/server-pruned ./server COPY --from=server /output/server-pruned ./server
COPY --from=web /usr/src/app/web/build /build/www COPY --from=web /usr/src/app/web/build /build/www
COPY --from=cli /output/cli-pruned ./cli COPY --from=cli /output/cli-pruned ./cli
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-core-plugin/dist
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/manifest.json COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-core-plugin/manifest.json
RUN ln -s ../../cli/bin/immich server/bin/immich RUN ln -s ../../cli/bin/immich server/bin/immich
COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE COPY LICENSE /LICENSE
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db);
}
+1 -1
View File
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0", "@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3", "@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*", "@immich/sdk": "workspace:*",
"@immich/ui": "^0.77.0", "@immich/ui": "^0.79.0",
"@mapbox/mapbox-gl-rtl-text": "0.4.0", "@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
@@ -103,7 +103,7 @@
{/if} {/if}
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
<ControlAppBar showBackButton={false}> <ControlAppBar>
{#snippet leading()} {#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/"> <a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" /> <Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
@@ -91,7 +91,7 @@
</div> </div>
</main> </main>
<header> <header>
<ControlAppBar showBackButton={false}> <ControlAppBar>
{#snippet leading()} {#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/"> <a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant="inline" /> <Logo variant="inline" />
@@ -18,7 +18,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk'; import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
import { IconButton, Logo, toastManager } from '@immich/ui'; import { IconButton, Logo, toastManager } from '@immich/ui';
import { mdiArrowLeft, mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js'; import { mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ControlAppBar from '../shared-components/ControlAppBar.svelte'; import ControlAppBar from '../shared-components/ControlAppBar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte'; import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
@@ -97,7 +97,7 @@
{/if} {/if}
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
<ControlAppBar onClose={() => goto(Route.photos())} backIcon={mdiArrowLeft} showBackButton={false}> <ControlAppBar>
{#snippet leading()} {#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/"> <a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" /> <Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
@@ -1,97 +1,49 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { ControlBar, ControlBarContent, ControlBarHeader, ControlBarOverflow, ControlBarTitle } from '@immich/ui';
import { IconButton } from '@immich/ui';
import { mdiClose } from '@mdi/js'; import { mdiClose } from '@mdi/js';
import { onDestroy, onMount, type Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n'; import type { ClassValue } from 'svelte/elements';
import { fly } from 'svelte/transition';
interface Props { interface Props {
showBackButton?: boolean;
backIcon?: string; backIcon?: string;
tailwindClasses?: string; class?: ClassValue;
forceDark?: boolean;
multiRow?: boolean;
onClose?: () => void; onClose?: () => void;
title?: Snippet | string;
leading?: Snippet; leading?: Snippet;
children?: Snippet; children?: Snippet;
trailing?: Snippet; trailing?: Snippet;
} }
let { let { backIcon = mdiClose, class: className = '', onClose, title, leading, children, trailing }: Props = $props();
showBackButton = true,
backIcon = mdiClose,
tailwindClasses = '',
forceDark = false,
multiRow = false,
onClose = () => {},
leading,
children,
trailing,
}: Props = $props();
let appBarBorder = $state('border border-subtle');
const onScroll = () => {
if (window.scrollY > 80) {
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
if (forceDark) {
appBarBorder = 'border border-gray-600';
}
} else {
appBarBorder = 'border border-subtle';
}
};
onMount(() => {
if (browser) {
document.addEventListener('scroll', onScroll, { passive: true });
}
});
onDestroy(() => {
if (browser) {
document.removeEventListener('scroll', onScroll);
}
});
</script> </script>
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full bg-transparent"> <div class={['absolute top-0 w-full bg-transparent p-2']}>
<nav <ControlBar closeIcon={backIcon} {onClose} shape="round" class={className}>
id="asset-selection-app-bar" {#if title || leading}
class={[ <ControlBarHeader>
'grid', {#if title}
multiRow && 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]', <ControlBarTitle>
!multiRow && 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]', {#if typeof title === 'string'}
'justify-between lg:grid-cols-[25%_50%_25%]', {title}
appBarBorder, {:else}
'm-2 place-items-center rounded-full p-2 transition-all max-md:p-0', {@render title()}
tailwindClasses, {/if}
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-light-50 dark:bg-immich-dark-gray', </ControlBarTitle>
]} {/if}
> {@render leading?.()}
<div class="flex place-items-center justify-self-start sm:gap-6 dark:text-immich-dark-fg {forceDark ? 'dark' : ''}"> </ControlBarHeader>
{#if showBackButton} {/if}
<IconButton
aria-label={$t('close')}
onclick={onClose}
color="secondary"
shape="round"
variant="ghost"
icon={backIcon}
size="large"
/>
{/if}
{@render leading?.()}
</div>
<div class="w-full"> {#if children}
{@render children?.()} <ControlBarContent>
</div> {@render children()}
</ControlBarContent>
{/if}
<div class="me-4 flex place-items-center gap-1 justify-self-end max-[350px]:me-0 max-[350px]:gap-0"> {#if trailing}
{@render trailing?.()} <ControlBarOverflow>
</div> {@render trailing()}
</nav> </ControlBarOverflow>
{/if}
</ControlBar>
</div> </div>
@@ -7,19 +7,18 @@
type Props = { type Props = {
children?: Snippet; children?: Snippet;
forceDark?: boolean;
}; };
let { children, forceDark }: Props = $props(); let { children }: Props = $props();
const onClose = () => assetMultiSelectManager.clear(); const onClose = () => assetMultiSelectManager.clear();
const assets = $derived(assetMultiSelectManager.assets); const assets = $derived(assetMultiSelectManager.assets);
</script> </script>
<ControlAppBar {onClose} {forceDark} backIcon={mdiClose} tailwindClasses="bg-white shadow-md"> <ControlAppBar {onClose} backIcon={mdiClose}>
{#snippet leading()} {#snippet leading()}
<div class="font-medium {forceDark ? 'text-immich-dark-primary' : 'text-primary'}"> <div class="font-medium text-primary">
<p class="block sm:hidden">{assets.length}</p> <p class="block sm:hidden">{assets.length}</p>
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p> <p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
</div> </div>
+1 -1
View File
@@ -12,7 +12,7 @@
const { trigger, selectedKey, onClose }: Props = $props(); const { trigger, selectedKey, onClose }: Props = $props();
</script> </script>
<BasicModal title={$t('add_step')} {onClose} size="medium"> <BasicModal title={$t('add_step')} {onClose}>
{#await searchPluginMethods({ trigger })} {#await searchPluginMethods({ trigger })}
<div class="flex w-full place-content-center place-items-center"> <div class="flex w-full place-content-center place-items-center">
<LoadingSpinner /> <LoadingSpinner />
@@ -38,7 +38,7 @@
</script> </script>
{#if method} {#if method}
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="medium"> <FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="small">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="grow text-start"> <div class="grow text-start">
<Text fontWeight="medium">{method.title}</Text> <Text fontWeight="medium">{method.title}</Text>
@@ -35,7 +35,7 @@
</script> </script>
{#if method} {#if method}
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="medium"> <FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="small">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="grow text-start"> <div class="grow text-start">
<Text fontWeight="medium">{method.title}</Text> <Text fontWeight="medium">{method.title}</Text>
@@ -17,7 +17,7 @@
const onSubmit = () => onClose(selected); const onSubmit = () => onClose(selected);
</script> </script>
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="medium"> <FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each pluginManager.triggers as item (item.trigger)} {#each pluginManager.triggers as item (item.trigger)}
<ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}> <ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}>
@@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import { goto, invalidate, onNavigate } from '$app/navigation'; import { goto, invalidate, onNavigate } from '$app/navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory'; import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import AlbumDescription from './AlbumDescription.svelte';
import AlbumMap from '$lib/components/album-page/AlbumMap.svelte'; import AlbumMap from '$lib/components/album-page/AlbumMap.svelte';
import AlbumSummary from '$lib/components/album-page/AlbumSummary.svelte'; import AlbumSummary from '$lib/components/album-page/AlbumSummary.svelte';
import AlbumTitle from './AlbumTitle.svelte';
import ActivityStatus from '$lib/components/asset-viewer/ActivityStatus.svelte'; import ActivityStatus from '$lib/components/asset-viewer/ActivityStatus.svelte';
import ActivityViewer from '$lib/components/asset-viewer/ActivityViewer.svelte'; import ActivityViewer from '$lib/components/asset-viewer/ActivityViewer.svelte';
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte'; import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
@@ -78,6 +76,8 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import type { PageData } from './$types'; import type { PageData } from './$types';
import AlbumDescription from './AlbumDescription.svelte';
import AlbumTitle from './AlbumTitle.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@@ -499,7 +499,7 @@
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
{#if viewMode === AlbumPageViewMode.VIEW} {#if viewMode === AlbumPageViewMode.VIEW}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}> <ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
{#snippet trailing()} {#snippet trailing()}
<ActionButton action={Cast} /> <ActionButton action={Cast} />
@@ -2,11 +2,8 @@
import { afterNavigate, goto } from '$app/navigation'; import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import MemoryPhotoViewer from './MemoryPhotoViewer.svelte';
import MemoryVideoViewer from './MemoryVideoViewer.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import ControlAppBar from '$lib/components/shared-components/ControlAppBar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
@@ -37,6 +34,7 @@
mdiChevronLeft, mdiChevronLeft,
mdiChevronRight, mdiChevronRight,
mdiChevronUp, mdiChevronUp,
mdiClose,
mdiDotsVertical, mdiDotsVertical,
mdiHeart, mdiHeart,
mdiHeartOutline, mdiHeartOutline,
@@ -54,6 +52,8 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { Attachment } from 'svelte/attachments'; import type { Attachment } from 'svelte/attachments';
import { Tween } from 'svelte/motion'; import { Tween } from 'svelte/motion';
import MemoryPhotoViewer from './MemoryPhotoViewer.svelte';
import MemoryVideoViewer from './MemoryVideoViewer.svelte';
let memoryGallery: HTMLElement | undefined = $state(); let memoryGallery: HTMLElement | undefined = $state();
let memoryWrapper: HTMLElement | undefined = $state(); let memoryWrapper: HTMLElement | undefined = $state();
@@ -327,8 +327,8 @@
/> />
{#if assetMultiSelectManager.selectionActive} {#if assetMultiSelectManager.selectionActive}
<div class="dark sticky top-0 z-1"> <div class="sticky top-0 z-1 dark">
<AssetSelectControlBar forceDark> <AssetSelectControlBar>
{@const Actions = getAssetBulkActions($t)} {@const Actions = getAssetBulkActions($t)}
<CreateSharedLink /> <CreateSharedLink />
<IconButton <IconButton
@@ -365,22 +365,33 @@
<section <section
id="memory-viewer" id="memory-viewer"
class="w-full bg-immich-dark-gray" class="dark w-full text-white bg-immich-dark-gray"
bind:this={memoryWrapper} bind:this={memoryWrapper}
bind:clientHeight={viewport.height} bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width} bind:clientWidth={viewport.width}
> >
{#if current} {#if current}
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow> <div
{#snippet leading()} class="max-md:h-auto max-md:flex-col dark grid grid-cols-[100%] md:grid-cols-[25%_50%_25%] px-2 py-2 md:px-4 md:py-4"
{#if current} >
{#if current}
<div class="flex gap-2 md:gap-6 items-center">
<IconButton
shape="round"
variant="ghost"
color="secondary"
icon={mdiClose}
aria-label={$t('close')}
size="large"
onclick={() => goto(Route.photos())}
/>
<p class="text-lg"> <p class="text-lg">
{$memoryLaneTitle(current.memory)} {$memoryLaneTitle(current.memory)}
</p> </p>
{/if} </div>
{/snippet} {/if}
<div class="dark flex place-content-center place-items-center gap-2"> <div class="dark flex w-full place-content-center place-items-center gap-2">
<IconButton <IconButton
shape="round" shape="round"
variant="ghost" variant="ghost"
@@ -438,7 +449,7 @@
</media-mute-button> </media-mute-button>
{/if} {/if}
</div> </div>
</ControlAppBar> </div>
{#if galleryInView} {#if galleryInView}
<div <div
@@ -462,7 +473,7 @@
</div> </div>
{/if} {/if}
<!-- Viewer --> <!-- Viewer -->
<section class="overflow-hidden pt-32 md:pt-20" bind:clientHeight={viewerHeight}> <section class="overflow-hidden pt-6 md:pt-0" bind:clientHeight={viewerHeight}>
<div <div
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] w-[300%] items-center justify-center gap-10 overflow-hidden md:h-[calc(100vh-180px)]" class="ms-[-100%] box-border flex h-[calc(100vh-224px)] w-[300%] items-center justify-center gap-10 overflow-hidden md:h-[calc(100vh-180px)]"
> >
@@ -47,7 +47,7 @@
<DownloadAction /> <DownloadAction />
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}> <ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}>
{#snippet leading()} {#snippet leading()}
<p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg"> <p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg">
{$t('partner_list_user_photos', { values: { user: data.partner.name } })} {$t('partner_list_user_photos', { values: { user: data.partner.name } })}
@@ -5,9 +5,6 @@
import { listNavigation } from '$lib/actions/list-navigation'; import { listNavigation } from '$lib/actions/list-navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory'; import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte'; import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
import EditNameInput from './EditNameInput.svelte';
import MergeFaceSelector from './MergeFaceSelector.svelte';
import UnmergeFaceSelector from './UnmergeFaceSelector.svelte';
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
@@ -54,6 +51,9 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import EditNameInput from './EditNameInput.svelte';
import MergeFaceSelector from './MergeFaceSelector.svelte';
import UnmergeFaceSelector from './UnmergeFaceSelector.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@@ -493,7 +493,7 @@
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
{#if viewMode === PersonPageViewMode.VIEW_ASSETS} {#if viewMode === PersonPageViewMode.VIEW_ASSETS}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}> <ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
{#snippet trailing()} {#snippet trailing()}
<ContextMenuButton <ContextMenuButton
items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]} items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]}
@@ -387,8 +387,7 @@
{:else} {:else}
<div class="fixed inset-s-0 top-0 z-2 w-full"> <div class="fixed inset-s-0 top-0 z-2 w-full">
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}> <ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="absolute bg-light"></div> <div class="mx-auto w-full max-w-2xl pe-2">
<div class="w-full flex-1 ps-4">
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} /> <SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
</div> </div>
</ControlAppBar> </ControlAppBar>
+88 -51
View File
@@ -4,24 +4,26 @@
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte'; import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { getWorkflowActions, getWorkflowsActions, getWorkflowShowSchemaAction } from '$lib/services/workflow.service'; import { getWorkflowActions, getWorkflowsActions, getWorkflowShowSchemaAction } from '$lib/services/workflow.service';
import { getWorkflowForShare, type WorkflowResponseDto } from '@immich/sdk'; import { getWorkflowForShare, type WorkflowResponseDto } from '@immich/sdk';
import { import {
Badge,
Button, Button,
Card, Card,
CardBody,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
CodeBlock, CodeBlock,
Container, Container,
Icon,
IconButton, IconButton,
MenuItemType, MenuItemType,
menuManager, menuManager,
Text,
VStack,
} from '@immich/ui'; } from '@immich/ui';
import { mdiClose, mdiDotsVertical, mdiFlashOutline } from '@mdi/js'; import { mdiClose, mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -44,6 +46,20 @@
} }
}; };
const getTriggerLabel = (triggerType: string) => {
const labels: Record<string, string> = {
AssetCreate: $t('asset_created'),
PersonRecognized: $t('person_recognized'),
};
return labels[triggerType] || triggerType;
};
const formatTimestamp = (createdAt: string) =>
new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(createdAt));
const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => { const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => {
const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow); const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow);
void menuManager.show({ void menuManager.show({
@@ -76,6 +92,12 @@
<OnEvents {onWorkflowCreate} {onWorkflowUpdate} {onWorkflowDelete} /> <OnEvents {onWorkflowCreate} {onWorkflowUpdate} {onWorkflowDelete} />
{#snippet chipItem(title: string)}
<span class="rounded-xl border border-gray-200/80 bg-light px-3 py-1.5 text-sm dark:border-gray-600">
<span class="font-medium text-dark">{title}</span>
</span>
{/snippet}
<UserPageLayout title={data.meta.title} actions={[Create]} scrollbar={false}> <UserPageLayout title={data.meta.title} actions={[Create]} scrollbar={false}>
<section class="flex place-content-center sm:mx-4"> <section class="flex place-content-center sm:mx-4">
<Container center size="large" class="pb-28"> <Container center size="large" class="pb-28">
@@ -89,77 +111,92 @@
class="mx-auto mt-10" class="mx-auto mt-10"
/> />
{:else} {:else}
<div class="my-6 flex flex-col gap-3"> <div class="my-6 grid gap-6">
{#each workflows as workflow (workflow.id)} {#each workflows as workflow (workflow.id)}
<Card class="group shadow-none transition-colors hover:border-primary"> <Card class="border border-light-200">
<CardHeader> <CardHeader
<a class={`flex flex-row gap-4 px-8 py-6 sm:items-center sm:gap-6 ${
href={Route.viewWorkflow({ id: workflow.id })} workflow.enabled
class="flex items-center gap-4" ? 'bg-linear-to-r from-green-50 to-white dark:from-green-800/50 dark:to-green-950/45'
class:opacity-55={!workflow.enabled} : 'bg-neutral-50 dark:bg-neutral-900'
> }`}
<div >
class={`flex size-11 shrink-0 items-center justify-center rounded-xl ${ <div class="flex-1">
workflow.enabled <div class="flex items-center gap-3">
? 'bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary' <span class="rounded-full {workflow.enabled ? 'size-3 bg-success' : 'size-3 rounded-full bg-muted'}"
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500' ></span>
}`} <CardTitle>{workflow.name || $t('workflow')}</CardTitle>
>
<Icon icon={mdiFlashOutline} size="20" />
</div> </div>
{#if workflow.description}
<CardDescription class="mt-1 text-sm">{workflow.description}</CardDescription>
{/if}
</div>
<div class="min-w-0 flex-1"> <div class="flex items-center gap-4">
<div class="flex items-center gap-2"> <div class="hidden text-right sm:block">
<CardTitle class="truncate font-semibold text-dark group-hover:text-primary"> <Text size="tiny">{$t('created_at')}</Text>
{workflow.name || $t('workflow')} <Text size="small" fontWeight="medium">
</CardTitle> {formatTimestamp(workflow.createdAt)}
</Text>
{#if !workflow.enabled}
<Badge size="small" color="secondary">
{$t('disabled')}
</Badge>
{/if}
</div>
{#if workflow.description}
<CardDescription class="mt-0.5 truncate">
{workflow.description}
</CardDescription>
{/if}
</div> </div>
<IconButton <IconButton
shape="round" shape="round"
variant="ghost" variant="ghost"
color="secondary" color="secondary"
icon={mdiDotsVertical} icon={mdiDotsVertical}
aria-label={$t('menu')} aria-label={$t('menu')}
onclick={(event: MouseEvent) => { onclick={(event: MouseEvent) => showWorkflowMenu(event, workflow)}
event.preventDefault();
event.stopPropagation();
showWorkflowMenu(event, workflow);
}}
/> />
</a> </div>
</CardHeader>
<CardBody class="space-y-6">
<div class="grid gap-4 md:grid-cols-3">
<!-- Trigger Section -->
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
<div class="mb-3">
<Text size="tiny" color="muted" fontWeight="medium">{$t('trigger')}</Text>
</div>
{@render chipItem(getTriggerLabel(workflow.trigger))}
</div>
<!-- Actions Section -->
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
<div class="mb-3">
<Text size="tiny" color="muted" fontWeight="medium">{$t('steps')}</Text>
</div>
<div>
{#if workflow.steps.length === 0}
<span class="text-sm text-light-600">
{$t('no_steps')}
</span>
{:else}
<div class="flex flex-wrap gap-2">
{#each workflow.steps as step, i (i)}
{@render chipItem(pluginManager.getMethodLabel(step.method))}
{/each}
</div>
{/if}
</div>
</div>
</div>
{#if expandedIds.has(workflow.id)} {#if expandedIds.has(workflow.id)}
{#await getWorkflowForShare({ id: workflow.id }) then result} {#await getWorkflowForShare({ id: workflow.id }) then result}
<div class="border-t border-gray-200 p-4 dark:border-gray-800"> <VStack gap={2} class="w-full rounded-2xl border border-light-200 bg-light-50 p-4">
<CodeBlock code={JSON.stringify(result, null, 2)} lineNumbers /> <CodeBlock code={JSON.stringify(result, null, 2)} lineNumbers />
<Button <Button
class="mt-2"
leadingIcon={mdiClose} leadingIcon={mdiClose}
fullWidth fullWidth
variant="ghost" variant="ghost"
color="secondary" color="secondary"
onclick={() => toggleExpanded(workflow.id)} onclick={() => toggleExpanded(workflow.id)}>{$t('close')}</Button
> >
{$t('close')} </VStack>
</Button>
</div>
{/await} {/await}
{/if} {/if}
</CardHeader> </CardBody>
</Card> </Card>
{/each} {/each}
</div> </div>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { beforeNavigate, goto, invalidate } from '$app/navigation'; import { goto, invalidate } from '$app/navigation';
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import { pluginManager } from '$lib/managers/plugin-manager.svelte'; import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte'; import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
@@ -8,7 +8,7 @@
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { handleUpdateWorkflow } from '$lib/services/workflow.service'; import { handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow'; import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk'; import type { WorkflowResponseDto, WorkflowStepDto } from '@immich/sdk';
import { import {
ActionBar, ActionBar,
AppShell, AppShell,
@@ -29,40 +29,26 @@
IconButton, IconButton,
Input, Input,
modalManager, modalManager,
Stack,
Switch, Switch,
Text,
Textarea, Textarea,
VStack, VStack,
type ActionItem,
} from '@immich/ui'; } from '@immich/ui';
import { import {
mdiArrowLeft, mdiArrowLeft,
mdiCodeJson,
mdiContentSave, mdiContentSave,
mdiFlashOutline, mdiFlashOutline,
mdiFormatListBulletedSquare, mdiFormatListBulletedSquare,
mdiInformationOutline, mdiInformationOutline,
mdiPencilOutline, mdiPencilOutline,
mdiPlus, mdiPlus,
mdiTrashCanOutline,
} from '@mdi/js'; } from '@mdi/js';
import { cloneDeep, isEqual } from 'lodash-es';
import { flushSync } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte'; import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import WorkflowStepCard from './WorkflowStepCard.svelte';
import WorkflowStepDragImage from './WorkflowStepDragImage.svelte';
import WorkflowSummary from './WorkflowSummary.svelte';
type WorkflowJsonContent = Required<
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>
>;
type EditMode = 'visual' | 'json';
type StepDragImage = {
description?: string;
isFilter: boolean;
label: string;
stepNumber: number;
};
type Props = { type Props = {
data: PageData; data: PageData;
@@ -71,27 +57,6 @@
let { data }: Props = $props(); let { data }: Props = $props();
let { id, enabled, name, description, trigger, steps } = $derived(data.workflow); let { id, enabled, name, description, trigger, steps } = $derived(data.workflow);
let savedWorkflow = $state(cloneDeep(data.workflow));
let allowNavigation = $state(false);
let isShowingNavigationDialog = $state(false);
let isSaving = $state(false);
let editMode = $state<EditMode>('visual');
let draggedIndex = $state<number | null>(null);
let dragHandleHoverIndex = $state<number | null>(null);
let dragImageElement = $state<HTMLElement | null>(null);
let dragImage = $state<StepDragImage>({ isFilter: false, label: '', stepNumber: 1 });
let dropTargetIndex = $state<number | null>(null);
const workflowSummary = $derived({ name, description, trigger, steps });
const workflowJsonContent = $derived<WorkflowJsonContent>({ description, enabled, name, steps, trigger });
const hasChanges = $derived(
enabled !== savedWorkflow.enabled ||
name !== savedWorkflow.name ||
description !== savedWorkflow.description ||
!isEqual(trigger, savedWorkflow.trigger) ||
!isEqual(steps, savedWorkflow.steps),
);
const handleAddStep = async () => { const handleAddStep = async () => {
const step = await modalManager.show(WorkflowAddStepModal, { trigger }); const step = await modalManager.show(WorkflowAddStepModal, { trigger });
@@ -100,90 +65,13 @@
} }
}; };
const handleInsertStep = async (index: number) => { const handleEditStep = async (step: WorkflowStepDto) => {
const step = await modalManager.show(WorkflowAddStepModal, { trigger }); const result = await modalManager.show(WorkflowEditStepModal, { trigger, step });
if (step) {
steps = [...steps.slice(0, index), step, ...steps.slice(index)];
}
};
const replaceStep = (index: number, step: WorkflowStepDto) => {
steps = steps.map((current, i) => (i === index ? cloneDeep(step) : current));
};
const handleEditStep = async (index: number) => {
const step = steps[index];
if (!step) {
return;
}
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step: cloneDeep(step) });
if (result) { if (result) {
replaceStep(index, result); Object.assign(step, result);
} }
}; };
const handleDragStart = (index: number, event: DragEvent) => {
draggedIndex = index;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(index));
const step = steps[index];
const method = step ? pluginManager.getMethod(step.method) : undefined;
dragImage = {
description: method?.description,
isFilter: method?.uiHints?.includes('filter') ?? false,
label: step ? pluginManager.getMethodLabel(step.method) : '',
stepNumber: index + 1,
};
flushSync();
if (dragImageElement) {
event.dataTransfer.setDragImage(dragImageElement, 16, 22);
}
}
};
const handleDragOver = (index: number, event: DragEvent) => {
if (draggedIndex === null) {
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
if (dropTargetIndex !== index) {
dropTargetIndex = index;
}
};
const handleDragLeave = (index: number) => {
if (dropTargetIndex === index) {
dropTargetIndex = null;
}
};
const handleDrop = (index: number, event: DragEvent) => {
event.preventDefault();
const from = draggedIndex;
draggedIndex = null;
dropTargetIndex = null;
if (from === null || from === index) {
return;
}
const next = [...steps];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
steps = next;
};
const handleDragEnd = () => {
draggedIndex = null;
dragHandleHoverIndex = null;
dropTargetIndex = null;
};
const handleDeleteStep = async (index: number) => { const handleDeleteStep = async (index: number) => {
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') }); const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
if (confirmed) { if (confirmed) {
@@ -192,16 +80,11 @@
} }
}; };
const handleJsonContentChange = (content: WorkflowJsonContent) => { const onClose = async () => {
enabled = content.enabled; // check for pending changes
name = content.name; await goto(Route.workflows());
description = content.description;
trigger = content.trigger;
steps = cloneDeep(content.steps);
}; };
const onClose = () => goto(Route.workflows());
const onChangeTrigger = async () => { const onChangeTrigger = async () => {
const newTrigger = await modalManager.show(WorkflowTriggerPicker, { selected: trigger }); const newTrigger = await modalManager.show(WorkflowTriggerPicker, { selected: trigger });
if (newTrigger) { if (newTrigger) {
@@ -212,228 +95,163 @@
const onWorkflowUpdate = async (response: WorkflowResponseDto) => { const onWorkflowUpdate = async (response: WorkflowResponseDto) => {
if (id === response.id) { if (id === response.id) {
data.workflow = response; data.workflow = response;
savedWorkflow = cloneDeep(response);
await invalidate('workflow:data'); await invalidate('workflow:data');
} }
}; };
const confirmNavigation = async () => { const Done: ActionItem = {
if (!hasChanges) { title: $t('save'),
return true; icon: mdiContentSave,
} color: 'primary',
onAction: () => handleUpdateWorkflow(id, { enabled, name, description, trigger, steps }),
if (isShowingNavigationDialog) {
return false;
}
try {
isShowingNavigationDialog = true;
return await modalManager.showDialog({
prompt: $t('workflow_navigation_prompt'),
confirmColor: 'primary',
});
} finally {
isShowingNavigationDialog = false;
}
}; };
const saveWorkflow = async () => {
if (!hasChanges || isSaving) {
return;
}
isSaving = true;
try {
const submitted = { enabled, name, description, trigger, steps: cloneDeep(steps) };
const saved = await handleUpdateWorkflow(id, submitted);
if (saved) {
Object.assign(savedWorkflow, submitted);
}
} finally {
isSaving = false;
}
};
beforeNavigate(({ cancel, to, willUnload }) => {
if (!hasChanges || allowNavigation) {
return;
}
cancel();
if (willUnload || !to) {
return;
}
void confirmNavigation().then((confirmed) => {
if (confirmed) {
allowNavigation = true;
void goto(to.url);
}
});
});
</script> </script>
<OnEvents {onWorkflowUpdate} /> <OnEvents {onWorkflowUpdate} />
<AppShell class=""> <AppShell>
<AppShellBar> <AppShellBar>
<ActionBar static {onClose} translations={{ close: $t('back') }} closeIcon={mdiArrowLeft}> <ActionBar static {onClose} translations={{ close: $t('back') }} closeIcon={mdiArrowLeft}>
<ControlBarHeader> <ControlBarHeader>
<ControlBarTitle>{data.workflow.name}</ControlBarTitle> <ControlBarTitle>{data.workflow.name}</ControlBarTitle>
<ControlBarDescription>{data.workflow.description}</ControlBarDescription> <ControlBarDescription>{data.workflow.description}</ControlBarDescription>
</ControlBarHeader> </ControlBarHeader>
<ControlBarContent class="flex items-center justify-end gap-6"> <ControlBarContent class="flex justify-end">
<div class="flex gap-1 rounded-full border border-light-200 bg-light p-1" role="group"> <HeaderActionButton action={Done} variant="filled" />
<Button
variant={editMode === 'visual' ? 'filled' : 'ghost'}
color={editMode === 'visual' ? 'primary' : 'secondary'}
size="small"
leadingIcon={mdiFormatListBulletedSquare}
aria-pressed={editMode === 'visual'}
onclick={() => (editMode = 'visual')}
shape="round"
>
{$t('visual')}
</Button>
<Button
variant={editMode === 'json' ? 'filled' : 'ghost'}
color={editMode === 'json' ? 'primary' : 'secondary'}
size="small"
leadingIcon={mdiCodeJson}
aria-pressed={editMode === 'json'}
onclick={() => (editMode = 'json')}
shape="round"
>
JSON
</Button>
</div>
<Button
variant="filled"
size="small"
color="primary"
leadingIcon={mdiContentSave}
disabled={!hasChanges || isSaving}
loading={isSaving}
onclick={saveWorkflow}
>
{$t('save')}
</Button>
</ControlBarContent> </ControlBarContent>
</ActionBar> </ActionBar>
</AppShellBar> </AppShellBar>
<Container size="medium" class="pt-8 pb-24" center> <Container size="medium" class="pt-8 pb-24" center>
<VStack gap={4}> <VStack gap={4}>
{#if editMode === 'visual'} <Card expandable>
<Card class="shadow-none" expandable> <CardHeader>
<CardHeader> <div class="flex place-items-start gap-3">
<div class="flex place-items-start gap-3"> <Icon icon={mdiInformationOutline} size="20" class="mt-1" />
<Icon icon={mdiInformationOutline} size="20" class="mt-1" /> <div class="flex flex-col">
<div class="flex flex-col"> <CardTitle>
<CardTitle> {$t('workflow_info')}
{$t('workflow_info')} </CardTitle>
</CardTitle>
</div>
</div> </div>
</CardHeader> </div>
</CardHeader>
<CardBody> <CardBody>
<VStack gap={4}> <VStack gap={4}>
<div class="relative w-full overflow-hidden rounded-xl border p-4" class:bg-primary-50={enabled}> <div class="relative w-full overflow-hidden rounded-xl border p-4" class:bg-primary-50={enabled}>
<Field label={enabled ? $t('enabled') : $t('disabled')} color={enabled ? 'primary' : 'secondary'}> <Field label={enabled ? $t('enabled') : $t('disabled')} color={enabled ? 'primary' : 'secondary'}>
<Switch bind:checked={enabled} /> <Switch bind:checked={enabled} />
</Field>
</div>
<Field label={$t('name')} required>
<Input
placeholder={$t('workflow_name')}
bind:value={() => name ?? '', (value) => (name = value || null)}
/>
</Field> </Field>
<Field label={$t('description')} for="workflow-description"> </div>
<Textarea
id="workflow-description"
grow
placeholder={$t('workflow_description')}
bind:value={() => description ?? '', (value) => (description = value || null)}
/>
</Field>
</VStack>
</CardBody>
</Card>
<div class="my-4 h-px w-[98%] bg-light-200"></div> <Field label={$t('name')} required>
<Input
<Card class="shadow-none"> placeholder={$t('workflow_name')}
<CardHeader> bind:value={() => name ?? '', (value) => (name = value || null)}
<div class="flex items-center gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-success-50">
<Icon icon={mdiFlashOutline} size="20" class="text-success" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<CardTitle class="truncate">{getTriggerName($t, trigger)}</CardTitle>
<CardDescription class="truncate">{getTriggerDescription($t, trigger)}</CardDescription>
</div>
<IconButton
icon={mdiPencilOutline}
aria-label={$t('edit')}
variant="ghost"
shape="round"
color="secondary"
size="small"
onclick={onChangeTrigger}
/> />
</Field>
<Field label={$t('description')} for="workflow-description">
<Textarea
id="workflow-description"
grow
placeholder={$t('workflow_description')}
bind:value={() => description ?? '', (value) => (description = value || null)}
/>
</Field>
</VStack>
</CardBody>
</Card>
<div class="my-4 h-px w-[98%] bg-light-200"></div>
<Card>
<CardHeader class="bg-success-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-success" />
<div class="flex grow flex-col">
<CardTitle class="text-left text-success">{$t('trigger')}</CardTitle>
<CardDescription>{$t('trigger_description')}</CardDescription>
</div> </div>
</CardHeader> <div class="flex items-center justify-end">
</Card> <Button leadingIcon={mdiPencilOutline} size="small" color="secondary" onclick={onChangeTrigger}>
{$t('edit')}
</Button>
</div>
</div>
</CardHeader>
{#each steps as step, index (index)} <CardBody>
<WorkflowStepCard <div class="flex flex-col items-start">
{step} <Text>{getTriggerName($t, trigger)}</Text>
{index} <Text size="small" color="muted">{getTriggerDescription($t, trigger)}</Text>
isDragging={draggedIndex === index} </div>
isDragHandleHovered={dragHandleHoverIndex === index} </CardBody>
isDropTarget={dropTargetIndex === index && draggedIndex !== null && draggedIndex !== index} </Card>
onEdit={handleEditStep}
onDelete={handleDeleteStep}
onInsertBefore={handleInsertStep}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragHandleEnter={(i) => (dragHandleHoverIndex = i)}
onDragHandleLeave={() => (dragHandleHoverIndex = null)}
/>
{/each}
<Button <Card>
size="small" <CardHeader class="bg-primary-50">
fullWidth <div class="flex items-start gap-3">
variant="ghost" <Icon icon={mdiFormatListBulletedSquare} size="20" class="mt-1 text-primary" />
leadingIcon={mdiPlus} <CardTitle class="text-left text-primary">{$t('steps')}</CardTitle>
class="border border-dashed" </div>
onclick={handleAddStep} </CardHeader>
>
{$t('add_step')} <CardBody>
</Button> {#if steps.length === 0}
{:else} <Button leadingIcon={mdiPlus} onclick={handleAddStep}>{$t('add_step')}</Button>
<WorkflowJsonEditor jsonContent={workflowJsonContent} onContentChange={handleJsonContentChange} /> {:else}
{/if} <Stack gap={2}>
{#each steps as step, index (index)}
{@const method = pluginManager.getMethod(step.method)}
{#if index > 0}
<hr />
{/if}
<div
// {@attach dragAndDrop({
// index,
// onDragStart: handleFilterDragStart,
// onDragEnter: handleFilterDragEnter,
// onDrop: handleFilterDrop,
// onDragEnd: handleFilterDragEnd,
// isDragging: draggedIndex === index,
// isDragOver: dragOverIndex === index,
// })}
class="flex cursor-move justify-between gap-2 rounded-2xl border-2 border-dashed bg-light-50 p-4 transition-all hover:border-light-300"
>
<div class="flex flex-col gap-1">
<Text>{pluginManager.getMethodLabel(step.method)}</Text>
{#if method?.description}
<Text color="muted" size="small">{method.description}</Text>
{/if}
</div>
<div class="flex gap-1">
<IconButton
icon={mdiPencilOutline}
aria-label={$t('edit')}
variant="ghost"
shape="round"
color="secondary"
onclick={() => handleEditStep(step)}
/>
<IconButton
icon={mdiTrashCanOutline}
aria-label={$t('delete')}
variant="ghost"
shape="round"
color="danger"
onclick={() => handleDeleteStep(index)}
/>
</div>
</div>
{/each}
<Button size="small" fullWidth variant="ghost" leadingIcon={mdiPlus} onclick={handleAddStep}>
{$t('add_step')}
</Button>
</Stack>
{/if}
</CardBody>
</Card>
</VStack> </VStack>
</Container> </Container>
<WorkflowStepDragImage
bind:ref={dragImageElement}
description={dragImage.description}
isFilter={dragImage.isFilter}
label={dragImage.label}
stepNumber={dragImage.stepNumber}
/>
<WorkflowSummary workflow={workflowSummary} />
</AppShell> </AppShell>
@@ -1,4 +1,4 @@
import { getWorkflow } from '@immich/sdk'; import { searchWorkflows } from '@immich/sdk';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { pluginManager } from '$lib/managers/plugin-manager.svelte'; import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
@@ -8,7 +8,7 @@ import type { PageLoad } from './$types';
export const load = (async ({ url, params, depends }) => { export const load = (async ({ url, params, depends }) => {
await authenticate(url); await authenticate(url);
const [workflow] = await Promise.all([getWorkflow({ id: params.workflowId }), pluginManager.ready()]); const [[workflow]] = await Promise.all([searchWorkflows({ id: params.workflowId }), pluginManager.ready()]);
const $t = await getFormatter(); const $t = await getFormatter();
if (!workflow) { if (!workflow) {
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { WorkflowTrigger, type WorkflowStepDto, type WorkflowUpdateDto } from '@immich/sdk'; import type { WorkflowResponseDto } from '@immich/sdk';
import { import {
Button,
Card, Card,
CardBody, CardBody,
CardDescription, CardDescription,
@@ -12,91 +13,40 @@
VStack, VStack,
} from '@immich/ui'; } from '@immich/ui';
import { mdiCodeJson } from '@mdi/js'; import { mdiCodeJson } from '@mdi/js';
import { isEqual } from 'lodash-es';
import { untrack } from 'svelte';
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor'; import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
import { t } from 'svelte-i18n';
type WorkflowJsonContent = Required<
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>
>;
type Props = { type Props = {
jsonContent: WorkflowJsonContent; jsonContent: WorkflowResponseDto;
onContentChange: (content: WorkflowJsonContent) => void; onApply: () => void;
onContentChange: (content: WorkflowResponseDto) => void;
}; };
let { jsonContent, onContentChange }: Props = $props(); let { jsonContent, onApply, onContentChange }: Props = $props();
let content: Content = $state({ json: jsonContent }); let content: Content = $derived({ json: jsonContent });
let canApply = $state(false);
let editorClass = $derived(themeManager.value === Theme.Dark ? 'jse-theme-dark' : ''); let editorClass = $derived(themeManager.value === Theme.Dark ? 'jse-theme-dark' : '');
const isWorkflowStep = (value: unknown): value is WorkflowStepDto => {
if (!value || typeof value !== 'object') {
return false;
}
const step = value as Partial<WorkflowStepDto>;
return (
typeof step.method === 'string' &&
(step.config === null || (typeof step.config === 'object' && !Array.isArray(step.config))) &&
(step.enabled === undefined || typeof step.enabled === 'boolean')
);
};
const isWorkflowJsonContent = (value: unknown): value is WorkflowJsonContent => {
if (!value || typeof value !== 'object') {
return false;
}
const workflow = value as Partial<WorkflowJsonContent>;
return (
typeof workflow.enabled === 'boolean' &&
(workflow.name === null || typeof workflow.name === 'string') &&
(workflow.description === null || typeof workflow.description === 'string') &&
Object.values(WorkflowTrigger).includes(workflow.trigger as WorkflowTrigger) &&
Array.isArray(workflow.steps) &&
workflow.steps.every(isWorkflowStep)
);
};
const parseContent = (updated: Content) => {
if ('json' in updated) {
return updated.json;
}
return JSON.parse(updated.text);
};
$effect(() => {
const nextContent = jsonContent;
let isSynced = false;
try {
isSynced = isEqual(
untrack(() => parseContent(content)),
nextContent,
);
} catch {
// The editor can be temporarily invalid while typing in text mode.
}
if (!isSynced) {
content = { json: nextContent };
}
});
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => { const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
if (status.contentErrors) { if (status.contentErrors) {
return; return;
} }
const parsed = parseContent(updated); canApply = true;
if (!isWorkflowJsonContent(parsed)) {
return;
}
onContentChange(parsed); 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> </script>
@@ -107,16 +57,17 @@
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<Icon icon={mdiCodeJson} size="20" class="mt-1" /> <Icon icon={mdiCodeJson} size="20" class="mt-1" />
<div class="flex flex-col"> <div class="flex flex-col">
<CardTitle>{$t('workflow_json')}</CardTitle> <CardTitle>Workflow JSON</CardTitle>
<CardDescription>{$t('workflow_json_help')}</CardDescription> <CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
</div> </div>
</div> </div>
<Button size="small" color="primary" onclick={handleApply} disabled={!canApply}>Apply Changes</Button>
</div> </div>
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<VStack gap={2}> <VStack gap={2}>
<div class="h-[600px] w-full overflow-hidden rounded-lg border {editorClass}"> <div class="h-[600px] w-full overflow-hidden rounded-lg border {editorClass}">
<JSONEditor bind:content onChange={handleChange} mainMenuBar={false} mode={Mode.text} /> <JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
</div> </div>
</VStack> </VStack>
</CardBody> </CardBody>
@@ -1,195 +0,0 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import type { WorkflowStepDto } from '@immich/sdk';
import { Badge, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, IconButton } from '@immich/ui';
import {
mdiAutoFix,
mdiDragVertical,
mdiFilterVariant,
mdiPencilOutline,
mdiPlus,
mdiTrashCanOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
step: WorkflowStepDto;
index: number;
isDragging: boolean;
isDragHandleHovered: boolean;
isDropTarget: boolean;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onInsertBefore: (index: number) => void;
onDragStart: (index: number, event: DragEvent) => void;
onDragEnd: () => void;
onDragOver: (index: number, event: DragEvent) => void;
onDragLeave: (index: number) => void;
onDrop: (index: number, event: DragEvent) => void;
onDragHandleEnter: (index: number) => void;
onDragHandleLeave: () => void;
};
let {
step,
index,
isDragging,
isDragHandleHovered,
isDropTarget,
onEdit,
onDelete,
onInsertBefore,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
onDragHandleEnter,
onDragHandleLeave,
}: Props = $props();
const method = $derived(pluginManager.getMethod(step.method));
const isFilter = $derived(method?.uiHints?.includes('filter') ?? false);
const configEntries = $derived(
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
);
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
const formatConfigValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'boolean') {
return value ? 'on' : 'off';
}
if (typeof value === 'number') {
return String(value);
}
if (typeof value === 'string') {
return `"${truncate(value)}"`;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return $t('none');
}
const items = value.map((v) => (v !== null && typeof v === 'object' ? '{}' : String(v)));
const joined = items.join(' · ');
if (joined.length <= 28) {
return `"${joined}"`;
}
return $t('items_count', { values: { count: value.length } });
}
return '{}';
};
</script>
<div class="group/step-row flex w-full flex-col">
<div class="-mt-4 ml-18 flex w-full items-center gap-4">
<div class="relative flex w-1 shrink-0 justify-start">
<div class="h-10 w-0.5 bg-light-200"></div>
<button
type="button"
class="absolute top-1/2 left-1/2 z-10 -translate-1/2 cursor-pointer rounded-full border border-dashed border-primary-200 bg-light p-0.5 text-primary opacity-0 transition-opacity group-hover/step-row:opacity-100 hover:bg-primary-50"
aria-label={$t('add_step')}
title={$t('add_step')}
onclick={() => onInsertBefore(index)}
>
<Icon icon={mdiPlus} size="14" />
</button>
</div>
</div>
<div
class="w-full transition-all"
class:opacity-40={isDragging}
class:scale-[0.99]={isDragging}
ondragover={(event) => onDragOver(index, event)}
ondragleave={() => onDragLeave(index)}
ondrop={(event) => onDrop(index, event)}
role="listitem"
>
<Card
class="shadow-none transition-colors {isDropTarget
? 'border-primary ring-2 ring-primary-200'
: isDragHandleHovered
? 'border-dashed border-primary'
: ''}"
>
<CardHeader>
<div class="flex items-center gap-2">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex shrink-0 cursor-grab items-center justify-center rounded-md border border-transparent p-1 text-light-400 select-none hover:border-primary-200 hover:bg-primary-50 hover:text-primary active:cursor-grabbing"
aria-label={$t('drag_to_reorder')}
draggable="true"
onmouseenter={() => onDragHandleEnter(index)}
onmouseleave={onDragHandleLeave}
ondragstart={(event) => onDragStart(index, event)}
ondragend={onDragEnd}
title={$t('drag_to_reorder')}
>
<Icon icon={mdiDragVertical} size="20" />
</div>
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg"
class:bg-primary-50={isFilter}
class:bg-warning-50={!isFilter}
>
<Icon
icon={isFilter ? mdiFilterVariant : mdiAutoFix}
size="20"
class={isFilter ? 'text-primary' : 'text-warning'}
/>
</div>
<div class="flex min-w-0 flex-1 flex-col">
<CardTitle class="truncate">
<span class="mr-1 font-bold text-light-500">{index + 1}</span>
{pluginManager.getMethodLabel(step.method)}
</CardTitle>
{#if method?.description}
<CardDescription class="truncate">{method.description}</CardDescription>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1">
<IconButton
icon={mdiPencilOutline}
aria-label={$t('edit')}
variant="ghost"
shape="round"
color="secondary"
size="small"
onclick={() => onEdit(index)}
/>
<IconButton
icon={mdiTrashCanOutline}
aria-label={$t('delete')}
variant="ghost"
shape="round"
color="danger"
size="small"
onclick={() => onDelete(index)}
/>
</div>
</div>
</CardHeader>
{#if configEntries.length > 0}
<CardBody class="py-3">
<div class="flex flex-wrap items-center gap-1.5">
{#each configEntries as [key, value] (key)}
<Badge
color={isFilter ? 'info' : 'warning'}
shape="round"
size="small"
class="border font-mono {isFilter ? 'border-primary-200' : 'border-warning-200'}"
>
<span class="opacity-60">{key}</span>{formatConfigValue(value)}
</Badge>
{/each}
</div>
</CardBody>
{/if}
</Card>
</div>
</div>
@@ -1,43 +0,0 @@
<script lang="ts">
import { Icon } from '@immich/ui';
import { mdiAutoFix, mdiFilterVariant } from '@mdi/js';
type Props = {
ref?: HTMLElement | null;
description?: string;
isFilter: boolean;
label: string;
stepNumber: number;
};
let { ref = $bindable(null), description, isFilter, label, stepNumber }: Props = $props();
</script>
<div
bind:this={ref}
aria-hidden="true"
class="pointer-events-none fixed top-[-1000px] left-0 flex w-80 items-center gap-2.5 rounded-lg border border-light-200 bg-light px-3 py-2.5 text-sm/5 text-dark shadow-2xl"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg"
class:bg-primary-50={isFilter}
class:bg-warning-50={!isFilter}
>
<Icon
icon={isFilter ? mdiFilterVariant : mdiAutoFix}
size="18"
class={isFilter ? 'text-primary' : 'text-warning'}
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<span class="shrink-0 font-bold text-light-500">#{stepNumber}</span>
<span class="truncate font-bold">{label}</span>
</div>
{#if description}
<div class="mt-0.5 truncate text-xs/4 text-light-500">{description}</div>
{/if}
</div>
</div>
@@ -1,176 +1,137 @@
<script lang="ts"> <script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { getTriggerName } from '$lib/utils/workflow'; import { getTriggerName } from '$lib/utils/workflow';
import type { WorkflowStepDto, WorkflowTrigger } from '@immich/sdk'; import type { WorkflowResponseDto } from '@immich/sdk';
import { Icon, IconButton, Text } from '@immich/ui'; import { Icon, IconButton, Text } from '@immich/ui';
import { mdiCheck, mdiClose, mdiContentCopy, mdiViewDashboardOutline } from '@mdi/js'; import { mdiClose, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
type WorkflowSummaryData = {
name: string | null;
description: string | null;
trigger: WorkflowTrigger;
steps: WorkflowStepDto[];
};
type Props = { type Props = {
workflow: WorkflowSummaryData; workflow: WorkflowResponseDto;
}; };
let { workflow }: Props = $props(); let { workflow }: Props = $props();
const { trigger, steps } = $derived(workflow);
let isOpen = $state(false); let isOpen = $state(false);
let justCopied = $state(false); let position = $state({ x: 0, y: 0 });
let copyTimer: ReturnType<typeof setTimeout> | undefined; let isDragging = $state(false);
let panelElement = $state<HTMLElement | undefined>(undefined); let dragOffset = $state({ x: 0, y: 0 });
let containerEl: HTMLDivElement | undefined = $state();
$effect(() => { const handleMouseDown = (e: MouseEvent) => {
if (!isOpen) { if (!containerEl) {
return; return;
} }
isDragging = true;
const handleKeydown = (event: KeyboardEvent) => { const rect = containerEl.getBoundingClientRect();
if (event.key === 'Escape') { dragOffset = {
event.stopPropagation(); x: e.clientX - rect.left,
event.preventDefault(); y: e.clientY - rect.top,
isOpen = false;
}
}; };
document.addEventListener('mousemove', handleMouseMove);
const handlePointerDown = (event: PointerEvent) => { document.addEventListener('mouseup', handleMouseUp);
if (panelElement && event.target instanceof Node && !panelElement.contains(event.target)) {
isOpen = false;
}
};
document.addEventListener('keydown', handleKeydown, { capture: true });
document.addEventListener('pointerdown', handlePointerDown);
return () => {
document.removeEventListener('keydown', handleKeydown, { capture: true });
document.removeEventListener('pointerdown', handlePointerDown);
};
});
const formatConfigValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
if (typeof value === 'number') {
return String(value);
}
if (typeof value === 'string') {
return `"${value}"`;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '[]';
}
return '[' + value.map((v) => (v !== null && typeof v === 'object' ? '{}' : String(v))).join(', ') + ']';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}; };
const getConfigEntries = (config: WorkflowStepDto['config']) => const handleMouseMove = (e: MouseEvent) => {
Object.entries(config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''); if (!isDragging) {
return;
const asciiSummary = $derived.by(() => {
const lines: string[] = [];
const title = workflow.name ?? $t('no_name');
lines.push(`${title}`);
if (workflow.description) {
lines.push(workflow.description);
}
lines.push('', ' WHEN', ` ⚡ ${getTriggerName($t, workflow.trigger)}`, '', ' THEN');
if (workflow.steps.length === 0) {
lines.push(` ${$t('no_steps')}`);
return lines.join('\n');
}
for (const [i, step] of workflow.steps.entries()) {
const method = pluginManager.getMethod(step.method);
const isFilter = method?.uiHints?.includes('filter') ?? false;
const type = isFilter ? $t('filter') : $t('action');
const label = pluginManager.getMethodLabel(step.method);
lines.push(` [${i + 1}] ${type.toUpperCase()} · ${label}`);
for (const [key, value] of getConfigEntries(step.config)) {
lines.push(` ${key} = ${formatConfigValue(value)}`);
}
if (i < workflow.steps.length - 1) {
lines.push('');
}
}
return lines.join('\n');
});
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(asciiSummary);
justCopied = true;
if (copyTimer) {
clearTimeout(copyTimer);
}
copyTimer = setTimeout(() => (justCopied = false), 1500);
} catch {
// ignore — clipboard may be unavailable
} }
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> </script>
{#if isOpen} {#if isOpen}
<aside <!-- svelte-ignore a11y_no_static_element_interactions -->
bind:this={panelElement} <div
class="fixed inset-y-20 right-4 bottom-4 hidden max-w-lg flex-col overflow-hidden rounded-2xl border border-light-200 bg-light shadow-2xl sm:flex" bind:this={containerEl}
transition:fly={{ x: 400, duration: 250 }} class="fixed hidden w-64 select-none hover:cursor-grab sm:block"
aria-label={$t('workflow_summary')} style="left: {position.x}px; top: {position.y}px;"
class:cursor-grabbing={isDragging}
onmousedown={handleMouseDown}
> >
<!-- Header --> <div
<div class="flex shrink-0 items-center justify-between border-b border-light-200 px-4 py-2.5"> class="rounded-xl border-2 border-transparent bg-light-50 p-4 shadow-sm transition-all hover:border-dashed hover:border-light-300 hover:shadow-xl"
<Text size="small" fontWeight="semi-bold" color="muted">{$t('workflow_summary')}</Text> >
<div class="flex items-center gap-1"> <div class="mb-4 flex cursor-grab items-center justify-between select-none">
<IconButton <Text size="small" fontWeight="semi-bold">{$t('workflow_summary')}</Text>
icon={justCopied ? mdiCheck : mdiContentCopy} <div class="flex items-center gap-1">
size="small" <IconButton
variant="ghost" icon={mdiClose}
color={justCopied ? 'success' : 'secondary'} size="small"
title={$t('copy_to_clipboard')} variant="ghost"
aria-label={$t('copy_to_clipboard')} color="secondary"
onclick={handleCopy} title="Close summary"
/> aria-label="Close summary"
<IconButton onclick={(e: MouseEvent) => {
icon={mdiClose} e.stopPropagation();
size="small" isOpen = false;
variant="ghost" }}
color="secondary" />
title="Close summary" </div>
aria-label="Close summary" </div>
onclick={() => (isOpen = false)}
/> <div class="space-y-2">
<!-- Trigger -->
<div class="rounded-lg border bg-light-100 p-3">
<div class="mb-1 flex items-center gap-2">
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
<Text size="tiny" fontWeight="semi-bold">{$t('trigger')}</Text>
</div>
<p class="truncate pl-5 text-sm">{getTriggerName($t, trigger)}</p>
</div>
<!-- Connector -->
<div class="flex justify-center">
<div class="h-3 w-0.5 bg-light-400"></div>
</div>
<!-- Steps -->
{#if steps.length > 0}
<div class="rounded-lg border bg-light-100 p-3">
<div class="mb-2 flex items-center gap-2">
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
<Text size="tiny" fontWeight="semi-bold">{$t('actions')}</Text>
</div>
<div class="space-y-1 pl-5">
{#each steps as step, index (index)}
<div class="flex items-center gap-2">
<span
class="flex size-4 shrink-0 items-center justify-center rounded-full bg-light-200 text-[10px] font-medium"
>{index + 1}</span
>
<p class="truncate text-sm">{step.method}</p>
</div>
{/each}
</div>
</div>
{/if}
</div> </div>
</div> </div>
</div>
<!-- ASCII body — what you see is what you copy -->
<div class="flex-1 overflow-auto p-4">
<pre
class="m-0 overflow-auto rounded-lg border border-light-200 bg-light-100 px-4 py-3 font-mono text-xs/relaxed whitespace-pre">{asciiSummary}</pre>
</div>
</aside>
{:else} {:else}
<button <button
type="button" type="button"
class="fixed right-6 bottom-6 hidden size-14 items-center justify-center rounded-full bg-primary text-light shadow-lg transition-colors hover:bg-primary/90 sm:flex" class="fixed right-6 bottom-6 hidden size-14 items-center justify-center rounded-full bg-primary text-light shadow-lg transition-colors hover:bg-primary/90 sm:flex"
title={$t('workflow_summary')} title={$t('workflow_summary')}
aria-label={$t('workflow_summary')}
onclick={() => (isOpen = true)} onclick={() => (isOpen = true)}
> >
<Icon icon={mdiViewDashboardOutline} size="24" /> <Icon icon={mdiViewDashboardOutline} size="24" />