mirror of
https://github.com/immich-app/immich.git
synced 2026-05-23 16:12:30 -04:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4eb983c49f | |||
| fd7ddfef54 | |||
| 0975b1599c | |||
| 05334569e3 | |||
| c72ad193fa | |||
| 6f8fb9e42b | |||
| 746599319b | |||
| 62578cfad5 | |||
| 4d886ba0ad | |||
| 3005ff0cc4 | |||
| 9ea4a03f21 | |||
| 69143a53b7 | |||
| 86255f8c31 | |||
| b3b651aec0 | |||
| 5e4b64670c | |||
| dd7a51a2d9 | |||
| 646b8249ca | |||
| 352c129d92 | |||
| 3ac91797e8 | |||
| a7d634bacd | |||
| 11cf9ffd85 | |||
| 220891d533 |
@@ -536,7 +536,7 @@ test.describe('Timeline', () => {
|
|||||||
force: false,
|
force: false,
|
||||||
ids: [assetToTrash.id],
|
ids: [assetToTrash.id],
|
||||||
});
|
});
|
||||||
await page.keyboard.press('Escape');
|
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||||
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.keyboard.press('Escape');
|
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||||
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.keyboard.press('Escape');
|
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||||
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);
|
||||||
|
|||||||
@@ -976,6 +976,7 @@
|
|||||||
"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.",
|
||||||
@@ -2254,6 +2255,7 @@
|
|||||||
"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?",
|
||||||
@@ -2476,6 +2478,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
Generated
+5
-5
@@ -758,8 +758,8 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/sdk
|
version: link:../packages/sdk
|
||||||
'@immich/ui':
|
'@immich/ui':
|
||||||
specifier: ^0.79.0
|
specifier: ^0.77.0
|
||||||
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))
|
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))
|
||||||
'@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.79.0':
|
'@immich/ui@0.77.3':
|
||||||
resolution: {integrity: sha512-UEQZrP8CTc4Kth1xCV8/6Xmk1P51GQKISC7vKqcrM0BO0fxCaNwJK8Ocn6R8baVqH52JYfPb1yQR9bweBnCjXw==}
|
resolution: {integrity: sha512-h3jrYE3JyGDOwXF7A4tVUHenP0L7TsDV22FyFInBTdwlWjjXoknwE1HWeTvvLxLeMuO5SHCZ9Q2D2al84xVjNw==}
|
||||||
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.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))':
|
'@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))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@internationalized/date': 3.12.1
|
'@internationalized/date': 3.12.1
|
||||||
'@mdi/js': 7.4.47
|
'@mdi/js': 7.4.47
|
||||||
|
|||||||
+2
-2
@@ -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-core-plugin/dist
|
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist
|
||||||
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-core-plugin/manifest.json
|
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/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
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
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
@@ -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.79.0",
|
"@immich/ui": "^0.77.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>
|
<ControlAppBar showBackButton={false}>
|
||||||
{#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>
|
<ControlAppBar showBackButton={false}>
|
||||||
{#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 { mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js';
|
import { mdiArrowLeft, 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>
|
<ControlAppBar onClose={() => goto(Route.photos())} backIcon={mdiArrowLeft} showBackButton={false}>
|
||||||
{#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,49 +1,97 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ControlBar, ControlBarContent, ControlBarHeader, ControlBarOverflow, ControlBarTitle } from '@immich/ui';
|
import { browser } from '$app/environment';
|
||||||
|
import { IconButton } from '@immich/ui';
|
||||||
import { mdiClose } from '@mdi/js';
|
import { mdiClose } from '@mdi/js';
|
||||||
import type { Snippet } from 'svelte';
|
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||||
import type { ClassValue } from 'svelte/elements';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
showBackButton?: boolean;
|
||||||
backIcon?: string;
|
backIcon?: string;
|
||||||
class?: ClassValue;
|
tailwindClasses?: string;
|
||||||
|
forceDark?: boolean;
|
||||||
|
multiRow?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
title?: Snippet | string;
|
|
||||||
leading?: Snippet;
|
leading?: Snippet;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
trailing?: Snippet;
|
trailing?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { backIcon = mdiClose, class: className = '', onClose, title, leading, children, trailing }: Props = $props();
|
let {
|
||||||
|
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 class={['absolute top-0 w-full bg-transparent p-2']}>
|
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full bg-transparent">
|
||||||
<ControlBar closeIcon={backIcon} {onClose} shape="round" class={className}>
|
<nav
|
||||||
{#if title || leading}
|
id="asset-selection-app-bar"
|
||||||
<ControlBarHeader>
|
class={[
|
||||||
{#if title}
|
'grid',
|
||||||
<ControlBarTitle>
|
multiRow && 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]',
|
||||||
{#if typeof title === 'string'}
|
!multiRow && 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]',
|
||||||
{title}
|
'justify-between lg:grid-cols-[25%_50%_25%]',
|
||||||
{:else}
|
appBarBorder,
|
||||||
{@render title()}
|
'm-2 place-items-center rounded-full p-2 transition-all max-md:p-0',
|
||||||
{/if}
|
tailwindClasses,
|
||||||
</ControlBarTitle>
|
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-light-50 dark:bg-immich-dark-gray',
|
||||||
{/if}
|
]}
|
||||||
{@render leading?.()}
|
>
|
||||||
</ControlBarHeader>
|
<div class="flex place-items-center justify-self-start sm:gap-6 dark:text-immich-dark-fg {forceDark ? 'dark' : ''}">
|
||||||
{/if}
|
{#if showBackButton}
|
||||||
|
<IconButton
|
||||||
|
aria-label={$t('close')}
|
||||||
|
onclick={onClose}
|
||||||
|
color="secondary"
|
||||||
|
shape="round"
|
||||||
|
variant="ghost"
|
||||||
|
icon={backIcon}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{@render leading?.()}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if children}
|
<div class="w-full">
|
||||||
<ControlBarContent>
|
{@render children?.()}
|
||||||
{@render children()}
|
</div>
|
||||||
</ControlBarContent>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if trailing}
|
<div class="me-4 flex place-items-center gap-1 justify-self-end max-[350px]:me-0 max-[350px]:gap-0">
|
||||||
<ControlBarOverflow>
|
{@render trailing?.()}
|
||||||
{@render trailing()}
|
</div>
|
||||||
</ControlBarOverflow>
|
</nav>
|
||||||
{/if}
|
|
||||||
</ControlBar>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,18 +7,19 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
|
forceDark?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children, forceDark }: Props = $props();
|
||||||
|
|
||||||
const onClose = () => assetMultiSelectManager.clear();
|
const onClose = () => assetMultiSelectManager.clear();
|
||||||
|
|
||||||
const assets = $derived(assetMultiSelectManager.assets);
|
const assets = $derived(assetMultiSelectManager.assets);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ControlAppBar {onClose} backIcon={mdiClose}>
|
<ControlAppBar {onClose} {forceDark} backIcon={mdiClose} tailwindClasses="bg-white shadow-md">
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<div class="font-medium text-primary">
|
<div class="font-medium {forceDark ? 'text-immich-dark-primary' : '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>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
const { trigger, selectedKey, onClose }: Props = $props();
|
const { trigger, selectedKey, onClose }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BasicModal title={$t('add_step')} {onClose}>
|
<BasicModal title={$t('add_step')} {onClose} size="medium">
|
||||||
{#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="small">
|
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="medium">
|
||||||
<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="small">
|
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="medium">
|
||||||
<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="small">
|
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="medium">
|
||||||
<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)}>
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,10 @@
|
|||||||
<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';
|
||||||
@@ -76,8 +78,6 @@
|
|||||||
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 backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
|
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<ActionButton action={Cast} />
|
<ActionButton action={Cast} />
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
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';
|
||||||
@@ -34,7 +37,6 @@
|
|||||||
mdiChevronLeft,
|
mdiChevronLeft,
|
||||||
mdiChevronRight,
|
mdiChevronRight,
|
||||||
mdiChevronUp,
|
mdiChevronUp,
|
||||||
mdiClose,
|
|
||||||
mdiDotsVertical,
|
mdiDotsVertical,
|
||||||
mdiHeart,
|
mdiHeart,
|
||||||
mdiHeartOutline,
|
mdiHeartOutline,
|
||||||
@@ -52,8 +54,6 @@
|
|||||||
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="sticky top-0 z-1 dark">
|
<div class="dark sticky top-0 z-1">
|
||||||
<AssetSelectControlBar>
|
<AssetSelectControlBar forceDark>
|
||||||
{@const Actions = getAssetBulkActions($t)}
|
{@const Actions = getAssetBulkActions($t)}
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -365,33 +365,22 @@
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
id="memory-viewer"
|
id="memory-viewer"
|
||||||
class="dark w-full text-white bg-immich-dark-gray"
|
class="w-full 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}
|
||||||
<div
|
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
|
||||||
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"
|
{#snippet leading()}
|
||||||
>
|
{#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>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
{/snippet}
|
||||||
|
|
||||||
<div class="dark flex w-full place-content-center place-items-center gap-2">
|
<div class="dark flex place-content-center place-items-center gap-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -449,7 +438,7 @@
|
|||||||
</media-mute-button>
|
</media-mute-button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ControlAppBar>
|
||||||
|
|
||||||
{#if galleryInView}
|
{#if galleryInView}
|
||||||
<div
|
<div
|
||||||
@@ -473,7 +462,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Viewer -->
|
<!-- Viewer -->
|
||||||
<section class="overflow-hidden pt-6 md:pt-0" bind:clientHeight={viewerHeight}>
|
<section class="overflow-hidden pt-32 md:pt-20" 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)]"
|
||||||
>
|
>
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@
|
|||||||
<DownloadAction />
|
<DownloadAction />
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}>
|
<ControlAppBar showBackButton 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 } })}
|
||||||
|
|||||||
+4
-4
@@ -5,6 +5,9 @@
|
|||||||
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';
|
||||||
@@ -51,9 +54,6 @@
|
|||||||
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 backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
|
<ControlAppBar showBackButton 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,7 +387,8 @@
|
|||||||
{: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="mx-auto w-full max-w-2xl pe-2">
|
<div class="absolute bg-light"></div>
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -4,26 +4,24 @@
|
|||||||
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 } from '@mdi/js';
|
import { mdiClose, mdiDotsVertical, mdiFlashOutline } 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';
|
||||||
@@ -46,20 +44,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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({
|
||||||
@@ -92,12 +76,6 @@
|
|||||||
|
|
||||||
<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">
|
||||||
@@ -111,92 +89,77 @@
|
|||||||
class="mx-auto mt-10"
|
class="mx-auto mt-10"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="my-6 grid gap-6">
|
<div class="my-6 flex flex-col gap-3">
|
||||||
{#each workflows as workflow (workflow.id)}
|
{#each workflows as workflow (workflow.id)}
|
||||||
<Card class="border border-light-200">
|
<Card class="group shadow-none transition-colors hover:border-primary">
|
||||||
<CardHeader
|
<CardHeader>
|
||||||
class={`flex flex-row gap-4 px-8 py-6 sm:items-center sm:gap-6 ${
|
<a
|
||||||
workflow.enabled
|
href={Route.viewWorkflow({ id: workflow.id })}
|
||||||
? 'bg-linear-to-r from-green-50 to-white dark:from-green-800/50 dark:to-green-950/45'
|
class="flex items-center gap-4"
|
||||||
: 'bg-neutral-50 dark:bg-neutral-900'
|
class:opacity-55={!workflow.enabled}
|
||||||
}`}
|
>
|
||||||
>
|
<div
|
||||||
<div class="flex-1">
|
class={`flex size-11 shrink-0 items-center justify-center rounded-xl ${
|
||||||
<div class="flex items-center gap-3">
|
workflow.enabled
|
||||||
<span class="rounded-full {workflow.enabled ? 'size-3 bg-success' : 'size-3 rounded-full bg-muted'}"
|
? 'bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary'
|
||||||
></span>
|
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'
|
||||||
<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="flex items-center gap-4">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="hidden text-right sm:block">
|
<div class="flex items-center gap-2">
|
||||||
<Text size="tiny">{$t('created_at')}</Text>
|
<CardTitle class="truncate font-semibold text-dark group-hover:text-primary">
|
||||||
<Text size="small" fontWeight="medium">
|
{workflow.name || $t('workflow')}
|
||||||
{formatTimestamp(workflow.createdAt)}
|
</CardTitle>
|
||||||
</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) => showWorkflowMenu(event, workflow)}
|
onclick={(event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
showWorkflowMenu(event, workflow);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</a>
|
||||||
</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}
|
||||||
<VStack gap={2} class="w-full rounded-2xl border border-light-200 bg-light-50 p-4">
|
<div class="border-t border-gray-200 p-4 dark:border-gray-800">
|
||||||
<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)}>{$t('close')}</Button
|
onclick={() => toggleExpanded(workflow.id)}
|
||||||
>
|
>
|
||||||
</VStack>
|
{$t('close')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
</CardBody>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto, invalidate } from '$app/navigation';
|
import { beforeNavigate, 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 } from '@immich/sdk';
|
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
ActionBar,
|
ActionBar,
|
||||||
AppShell,
|
AppShell,
|
||||||
@@ -29,26 +29,40 @@
|
|||||||
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 HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
import WorkflowJsonEditor from './WorkflowJsonEditor.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;
|
||||||
@@ -57,6 +71,27 @@
|
|||||||
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 });
|
||||||
@@ -65,13 +100,90 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditStep = async (step: WorkflowStepDto) => {
|
const handleInsertStep = async (index: number) => {
|
||||||
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step });
|
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
|
||||||
if (result) {
|
if (step) {
|
||||||
Object.assign(step, result);
|
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) {
|
||||||
|
replaceStep(index, 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) {
|
||||||
@@ -80,11 +192,16 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClose = async () => {
|
const handleJsonContentChange = (content: WorkflowJsonContent) => {
|
||||||
// check for pending changes
|
enabled = content.enabled;
|
||||||
await goto(Route.workflows());
|
name = content.name;
|
||||||
|
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) {
|
||||||
@@ -95,163 +212,228 @@
|
|||||||
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 Done: ActionItem = {
|
const confirmNavigation = async () => {
|
||||||
title: $t('save'),
|
if (!hasChanges) {
|
||||||
icon: mdiContentSave,
|
return true;
|
||||||
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>
|
<AppShell class="">
|
||||||
<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 justify-end">
|
<ControlBarContent class="flex items-center justify-end gap-6">
|
||||||
<HeaderActionButton action={Done} variant="filled" />
|
<div class="flex gap-1 rounded-full border border-light-200 bg-light p-1" role="group">
|
||||||
|
<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}>
|
||||||
<Card expandable>
|
{#if editMode === 'visual'}
|
||||||
<CardHeader>
|
<Card class="shadow-none" expandable>
|
||||||
<div class="flex place-items-start gap-3">
|
<CardHeader>
|
||||||
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
|
<div class="flex place-items-start gap-3">
|
||||||
<div class="flex flex-col">
|
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
|
||||||
<CardTitle>
|
<div class="flex flex-col">
|
||||||
{$t('workflow_info')}
|
<CardTitle>
|
||||||
</CardTitle>
|
{$t('workflow_info')}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
</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>
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<Field label={$t('name')} required>
|
<div class="my-4 h-px w-[98%] bg-light-200"></div>
|
||||||
<Input
|
|
||||||
placeholder={$t('workflow_name')}
|
<Card class="shadow-none">
|
||||||
bind:value={() => name ?? '', (value) => (name = value || null)}
|
<CardHeader>
|
||||||
|
<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>
|
||||||
<div class="flex items-center justify-end">
|
</CardHeader>
|
||||||
<Button leadingIcon={mdiPencilOutline} size="small" color="secondary" onclick={onChangeTrigger}>
|
</Card>
|
||||||
{$t('edit')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody>
|
{#each steps as step, index (index)}
|
||||||
<div class="flex flex-col items-start">
|
<WorkflowStepCard
|
||||||
<Text>{getTriggerName($t, trigger)}</Text>
|
{step}
|
||||||
<Text size="small" color="muted">{getTriggerDescription($t, trigger)}</Text>
|
{index}
|
||||||
</div>
|
isDragging={draggedIndex === index}
|
||||||
</CardBody>
|
isDragHandleHovered={dragHandleHoverIndex === index}
|
||||||
</Card>
|
isDropTarget={dropTargetIndex === index && draggedIndex !== null && draggedIndex !== index}
|
||||||
|
onEdit={handleEditStep}
|
||||||
|
onDelete={handleDeleteStep}
|
||||||
|
onInsertBefore={handleInsertStep}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragHandleEnter={(i) => (dragHandleHoverIndex = i)}
|
||||||
|
onDragHandleLeave={() => (dragHandleHoverIndex = null)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
<Card>
|
<Button
|
||||||
<CardHeader class="bg-primary-50">
|
size="small"
|
||||||
<div class="flex items-start gap-3">
|
fullWidth
|
||||||
<Icon icon={mdiFormatListBulletedSquare} size="20" class="mt-1 text-primary" />
|
variant="ghost"
|
||||||
<CardTitle class="text-left text-primary">{$t('steps')}</CardTitle>
|
leadingIcon={mdiPlus}
|
||||||
</div>
|
class="border border-dashed"
|
||||||
</CardHeader>
|
onclick={handleAddStep}
|
||||||
|
>
|
||||||
<CardBody>
|
{$t('add_step')}
|
||||||
{#if steps.length === 0}
|
</Button>
|
||||||
<Button leadingIcon={mdiPlus} onclick={handleAddStep}>{$t('add_step')}</Button>
|
{:else}
|
||||||
{:else}
|
<WorkflowJsonEditor jsonContent={workflowJsonContent} onContentChange={handleJsonContentChange} />
|
||||||
<Stack gap={2}>
|
{/if}
|
||||||
{#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 { searchWorkflows } from '@immich/sdk';
|
import { getWorkflow } 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([searchWorkflows({ id: params.workflowId }), pluginManager.ready()]);
|
const [workflow] = await Promise.all([getWorkflow({ id: params.workflowId }), pluginManager.ready()]);
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
if (!workflow) {
|
if (!workflow) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { WorkflowResponseDto } from '@immich/sdk';
|
import { WorkflowTrigger, type WorkflowStepDto, type WorkflowUpdateDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
@@ -13,40 +12,91 @@
|
|||||||
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: WorkflowResponseDto;
|
jsonContent: WorkflowJsonContent;
|
||||||
onApply: () => void;
|
onContentChange: (content: WorkflowJsonContent) => void;
|
||||||
onContentChange: (content: WorkflowResponseDto) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let { jsonContent, onApply, onContentChange }: Props = $props();
|
let { jsonContent, onContentChange }: Props = $props();
|
||||||
|
|
||||||
let content: Content = $derived({ json: jsonContent });
|
let content: Content = $state({ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
canApply = true;
|
const parsed = parseContent(updated);
|
||||||
|
if (!isWorkflowJsonContent(parsed)) {
|
||||||
if ('text' in updated && updated.text !== undefined) {
|
return;
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(updated.text);
|
|
||||||
onContentChange(parsed);
|
|
||||||
} catch (error_) {
|
|
||||||
console.error('Invalid JSON in text mode:', error_);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleApply = () => {
|
onContentChange(parsed);
|
||||||
onApply();
|
|
||||||
canApply = false;
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -57,17 +107,16 @@
|
|||||||
<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>Workflow JSON</CardTitle>
|
<CardTitle>{$t('workflow_json')}</CardTitle>
|
||||||
<CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
|
<CardDescription>{$t('workflow_json_help')}</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 {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
|
<JSONEditor bind:content onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
|
||||||
</div>
|
</div>
|
||||||
</VStack>
|
</VStack>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<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,137 +1,176 @@
|
|||||||
<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 { WorkflowResponseDto } from '@immich/sdk';
|
import type { WorkflowStepDto, WorkflowTrigger } from '@immich/sdk';
|
||||||
import { Icon, IconButton, Text } from '@immich/ui';
|
import { Icon, IconButton, Text } from '@immich/ui';
|
||||||
import { mdiClose, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
|
import { mdiCheck, mdiClose, mdiContentCopy, 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: WorkflowResponseDto;
|
workflow: WorkflowSummaryData;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { workflow }: Props = $props();
|
let { workflow }: Props = $props();
|
||||||
const { trigger, steps } = $derived(workflow);
|
|
||||||
|
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
let position = $state({ x: 0, y: 0 });
|
let justCopied = $state(false);
|
||||||
let isDragging = $state(false);
|
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let dragOffset = $state({ x: 0, y: 0 });
|
let panelElement = $state<HTMLElement | undefined>(undefined);
|
||||||
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(() => {
|
$effect(() => {
|
||||||
// Initialize position to bottom-right on mount
|
if (!isOpen) {
|
||||||
if (globalThis.window && position.x === 0 && position.y === 0) {
|
return;
|
||||||
position = {
|
|
||||||
x: globalThis.innerWidth - 280,
|
|
||||||
y: globalThis.innerHeight - 400,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
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']) =>
|
||||||
|
Object.entries(config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== '');
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<aside
|
||||||
<div
|
bind:this={panelElement}
|
||||||
bind:this={containerEl}
|
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"
|
||||||
class="fixed hidden w-64 select-none hover:cursor-grab sm:block"
|
transition:fly={{ x: 400, duration: 250 }}
|
||||||
style="left: {position.x}px; top: {position.y}px;"
|
aria-label={$t('workflow_summary')}
|
||||||
class:cursor-grabbing={isDragging}
|
|
||||||
onmousedown={handleMouseDown}
|
|
||||||
>
|
>
|
||||||
<div
|
<!-- Header -->
|
||||||
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"
|
<div class="flex shrink-0 items-center justify-between border-b border-light-200 px-4 py-2.5">
|
||||||
>
|
<Text size="small" fontWeight="semi-bold" color="muted">{$t('workflow_summary')}</Text>
|
||||||
<div class="mb-4 flex cursor-grab items-center justify-between select-none">
|
<div class="flex items-center gap-1">
|
||||||
<Text size="small" fontWeight="semi-bold">{$t('workflow_summary')}</Text>
|
<IconButton
|
||||||
<div class="flex items-center gap-1">
|
icon={justCopied ? mdiCheck : mdiContentCopy}
|
||||||
<IconButton
|
size="small"
|
||||||
icon={mdiClose}
|
variant="ghost"
|
||||||
size="small"
|
color={justCopied ? 'success' : 'secondary'}
|
||||||
variant="ghost"
|
title={$t('copy_to_clipboard')}
|
||||||
color="secondary"
|
aria-label={$t('copy_to_clipboard')}
|
||||||
title="Close summary"
|
onclick={handleCopy}
|
||||||
aria-label="Close summary"
|
/>
|
||||||
onclick={(e: MouseEvent) => {
|
<IconButton
|
||||||
e.stopPropagation();
|
icon={mdiClose}
|
||||||
isOpen = false;
|
size="small"
|
||||||
}}
|
variant="ghost"
|
||||||
/>
|
color="secondary"
|
||||||
</div>
|
title="Close summary"
|
||||||
</div>
|
aria-label="Close summary"
|
||||||
|
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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user