feat: workflow ui

This commit is contained in:
Alex Tran
2025-11-15 18:35:06 +00:00
parent 4dcc049465
commit 272ad7c773
34 changed files with 2099 additions and 142 deletions

View File

@@ -60,12 +60,12 @@
{/if}
<main class="relative">
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2 z-10" use:useActions={use}>
{@render children?.()}
</div>
{#if title || buttons}
<div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark">
<div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark z-10">
<div class="flex gap-2 items-center">
{#if title}
<div class="font-medium outline-none pe-8" tabindex="-1" id={headerId}>{title}</div>

View File

@@ -4,6 +4,7 @@
import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
import { Icon, modalManager } from '@immich/ui';
import {
mdiAutoFix,
mdiCellphoneArrowDownVariant,
mdiContentDuplicate,
mdiCrosshairsGps,
@@ -16,6 +17,7 @@
{ href: AppRoute.DUPLICATES, icon: mdiContentDuplicate, label: $t('review_duplicates') },
{ href: AppRoute.LARGE_FILES, icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
{ href: AppRoute.GEOLOCATION, icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
{ href: AppRoute.WORKFLOWS, icon: mdiAutoFix, label: $t('workflow') },
];
</script>

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import { type PluginResponseDto, PluginContext } from '@immich/sdk';
import { Button, Field, Icon, IconButton } from '@immich/ui';
import { mdiChevronDown, mdiChevronUp, mdiClose, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import SchemaFormFields from './schema-form/SchemaFormFields.svelte';
interface Props {
actions: Array<{ actionId: string; actionConfig?: object }>;
triggerType: 'AssetCreate' | 'PersonRecognized';
plugins: PluginResponseDto[];
}
let { actions = $bindable([]), triggerType, plugins }: Props = $props();
// Map trigger type to context
const getTriggerContext = (trigger: string): PluginContext => {
const contextMap: Record<string, PluginContext> = {
AssetCreate: PluginContext.Asset,
PersonRecognized: PluginContext.Person,
};
return contextMap[trigger] || PluginContext.Asset;
};
const triggerContext = $derived(getTriggerContext(triggerType));
// Get all available actions that match the trigger context
const availableActions = $derived(
plugins.flatMap((plugin) => plugin.actions.filter((action) => action.supportedContexts.includes(triggerContext))),
);
const addAction = () => {
if (availableActions.length > 0) {
actions = [...actions, { actionId: availableActions[0].id, actionConfig: {} }];
}
};
const removeAction = (index: number) => {
actions = actions.filter((_, i) => i !== index);
};
const moveUp = (index: number) => {
if (index > 0) {
const newActions = [...actions];
[newActions[index - 1], newActions[index]] = [newActions[index], newActions[index - 1]];
actions = newActions;
}
};
const moveDown = (index: number) => {
if (index < actions.length - 1) {
const newActions = [...actions];
[newActions[index], newActions[index + 1]] = [newActions[index + 1], newActions[index]];
actions = newActions;
}
};
const getActionById = (actionId: string) => {
for (const plugin of plugins) {
const action = plugin.actions.find((a) => a.id === actionId);
if (action) {
return action;
}
}
return null;
};
</script>
{#if actions.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
{$t('no_actions_added')}
</div>
{:else}
<div class="flex flex-col gap-3">
{#each actions as action, index (index)}
{@const actionDef = getActionById(action.actionId)}
<div class="rounded-lg border border-gray-300 p-3 dark:border-gray-700">
<div class="mb-2 flex items-center justify-between">
<div class="flex-1">
<Field label={$t('action')}>
<select
bind:value={action.actionId}
class="immich-form-input w-full"
onchange={() => {
action.actionConfig = {};
}}
>
{#each availableActions as availAction (availAction.id)}
<option value={availAction.id}>{availAction.title}</option>
{/each}
</select>
</Field>
</div>
<div class="ml-2 flex gap-1">
<IconButton
shape="round"
color="secondary"
icon={mdiChevronUp}
aria-label={$t('move_up')}
onclick={() => moveUp(index)}
disabled={index === 0}
size="small"
/>
<IconButton
shape="round"
color="secondary"
icon={mdiChevronDown}
aria-label={$t('move_down')}
onclick={() => moveDown(index)}
disabled={index === actions.length - 1}
size="small"
/>
<IconButton
shape="round"
color="secondary"
icon={mdiClose}
aria-label={$t('remove')}
onclick={() => removeAction(index)}
size="small"
/>
</div>
</div>
{#if actionDef}
<div class="text-xs text-gray-600 dark:text-gray-400 mb-2">
{actionDef.description}
</div>
{#if actionDef.schema}
<SchemaFormFields schema={actionDef.schema} bind:config={action.actionConfig} />
{/if}
{/if}
</div>
{/each}
</div>
{/if}
<Button shape="round" size="small" onclick={addAction} disabled={availableActions.length === 0} class="mt-2">
<Icon icon={mdiPlus} size="18" />
{$t('add_action')}
</Button>

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import { type PluginResponseDto, PluginContext } from '@immich/sdk';
import { Button, Field, Icon, IconButton } from '@immich/ui';
import { mdiChevronDown, mdiChevronUp, mdiClose, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import SchemaFormFields from './schema-form/SchemaFormFields.svelte';
interface Props {
filters: Array<{ filterId: string; filterConfig?: object }>;
triggerType: 'AssetCreate' | 'PersonRecognized';
plugins: PluginResponseDto[];
}
let { filters = $bindable([]), triggerType, plugins }: Props = $props();
// Map trigger type to context
const getTriggerContext = (trigger: string): PluginContext => {
const contextMap: Record<string, PluginContext> = {
AssetCreate: PluginContext.Asset,
PersonRecognized: PluginContext.Person,
};
return contextMap[trigger] || PluginContext.Asset;
};
const triggerContext = $derived(getTriggerContext(triggerType));
// Get all available filters that match the trigger context
const availableFilters = $derived(
plugins.flatMap((plugin) => plugin.filters.filter((filter) => filter.supportedContexts.includes(triggerContext))),
);
const addFilter = () => {
if (availableFilters.length > 0) {
filters = [...filters, { filterId: availableFilters[0].id, filterConfig: {} }];
}
};
const removeFilter = (index: number) => {
filters = filters.filter((_, i) => i !== index);
};
const moveUp = (index: number) => {
if (index > 0) {
const newFilters = [...filters];
[newFilters[index - 1], newFilters[index]] = [newFilters[index], newFilters[index - 1]];
filters = newFilters;
}
};
const moveDown = (index: number) => {
if (index < filters.length - 1) {
const newFilters = [...filters];
[newFilters[index], newFilters[index + 1]] = [newFilters[index + 1], newFilters[index]];
filters = newFilters;
}
};
const getFilterById = (filterId: string) => {
for (const plugin of plugins) {
const filter = plugin.filters.find((f) => f.id === filterId);
if (filter) {
return filter;
}
}
return null;
};
</script>
{#if filters.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
{$t('no_filters_added')}
</div>
{:else}
<div class="flex flex-col gap-3">
{#each filters as filter, index (index)}
{@const filterDef = getFilterById(filter.filterId)}
<div class="rounded-lg border border-gray-300 p-3 dark:border-gray-700">
<div class="mb-2 flex items-center justify-between">
<div class="flex-1">
<Field label={$t('filter')}>
<select
bind:value={filter.filterId}
class="immich-form-input w-full"
onchange={() => {
filter.filterConfig = {};
}}
>
{#each availableFilters as availFilter (availFilter.id)}
<option value={availFilter.id}>{availFilter.title}</option>
{/each}
</select>
</Field>
</div>
<div class="ml-2 flex gap-1">
<IconButton
shape="round"
color="secondary"
icon={mdiChevronUp}
aria-label={$t('move_up')}
onclick={() => moveUp(index)}
disabled={index === 0}
size="small"
/>
<IconButton
shape="round"
color="secondary"
icon={mdiChevronDown}
aria-label={$t('move_down')}
onclick={() => moveDown(index)}
disabled={index === filters.length - 1}
size="small"
/>
<IconButton
shape="round"
color="secondary"
icon={mdiClose}
aria-label={$t('remove')}
onclick={() => removeFilter(index)}
size="small"
/>
</div>
</div>
{#if filterDef}
<div class="text-xs text-gray-600 dark:text-gray-400 mb-2">
{filterDef.description}
</div>
{#if filterDef.schema}
<SchemaFormFields schema={filterDef.schema} bind:config={filter.filterConfig} />
{/if}
{/if}
</div>
{/each}
</div>
{/if}
<Button shape="round" size="small" onclick={addFilter} disabled={availableFilters.length === 0} class="mt-2">
<Icon icon={mdiPlus} size="18" />
{$t('add_filter')}
</Button>

View File

@@ -0,0 +1,326 @@
<script lang="ts">
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { formatLabel, getComponentFromSchema } from '$lib/utils/workflow';
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import { Button, Field, Input, MultiSelect, Select, Switch, modalManager, type SelectItem } from '@immich/ui';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
schema: object | null;
config: Record<string, unknown>;
configKey?: string;
}
let { schema = null, config = $bindable({}), configKey }: Props = $props();
const components = $derived(getComponentFromSchema(schema));
// Get the actual config object to work with
const actualConfig = $derived(configKey ? (config[configKey] as Record<string, unknown>) || {} : config);
// Update function that handles nested config
const updateConfig = (key: string, value: unknown) => {
config = configKey ? { ...config, [configKey]: { ...actualConfig, [key]: value } } : { ...config, [key]: value };
};
const updateConfigBatch = (updates: Record<string, unknown>) => {
config = configKey ? { ...config, [configKey]: { ...actualConfig, ...updates } } : { ...config, ...updates };
};
let selectValue = $state<SelectItem>();
let switchValue = $state<boolean>(false);
let multiSelectValue = $state<SelectItem[]>([]);
let pickerMetadata = $state<
Record<string, AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]>
>({});
$effect(() => {
// Initialize config for actions/filters with empty schemas
if (configKey && !config[configKey]) {
config = { ...config, [configKey]: {} };
}
if (components) {
const updates: Record<string, unknown> = {};
for (const [key, component] of Object.entries(components)) {
// Only initialize if the key doesn't exist in config yet
if (actualConfig[key] === undefined) {
// Use default value if available, otherwise use appropriate empty value based on type
const hasDefault = component.defaultValue !== undefined;
if (hasDefault) {
updates[key] = component.defaultValue;
} else {
// Initialize with appropriate empty value based on component type
if (
component.type === 'multiselect' ||
(component.type === 'text' && component.subType === 'people-picker')
) {
updates[key] = [];
} else if (component.type === 'switch') {
updates[key] = false;
} else {
updates[key] = '';
}
}
// Update UI state for components with default values
if (hasDefault) {
if (component.type === 'select') {
selectValue = {
label: formatLabel(String(component.defaultValue)),
value: String(component.defaultValue),
};
}
if (component.type === 'switch') {
switchValue = Boolean(component.defaultValue);
}
}
}
}
if (Object.keys(updates).length > 0) {
updateConfigBatch(updates);
}
}
});
const handleAlbumPicker = async (key: string, multiple: boolean) => {
const albums = await modalManager.show(AlbumPickerModal, { shared: false });
if (albums && albums.length > 0) {
const value = multiple ? albums.map((a) => a.id) : albums[0].id;
updateConfig(key, value);
pickerMetadata = {
...pickerMetadata,
[key]: multiple ? albums : albums[0],
};
}
};
const handlePeoplePicker = async (key: string, multiple: boolean) => {
const people = await modalManager.show(PeoplePickerModal, { multiple });
if (people && people.length > 0) {
const value = multiple ? people.map((p) => p.id) : people[0].id;
updateConfig(key, value);
pickerMetadata = {
...pickerMetadata,
[key]: multiple ? people : people[0],
};
}
};
const removeSelection = (key: string) => {
const { [key]: _, ...rest } = actualConfig;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [key]: _removed, ...restMetadata } = pickerMetadata;
config = configKey ? { ...config, [configKey]: rest } : rest;
pickerMetadata = restMetadata;
};
const removeItemFromSelection = (key: string, itemId: string) => {
const currentIds = actualConfig[key] as string[];
const currentMetadata = pickerMetadata[key] as (AlbumResponseDto | PersonResponseDto)[];
updateConfig(
key,
currentIds.filter((id) => id !== itemId),
);
pickerMetadata = {
...pickerMetadata,
[key]: currentMetadata.filter((item) => item.id !== itemId) as AlbumResponseDto[] | PersonResponseDto[],
};
};
const renderPicker = (subType: 'album-picker' | 'people-picker', multiple: boolean) => {
const isAlbum = subType === 'album-picker';
const handler = isAlbum ? handleAlbumPicker : handlePeoplePicker;
const selectSingleLabel = isAlbum ? 'select_album' : 'select_person';
const selectMultiLabel = isAlbum ? 'select_albums' : 'select_people';
const buttonText = multiple ? $t(selectMultiLabel) : $t(selectSingleLabel);
return { handler, buttonText };
};
</script>
{#snippet pickerItemCard(
item: AlbumResponseDto | PersonResponseDto,
isAlbum: boolean,
size: 'large' | 'small',
onRemove: () => void,
)}
{@const sizeClass = size === 'large' ? 'h-16 w-16' : 'h-12 w-12'}
{@const textSizeClass = size === 'large' ? 'font-medium' : 'font-medium text-sm'}
{@const iconSizeClass = size === 'large' ? 'h-5 w-5' : 'h-4 w-4'}
{@const countSizeClass = size === 'large' ? 'text-sm' : 'text-xs'}
<div
class="flex items-center gap-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-3 shadow-sm"
>
<div class="shrink-0">
{#if isAlbum && 'albumThumbnailAssetId' in item}
{#if item.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
alt={item.albumName}
class="{sizeClass} rounded-lg object-cover"
/>
{:else}
<div class="{sizeClass} rounded-lg bg-gray-200 dark:bg-gray-700"></div>
{/if}
{:else if !isAlbum && 'name' in item}
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="{sizeClass} rounded-full object-cover" />
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="{textSizeClass} text-gray-900 dark:text-gray-100 truncate">
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
</p>
{#if isAlbum && 'assetCount' in item}
<p class="{countSizeClass} text-gray-500 dark:text-gray-400">
{$t('items_count', { values: { count: item.assetCount } })}
</p>
{/if}
</div>
<button
type="button"
onclick={onRemove}
class="shrink-0 rounded-full p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={$t('remove')}
>
<svg class={iconSizeClass} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/snippet}
{#snippet pickerField(
subType: string,
key: string,
label: string,
component: { required?: boolean; description?: string },
multiple: boolean,
)}
{@const picker = renderPicker(subType as 'album-picker' | 'people-picker', multiple)}
{@const metadata = pickerMetadata[key]}
{@const isAlbum = subType === 'album-picker'}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<div class="flex flex-col gap-3">
{#if metadata && !Array.isArray(metadata)}
{@render pickerItemCard(metadata, isAlbum, 'large', () => removeSelection(key))}
{:else if metadata && Array.isArray(metadata) && metadata.length > 0}
<div class="flex flex-col gap-2">
{#each metadata as item (item.id)}
{@render pickerItemCard(item, isAlbum, 'small', () => removeItemFromSelection(key, item.id))}
{/each}
</div>
{/if}
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={() => picker.handler(key, multiple)}>
{picker.buttonText}
</Button>
</div>
</Field>
{/snippet}
{#if components}
<div class="flex flex-col gap-2">
{#each Object.entries(components) as [key, component] (key)}
{@const label = component.title || component.label || key}
<div
class="flex flex-col gap-1 bg-gray-50 dark:bg-subtle border border-gray-200 dark:border-gray-700 p-4 rounded-xl"
>
<!-- Select component -->
{#if component.type === 'select'}
{#if component.subType === 'album-picker' || component.subType === 'people-picker'}
{@render pickerField(component.subType, key, label, component, false)}
{:else}
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<Select data={options} onChange={(opt) => updateConfig(key, opt.value)} bind:value={selectValue} />
</Field>
{/if}
<!-- MultiSelect component -->
{:else if component.type === 'multiselect'}
{#if component.subType === 'album-picker' || component.subType === 'people-picker'}
{@render pickerField(component.subType, key, label, component, true)}
{:else}
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<MultiSelect
data={options}
onChange={(opt) =>
updateConfig(
key,
opt.map((o) => o.value),
)}
bind:values={multiSelectValue}
/>
</Field>
{/if}
<!-- Switch component -->
{:else if component.type === 'switch'}
<Field
{label}
description={component.description}
requiredIndicator={component.required}
required={component.required}
>
<Switch bind:checked={switchValue} onCheckedChange={(check) => updateConfig(key, check)} />
</Field>
<!-- Text input -->
{:else if component.subType === 'album-picker' || component.subType === 'people-picker'}
{@render pickerField(component.subType, key, label, component, false)}
{:else}
<Field
{label}
description={component.description}
requiredIndicator={component.required}
required={component.required}
>
<Input
id={key}
value={actualConfig[key] as string}
oninput={(e) => updateConfig(key, e.currentTarget.value)}
required={component.required}
/>
</Field>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-sm text-gray-500">No configuration required</p>
{/if}

View File

@@ -0,0 +1,43 @@
<script lang="ts">
interface Props {
animated?: boolean;
}
let { animated = true }: Props = $props();
</script>
<div class="flex justify-center py-2">
<div class="relative h-12 w-0.5">
<div class="absolute inset-0 bg-linear-to-b from-primary/30 via-primary/50 to-primary/30"></div>
{#if animated}
<div class="absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent flow-pulse"></div>
{/if}
<!-- Connection nodes -->
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
</div>
<div class="absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2">
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
</div>
</div>
</div>
<style>
@keyframes flow {
0% {
transform: translateY(-25%);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: translateY(25%);
opacity: 0;
}
}
.flow-pulse {
animation: flow 2s ease-in-out infinite;
}
</style>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import { PluginTriggerType, type PluginTriggerResponseDto } from '@immich/sdk';
import { Icon, Text } from '@immich/ui';
import { mdiFaceRecognition, mdiFileUploadOutline, mdiLightningBolt } from '@mdi/js';
interface Props {
trigger: PluginTriggerResponseDto;
selected: boolean;
onclick: () => void;
}
let { trigger, selected, onclick }: Props = $props();
const getTriggerIcon = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return mdiFileUploadOutline;
}
case PluginTriggerType.PersonRecognized: {
return mdiFaceRecognition;
}
default: {
return mdiLightningBolt;
}
}
};
</script>
<button
type="button"
{onclick}
class="rounded-xl p-4 w-full text-left transition-all cursor-pointer border-2 {selected
? 'border-primary text-primary'
: 'border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-200'}"
>
<div class="flex items-center gap-3">
<div
class="rounded-xl p-2 bg-gray-200 {selected
? 'bg-primary text-light'
: 'text-gray-400 dark:text-gray-400 dark:bg-gray-900'}"
>
<Icon icon={getTriggerIcon(trigger.triggerType)} size="24" />
</div>
<div class="flex-1">
<Text class="font-semibold mb-1">{trigger.name}</Text>
{#if trigger.description}
<Text class="text-sm text-muted-foreground">{trigger.description}</Text>
{/if}
</div>
</div>
</button>