mirror of
https://github.com/immich-app/immich.git
synced 2026-05-24 00:22:29 -04:00
feat: workflows & plugins (#26727)
feat: plugins chore: better types feat: plugins
This commit is contained in:
@@ -1,18 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { Button } from '@immich/ui';
|
||||
import { Button, type Variants } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
action: HeaderButtonActionItem;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
const { action }: Props = $props();
|
||||
const { action, variant }: Props = $props();
|
||||
const { title, icon, color = 'secondary', onAction } = $derived(action);
|
||||
</script>
|
||||
|
||||
{#if action.$if?.() ?? true}
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant={variant ?? 'ghost'}
|
||||
size="small"
|
||||
{color}
|
||||
leadingIcon={icon}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import AlbumThumbnail from '$lib/components/album-page/AlbumThumbnail.svelte';
|
||||
import { Button, Text, Label, modalManager } from '@immich/ui';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
description?: string;
|
||||
albumIds: string[];
|
||||
array?: boolean;
|
||||
};
|
||||
|
||||
let { array, label, description, albumIds = $bindable([]) }: Props = $props();
|
||||
|
||||
const onAlbums = async () => {
|
||||
const albums = await modalManager.show(AlbumPickerModal);
|
||||
if (!albums || albums.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
albumIds = array ? [...albumIds, ...albums.map((album) => album.id)] : [albums[0].id];
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet button()}
|
||||
<Button size="small" shape="round" color="secondary" onclick={() => onAlbums()}>{$t('choose')}</Button>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Label for="album-picker" size="small" class="font-medium" {label} />
|
||||
{#if description}
|
||||
<Text color="muted" size="small">{description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if array}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each albumIds as albumId, i (i)}
|
||||
<AlbumThumbnail
|
||||
{albumId}
|
||||
onDelete={() => {
|
||||
albumIds.splice(i, 1);
|
||||
albumIds = [...albumIds];
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{@render button()}
|
||||
</div>
|
||||
{:else}
|
||||
{@const albumId = albumIds[0]}
|
||||
{#if albumId}
|
||||
<AlbumThumbnail {albumId} onDelete={() => (albumIds = [])} />
|
||||
{:else}
|
||||
{@render button()}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import SchemaAlbumPicker from '$lib/components/SchemaAlbumPicker.svelte';
|
||||
import Self from '$lib/components/SchemaConfiguration.svelte';
|
||||
import type { JSONSchemaProperty, SchemaConfig } from '$lib/types';
|
||||
import { CodeBlock, Field, Input, Label, MultiSelect, NumberInput, Select, Switch, Text } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
schema: JSONSchemaProperty;
|
||||
root?: boolean;
|
||||
key?: string;
|
||||
config: SchemaConfig;
|
||||
};
|
||||
|
||||
let { schema, key = '', root = false, config = $bindable() }: Props = $props();
|
||||
|
||||
const label = $derived(schema.title ?? key);
|
||||
const description = $derived(schema.description);
|
||||
|
||||
const getValue = <T,>(defaultValue?: T) => (root === true ? config : (config?.[key] ?? defaultValue)) as T;
|
||||
const setValue = <T,>(value: T) => {
|
||||
if (root === true) {
|
||||
config = value;
|
||||
} else {
|
||||
if (config === undefined) {
|
||||
config = {};
|
||||
}
|
||||
|
||||
config[key] = value;
|
||||
}
|
||||
};
|
||||
|
||||
const getUiHintValue = () => {
|
||||
if (schema.array) {
|
||||
return getValue<string[]>([]);
|
||||
}
|
||||
|
||||
const values = getValue<string>();
|
||||
return values ? [values] : [];
|
||||
};
|
||||
|
||||
const setUiHintValue = (values: string[]) => setValue(schema.array ? values : values[0]);
|
||||
|
||||
const getBoolean = (defaultValue = false) => getValue<boolean>(defaultValue);
|
||||
const getString = () => getValue<string>();
|
||||
const getEnum = () => getValue<string[]>([]);
|
||||
const getNumber = () => getValue<number>();
|
||||
</script>
|
||||
|
||||
<!-- Empty schema object -->
|
||||
{#if Object.keys(schema).length === 0}
|
||||
<!-- noop -->
|
||||
<!-- nested configuration (also top level objects) -->
|
||||
{:else if schema.type === 'object'}
|
||||
{#if !root}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label size="small" class="font-medium" {label}></Label>
|
||||
{#if description}
|
||||
<Text color="muted" size="small">{description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-4 {root ? '' : 'border-l-4 border-gray-200 ps-2'}">
|
||||
{#each Object.entries(schema.properties ?? {}) as [childKey, childSchema] (childKey)}
|
||||
<Self schema={childSchema} key={childKey} bind:config={getValue, setValue} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if schema.uiHint === 'albumId'}
|
||||
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
|
||||
{:else if schema.enum && schema.array}
|
||||
<Field {label} {description}>
|
||||
<MultiSelect options={schema.enum} bind:values={getEnum, setValue} />
|
||||
</Field>
|
||||
{:else if schema.enum}
|
||||
<Field {label} {description}>
|
||||
<Select options={schema.enum} bind:value={getString, setValue} />
|
||||
</Field>
|
||||
{:else if schema.array}
|
||||
<!-- {@const values = getValue<string[]>([])}
|
||||
<Field {label} {description}>
|
||||
{#each values as value, i (i)}
|
||||
<Input bind:value={() => getValue<string>(), setValue} />
|
||||
{/each}
|
||||
</Field> -->
|
||||
{:else if schema.type === 'boolean'}
|
||||
<Field {label} {description}>
|
||||
<Switch bind:checked={() => getBoolean(schema.default ?? false), setValue} />
|
||||
</Field>
|
||||
{:else if schema.type === 'number'}
|
||||
<Field {label} {description}>
|
||||
<NumberInput bind:value={getNumber, setValue} />
|
||||
</Field>
|
||||
{:else if schema.type === 'string'}
|
||||
<Field {label} {description}>
|
||||
<Input bind:value={() => getValue<string>(), setValue} />
|
||||
</Field>
|
||||
{:else}
|
||||
<Text>Unknown schema</Text>
|
||||
<CodeBlock code={JSON.stringify(schema, null, 2)} />
|
||||
{/if}
|
||||
@@ -23,7 +23,7 @@
|
||||
showDateRange = false,
|
||||
showItemCount = false,
|
||||
preload = false,
|
||||
onShowContextMenu = undefined,
|
||||
onShowContextMenu,
|
||||
}: Props = $props();
|
||||
|
||||
const showAlbumContextMenu = (e: MouseEvent) => {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { getAlbumInfo } from '@immich/sdk';
|
||||
import { IconButton, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiTrashCanOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
albumId: string;
|
||||
onDelete: () => unknown;
|
||||
}
|
||||
|
||||
let { albumId, onDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#await getAlbumInfo({ ...authManager.params, id: albumId })}
|
||||
<LoadingSpinner />
|
||||
{:then album}
|
||||
<div class="flex gap-2">
|
||||
<AlbumCover {album} class="size-24" />
|
||||
<p
|
||||
class="line-clamp-2 grow text-lg/6 font-semibold text-black group-hover:text-primary dark:text-white"
|
||||
data-testid="album-name"
|
||||
title={album.albumName}
|
||||
>
|
||||
{album.albumName}
|
||||
</p>
|
||||
{#if album.description}
|
||||
<p
|
||||
class="line-clamp-2 grow text-lg/6 font-semibold text-black group-hover:text-primary dark:text-white"
|
||||
data-testid="album-name"
|
||||
>
|
||||
{album.description}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="">
|
||||
<IconButton
|
||||
icon={mdiTrashCanOutline}
|
||||
shape="round"
|
||||
color="danger"
|
||||
variant="ghost"
|
||||
onclick={onDelete}
|
||||
aria-label={$t('remove')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
getWorkflowTriggers,
|
||||
searchPluginMethods,
|
||||
WorkflowTrigger,
|
||||
type PluginMethodResponseDto,
|
||||
type WorkflowTriggerResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
|
||||
class PluginManager {
|
||||
#loading: Promise<void> | undefined;
|
||||
#methodMap = new SvelteMap<string, PluginMethodResponseDto>();
|
||||
#methods = $state<PluginMethodResponseDto[]>([]);
|
||||
#triggers = $state<WorkflowTriggerResponseDto[]>([]);
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
AuthLogout: () => this.clearCache(),
|
||||
AuthUserLoaded: () => this.initialize(),
|
||||
});
|
||||
|
||||
// loaded event might have already happened
|
||||
if (authManager.authenticated) {
|
||||
void this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
get triggers() {
|
||||
return this.#triggers;
|
||||
}
|
||||
|
||||
ready() {
|
||||
return this.initialize();
|
||||
}
|
||||
|
||||
getMethod(key: string) {
|
||||
return this.#methodMap.get(key);
|
||||
}
|
||||
|
||||
getMethodLabel(key: string) {
|
||||
const method = this.getMethod(key);
|
||||
return method?.title ?? get(t)('unknown');
|
||||
}
|
||||
|
||||
getTrigger(trigger: WorkflowTrigger) {
|
||||
const result = this.#triggers.find((t) => t.trigger === trigger);
|
||||
|
||||
if (!result) {
|
||||
throw new Error(`Unknown trigger type: ${trigger}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private clearCache() {
|
||||
this.#loading = undefined;
|
||||
this.#methodMap = new SvelteMap();
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
if (!this.#loading) {
|
||||
this.#loading = this.load();
|
||||
}
|
||||
|
||||
return this.#loading;
|
||||
}
|
||||
|
||||
private async load() {
|
||||
const [methods, triggers] = await Promise.all([searchPluginMethods({}), getWorkflowTriggers()]);
|
||||
|
||||
this.#methods = methods;
|
||||
for (const method of this.#methods) {
|
||||
this.#methodMap.set(method.key, method);
|
||||
}
|
||||
|
||||
this.#triggers = triggers;
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginManager = new PluginManager();
|
||||
@@ -1,80 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PluginActionResponseDto, PluginFilterResponseDto } from '@immich/sdk';
|
||||
import { Modal, ModalBody, Text } from '@immich/ui';
|
||||
import { mdiFilterOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
filters: PluginFilterResponseDto[];
|
||||
actions: PluginActionResponseDto[];
|
||||
onClose: (result?: { type: 'filter' | 'action'; item: PluginFilterResponseDto | PluginActionResponseDto }) => void;
|
||||
type?: 'filter' | 'action';
|
||||
};
|
||||
|
||||
let { filters, actions, onClose, type }: Props = $props();
|
||||
|
||||
type StepType = 'filter' | 'action';
|
||||
|
||||
const handleSelect = (type: StepType, item: PluginFilterResponseDto | PluginActionResponseDto) => {
|
||||
onClose({ type, item });
|
||||
};
|
||||
|
||||
const getModalTitle = () => {
|
||||
if (type === 'filter') {
|
||||
return $t('add_filter');
|
||||
} else if (type === 'action') {
|
||||
return $t('add_action');
|
||||
} else {
|
||||
return $t('add_workflow_step');
|
||||
}
|
||||
};
|
||||
|
||||
const getModalIcon = () => {
|
||||
if (type === 'filter') {
|
||||
return mdiFilterOutline;
|
||||
} else if (type === 'action') {
|
||||
return mdiPlayCircleOutline;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet stepButton(title: string, description?: string, onclick?: () => void)}
|
||||
<button
|
||||
type="button"
|
||||
{onclick}
|
||||
class="flex items-start gap-3 rounded-lg border bg-light-100 p-3 text-left text-dark hover:border-primary"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<Text color="primary" fontWeight="medium">{title}</Text>
|
||||
{#if description}
|
||||
<Text size="small" class="mt-1">{description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<Modal title={getModalTitle()} icon={getModalIcon()} onClose={() => onClose()}>
|
||||
<ModalBody>
|
||||
<div class="space-y-6">
|
||||
<!-- Filters Section -->
|
||||
{#if filters.length > 0 && (!type || type === 'filter')}
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
{#each filters as filter (filter.id)}
|
||||
{@render stepButton(filter.title, filter.description, () => handleSelect('filter', filter))}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions Section -->
|
||||
{#if actions.length > 0 && (!type || type === 'action')}
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
{#each actions as action (action.id)}
|
||||
{@render stepButton(action.title, action.description, () => handleSelect('action', action))}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
@@ -21,9 +21,9 @@
|
||||
let search = $state('');
|
||||
let selectedRowIndex: number = $state(-1);
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
onClose: (albums?: AlbumResponseDto[]) => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { searchPluginMethods, WorkflowTrigger, type PluginMethodResponseDto } from '@immich/sdk';
|
||||
import { Badge, BasicModal, ListButton, LoadingSpinner, Stack, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
trigger: WorkflowTrigger;
|
||||
selectedKey?: string;
|
||||
onClose: (method?: PluginMethodResponseDto) => void;
|
||||
};
|
||||
|
||||
const { trigger, selectedKey, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BasicModal title={$t('add_step')} {onClose}>
|
||||
{#await searchPluginMethods({ trigger })}
|
||||
<div class="flex w-full place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:then methods}
|
||||
<Stack>
|
||||
{#each methods as method (method.key)}
|
||||
<ListButton selected={method.key === selectedKey} onclick={() => onClose(method)}>
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium" class="flex items-center gap-1"
|
||||
>{method.title}
|
||||
{#if method.uiHints.includes('filter')}
|
||||
<Badge size="tiny" color="info" title={$t('plugin_method_filter_type_description')}
|
||||
>{$t('plugin_method_filter_type')}</Badge
|
||||
>
|
||||
{/if}
|
||||
</Text>
|
||||
{#if method.description}
|
||||
<Text size="tiny" color="muted">{method.description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
</ListButton>
|
||||
{/each}
|
||||
</Stack>
|
||||
{/await}
|
||||
</BasicModal>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import SchemaConfiguration from '$lib/components/SchemaConfiguration.svelte';
|
||||
import PluginMethodPicker from '$lib/modals/PluginMethodPicker.svelte';
|
||||
import { type JSONSchemaProperty, type SchemaConfig } from '$lib/types';
|
||||
import { WorkflowTrigger, type PluginMethodResponseDto, type WorkflowStepDto } from '@immich/sdk';
|
||||
import { FormModal, IconButton, modalManager, Stack, Text, Textarea } from '@immich/ui';
|
||||
import { mdiPencilOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
trigger: WorkflowTrigger;
|
||||
onClose: (step?: WorkflowStepDto) => void;
|
||||
};
|
||||
|
||||
const { trigger, onClose }: Props = $props();
|
||||
|
||||
const onSubmit = () => {
|
||||
if (method) {
|
||||
onClose({ method: method.key, config, enabled: true });
|
||||
}
|
||||
};
|
||||
|
||||
let method = $state<PluginMethodResponseDto>();
|
||||
let config = $state<SchemaConfig>({});
|
||||
let debug = $state(false);
|
||||
|
||||
const onPickMethod = async () => {
|
||||
const selected = await modalManager.show(PluginMethodPicker, { trigger, selectedKey: method?.key });
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
method = selected;
|
||||
config = selected.schema ? {} : null;
|
||||
};
|
||||
|
||||
void onPickMethod();
|
||||
</script>
|
||||
|
||||
{#if method}
|
||||
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="small">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium">{method.title}</Text>
|
||||
{#if method.description}
|
||||
<Text size="tiny" color="muted">{method.description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
<IconButton
|
||||
icon={mdiPencilOutline}
|
||||
onclick={onPickMethod}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
aria-label={$t('edit')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if method.schema}
|
||||
<hr class="my-4" />
|
||||
<div class="mt-4 grow text-start">
|
||||
<Text fontWeight="medium">{$t('configuration')}</Text>
|
||||
<Stack gap={4}>
|
||||
<SchemaConfiguration schema={method.schema as JSONSchemaProperty} bind:config root />
|
||||
</Stack>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if debug}
|
||||
<hr class="my-4" />
|
||||
<Text fontWeight="medium">{$t('preview')}</Text>
|
||||
<Textarea class="mt-2" readonly grow value={JSON.stringify({ method: method.key, config }, null, 2)} />
|
||||
{/if}
|
||||
</FormModal>
|
||||
{/if}
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import SchemaConfiguration from '$lib/components/SchemaConfiguration.svelte';
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import PluginMethodPicker from '$lib/modals/PluginMethodPicker.svelte';
|
||||
import { type JSONSchemaProperty, type SchemaConfig } from '$lib/types';
|
||||
import { WorkflowTrigger, type WorkflowStepDto } from '@immich/sdk';
|
||||
import { Button, FormModal, modalManager, Stack, Text, Textarea } from '@immich/ui';
|
||||
import { mdiPencilOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
trigger: WorkflowTrigger;
|
||||
step: WorkflowStepDto;
|
||||
onClose: (step?: WorkflowStepDto) => void;
|
||||
};
|
||||
|
||||
const { trigger, step, onClose }: Props = $props();
|
||||
|
||||
const onSubmit = () => onClose(method ? { method: method.key, config, enabled } : undefined);
|
||||
|
||||
let enabled = $state<boolean>(true);
|
||||
let method = $state(pluginManager.getMethod(step.method));
|
||||
let config = $state<SchemaConfig>(step.config);
|
||||
let debug = $state(false);
|
||||
|
||||
const onPickMethod = async () => {
|
||||
const selected = await modalManager.show(PluginMethodPicker, { trigger, selectedKey: method?.key });
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
method = selected;
|
||||
config = selected.schema ? {} : null;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if method}
|
||||
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="small">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium">{method.title}</Text>
|
||||
{#if method.description}
|
||||
<Text size="small" color="muted">{method.description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
<Button size="small" color="secondary" shape="round" onclick={onPickMethod} leadingIcon={mdiPencilOutline}
|
||||
>{$t('change')}</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if method.schema}
|
||||
<hr class="my-4" />
|
||||
<div class="mt-4 grow text-start">
|
||||
<Text fontWeight="medium">{$t('configuration')}</Text>
|
||||
<Stack gap={4}>
|
||||
<SchemaConfiguration schema={method.schema as JSONSchemaProperty} bind:config root />
|
||||
</Stack>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if debug}
|
||||
<hr class="my-4" />
|
||||
<Text fontWeight="medium">{$t('preview')}</Text>
|
||||
<Textarea class="mt-2" readonly grow value={JSON.stringify({ method: method.key, config }, null, 2)} />
|
||||
{/if}
|
||||
</FormModal>
|
||||
{/if}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
|
||||
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
||||
import { type WorkflowResponseDto } from '@immich/sdk';
|
||||
import { FormModal, ListButton, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
workflow: WorkflowResponseDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { workflow, onClose }: Props = $props();
|
||||
|
||||
let selected = $state(pluginManager.getTrigger(workflow.trigger));
|
||||
|
||||
const onSubmit = async () => {
|
||||
const success = await handleUpdateWorkflow(workflow.id, { trigger: selected.trigger });
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each pluginManager.triggers as item (item.trigger)}
|
||||
<ListButton selected={item.trigger === selected.trigger} onclick={() => (selected = item)}>
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium">{getTriggerName($t, item.trigger)}</Text>
|
||||
<Text size="tiny" color="muted">{getTriggerDescription($t, item.trigger)}</Text>
|
||||
</div>
|
||||
</ListButton>
|
||||
{/each}
|
||||
</div>
|
||||
</FormModal>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
||||
import { WorkflowTrigger } from '@immich/sdk';
|
||||
import { FormModal, ListButton, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
selected?: WorkflowTrigger;
|
||||
onClose: (trigger?: WorkflowTrigger) => void;
|
||||
};
|
||||
|
||||
const { selected: initialSelected, onClose }: Props = $props();
|
||||
|
||||
let selected = $state(initialSelected);
|
||||
|
||||
const onSubmit = () => onClose(selected);
|
||||
</script>
|
||||
|
||||
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each pluginManager.triggers as item (item.trigger)}
|
||||
<ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}>
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium">{getTriggerName($t, item.trigger)}</Text>
|
||||
<Text size="tiny" color="muted">{getTriggerDescription($t, item.trigger)}</Text>
|
||||
</div>
|
||||
</ListButton>
|
||||
{/each}
|
||||
</div>
|
||||
</FormModal>
|
||||
@@ -146,8 +146,8 @@ export const Route = {
|
||||
geolocationUtility: () => '/utilities/geolocation',
|
||||
|
||||
// workflows
|
||||
workflows: () => '/utilities/workflows',
|
||||
viewWorkflow: ({ id }: { id: string }) => `/utilities/workflows/${id}`,
|
||||
workflows: () => '/workflows',
|
||||
viewWorkflow: ({ id }: { id: string }) => `/workflows/${id}`,
|
||||
|
||||
// queues
|
||||
queues: () => '/admin/queues',
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import {
|
||||
createWorkflow,
|
||||
deleteWorkflow,
|
||||
getAlbumInfo,
|
||||
getPerson,
|
||||
PluginTriggerType,
|
||||
updateWorkflow,
|
||||
WorkflowTrigger,
|
||||
type AlbumResponseDto,
|
||||
type PersonResponseDto,
|
||||
type PluginActionResponseDto,
|
||||
type PluginContextType,
|
||||
type PluginFilterResponseDto,
|
||||
type PluginTriggerResponseDto,
|
||||
type WorkflowActionItemDto,
|
||||
type WorkflowCreateDto,
|
||||
type WorkflowFilterItemDto,
|
||||
type WorkflowResponseDto,
|
||||
type WorkflowUpdateDto,
|
||||
} from '@immich/sdk';
|
||||
@@ -29,306 +21,14 @@ import { getFormatter } from '$lib/utils/i18n';
|
||||
export type PickerSubType = 'album-picker' | 'people-picker';
|
||||
export type PickerMetadata = AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[];
|
||||
|
||||
export interface WorkflowPayload {
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
triggerType: string;
|
||||
filters: Record<string, unknown>[];
|
||||
actions: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filters that support the given context
|
||||
*/
|
||||
export const getFiltersByContext = (
|
||||
availableFilters: PluginFilterResponseDto[],
|
||||
context: PluginContextType,
|
||||
): PluginFilterResponseDto[] => {
|
||||
return availableFilters.filter((filter) => filter.supportedContexts.includes(context));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get actions that support the given context
|
||||
*/
|
||||
export const getActionsByContext = (
|
||||
availableActions: PluginActionResponseDto[],
|
||||
context: PluginContextType,
|
||||
): PluginActionResponseDto[] => {
|
||||
return availableActions.filter((action) => action.supportedContexts.includes(context));
|
||||
};
|
||||
|
||||
export const remapConfigsOnReorder = (
|
||||
configs: Record<string, unknown>,
|
||||
prefix: 'filter' | 'action',
|
||||
fromIndex: number,
|
||||
toIndex: number,
|
||||
totalCount: number,
|
||||
): Record<string, unknown> => {
|
||||
const newConfigs: Record<string, unknown> = {};
|
||||
|
||||
// Create an array of configs in order
|
||||
const configArray: unknown[] = [];
|
||||
for (let i = 0; i < totalCount; i++) {
|
||||
configArray.push(configs[`${prefix}_${i}`] ?? {});
|
||||
}
|
||||
|
||||
// Move the item from fromIndex to toIndex
|
||||
const [movedItem] = configArray.splice(fromIndex, 1);
|
||||
configArray.splice(toIndex, 0, movedItem);
|
||||
|
||||
// Rebuild the configs object with new indices
|
||||
for (let i = 0; i < configArray.length; i++) {
|
||||
newConfigs[`${prefix}_${i}`] = configArray[i];
|
||||
}
|
||||
|
||||
return newConfigs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remap configs when an item is removed
|
||||
* Shifts all configs after the removed index down by one
|
||||
*/
|
||||
export const remapConfigsOnRemove = (
|
||||
configs: Record<string, unknown>,
|
||||
prefix: 'filter' | 'action',
|
||||
removedIndex: number,
|
||||
totalCount: number,
|
||||
): Record<string, unknown> => {
|
||||
const newConfigs: Record<string, unknown> = {};
|
||||
|
||||
let newIndex = 0;
|
||||
for (let i = 0; i < totalCount; i++) {
|
||||
if (i !== removedIndex) {
|
||||
newConfigs[`${prefix}_${newIndex}`] = configs[`${prefix}_${i}`] ?? {};
|
||||
newIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return newConfigs;
|
||||
};
|
||||
|
||||
export const initializeConfigs = (
|
||||
type: 'action' | 'filter',
|
||||
workflow: WorkflowResponseDto,
|
||||
): Record<string, unknown> => {
|
||||
const configs: Record<string, unknown> = {};
|
||||
|
||||
if (workflow.filters && type == 'filter') {
|
||||
for (const [index, workflowFilter] of workflow.filters.entries()) {
|
||||
configs[`filter_${index}`] = workflowFilter.filterConfig ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
if (workflow.actions && type == 'action') {
|
||||
for (const [index, workflowAction] of workflow.actions.entries()) {
|
||||
configs[`action_${index}`] = workflowAction.actionConfig ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
return configs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build workflow payload from current state
|
||||
* Uses index-based keys to support multiple filters/actions of the same type
|
||||
*/
|
||||
export const buildWorkflowPayload = (
|
||||
name: string,
|
||||
description: string,
|
||||
enabled: boolean,
|
||||
triggerType: string,
|
||||
orderedFilters: PluginFilterResponseDto[],
|
||||
orderedActions: PluginActionResponseDto[],
|
||||
filterConfigs: Record<string, unknown>,
|
||||
actionConfigs: Record<string, unknown>,
|
||||
): WorkflowPayload => {
|
||||
const filters = orderedFilters.map((filter, index) => ({
|
||||
[filter.methodName]: filterConfigs[`filter_${index}`] ?? {},
|
||||
}));
|
||||
|
||||
const actions = orderedActions.map((action, index) => ({
|
||||
[action.methodName]: actionConfigs[`action_${index}`] ?? {},
|
||||
}));
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
enabled,
|
||||
triggerType,
|
||||
filters,
|
||||
actions,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseWorkflowJson = (
|
||||
jsonString: string,
|
||||
availableTriggers: PluginTriggerResponseDto[],
|
||||
availableFilters: PluginFilterResponseDto[],
|
||||
availableActions: PluginActionResponseDto[],
|
||||
): {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: {
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
trigger?: PluginTriggerResponseDto;
|
||||
filters: PluginFilterResponseDto[];
|
||||
actions: PluginActionResponseDto[];
|
||||
filterConfigs: Record<string, unknown>;
|
||||
actionConfigs: Record<string, unknown>;
|
||||
};
|
||||
} => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
|
||||
const trigger = availableTriggers.find((t) => t.type === parsed.triggerType);
|
||||
|
||||
const filters: PluginFilterResponseDto[] = [];
|
||||
const filterConfigs: Record<string, unknown> = {};
|
||||
if (Array.isArray(parsed.filters)) {
|
||||
for (const [index, filterObj] of parsed.filters.entries()) {
|
||||
const methodName = Object.keys(filterObj)[0];
|
||||
const filter = availableFilters.find((f) => f.methodName === methodName);
|
||||
if (filter) {
|
||||
filters.push(filter);
|
||||
filterConfigs[`filter_${index}`] = (filterObj as Record<string, unknown>)[methodName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actions: PluginActionResponseDto[] = [];
|
||||
const actionConfigs: Record<string, unknown> = {};
|
||||
if (Array.isArray(parsed.actions)) {
|
||||
for (const [index, actionObj] of parsed.actions.entries()) {
|
||||
const methodName = Object.keys(actionObj)[0];
|
||||
const action = availableActions.find((a) => a.methodName === methodName);
|
||||
if (action) {
|
||||
actions.push(action);
|
||||
actionConfigs[`action_${index}`] = (actionObj as Record<string, unknown>)[methodName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
name: parsed.name ?? '',
|
||||
description: parsed.description ?? '',
|
||||
enabled: parsed.enabled ?? false,
|
||||
trigger,
|
||||
filters,
|
||||
actions,
|
||||
filterConfigs,
|
||||
actionConfigs,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Invalid JSON',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const hasWorkflowChanged = (
|
||||
previousWorkflow: WorkflowResponseDto,
|
||||
enabled: boolean,
|
||||
name: string,
|
||||
description: string,
|
||||
triggerType: string,
|
||||
orderedFilters: PluginFilterResponseDto[],
|
||||
orderedActions: PluginActionResponseDto[],
|
||||
filterConfigs: Record<string, unknown>,
|
||||
actionConfigs: Record<string, unknown>,
|
||||
): boolean => {
|
||||
if (enabled !== previousWorkflow.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (name !== (previousWorkflow.name ?? '') || description !== (previousWorkflow.description ?? '')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (triggerType !== previousWorkflow.triggerType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousFilterIds = previousWorkflow.filters?.map((f) => f.pluginFilterId) ?? [];
|
||||
const currentFilterIds = orderedFilters.map((f) => f.id);
|
||||
if (JSON.stringify(previousFilterIds) !== JSON.stringify(currentFilterIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousActionIds = previousWorkflow.actions?.map((a) => a.pluginActionId) ?? [];
|
||||
const currentActionIds = orderedActions.map((a) => a.id);
|
||||
if (JSON.stringify(previousActionIds) !== JSON.stringify(currentActionIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousFilterConfigs: Record<string, unknown> = {};
|
||||
for (const [index, wf] of (previousWorkflow.filters ?? []).entries()) {
|
||||
previousFilterConfigs[`filter_${index}`] = wf.filterConfig ?? {};
|
||||
}
|
||||
if (JSON.stringify(previousFilterConfigs) !== JSON.stringify(filterConfigs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousActionConfigs: Record<string, unknown> = {};
|
||||
for (const [index, wa] of (previousWorkflow.actions ?? []).entries()) {
|
||||
previousActionConfigs[`action_${index}`] = wa.actionConfig ?? {};
|
||||
}
|
||||
if (JSON.stringify(previousActionConfigs) !== JSON.stringify(actionConfigs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const handleUpdateWorkflow = async (
|
||||
workflowId: string,
|
||||
name: string,
|
||||
description: string,
|
||||
enabled: boolean,
|
||||
triggerType: PluginTriggerType,
|
||||
orderedFilters: PluginFilterResponseDto[],
|
||||
orderedActions: PluginActionResponseDto[],
|
||||
filterConfigs: Record<string, unknown>,
|
||||
actionConfigs: Record<string, unknown>,
|
||||
): Promise<WorkflowResponseDto> => {
|
||||
const filters = orderedFilters.map((filter, index) => ({
|
||||
pluginFilterId: filter.id,
|
||||
filterConfig: filterConfigs[`filter_${index}`] ?? {},
|
||||
})) as WorkflowFilterItemDto[];
|
||||
|
||||
const actions = orderedActions.map((action, index) => ({
|
||||
pluginActionId: action.id,
|
||||
actionConfig: actionConfigs[`action_${index}`] ?? {},
|
||||
})) as WorkflowActionItemDto[];
|
||||
|
||||
const updateDto: WorkflowUpdateDto = {
|
||||
name,
|
||||
description,
|
||||
enabled,
|
||||
filters,
|
||||
actions,
|
||||
triggerType,
|
||||
};
|
||||
|
||||
return updateWorkflow({ id: workflowId, workflowUpdateDto: updateDto });
|
||||
};
|
||||
|
||||
export const getWorkflowsActions = ($t: MessageFormatter) => {
|
||||
const Create: ActionItem = {
|
||||
title: $t('create_workflow'),
|
||||
icon: mdiPlus,
|
||||
onAction: () =>
|
||||
handleCreateWorkflow({
|
||||
name: $t('untitled_workflow'),
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
filters: [],
|
||||
actions: [],
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [],
|
||||
enabled: false,
|
||||
}),
|
||||
};
|
||||
@@ -341,9 +41,7 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
|
||||
title: workflow.enabled ? $t('disable') : $t('enable'),
|
||||
icon: workflow.enabled ? mdiPause : mdiPlay,
|
||||
color: workflow.enabled ? 'danger' : 'primary',
|
||||
onAction: async () => {
|
||||
await handleToggleWorkflowEnabled(workflow);
|
||||
},
|
||||
onAction: () => handleUpdateWorkflow(workflow.id, { enabled: !workflow.enabled }),
|
||||
};
|
||||
|
||||
const Edit: ActionItem = {
|
||||
@@ -385,22 +83,17 @@ const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const handleToggleWorkflowEnabled = async (
|
||||
workflow: WorkflowResponseDto,
|
||||
): Promise<WorkflowResponseDto | undefined> => {
|
||||
export const handleUpdateWorkflow = async (id: string, dto: WorkflowUpdateDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const updated = await updateWorkflow({
|
||||
id: workflow.id,
|
||||
workflowUpdateDto: { enabled: !workflow.enabled },
|
||||
});
|
||||
|
||||
eventManager.emit('WorkflowUpdate', updated);
|
||||
toastManager.primary($t('workflow_updated'));
|
||||
return updated;
|
||||
const response = await updateWorkflow({ id, workflowUpdateDto: dto });
|
||||
eventManager.emit('WorkflowUpdate', response);
|
||||
toastManager.primary($t('workflow_update_success'), { closable: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_workflow'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -426,30 +119,3 @@ export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promi
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchPickerMetadata = async (
|
||||
value: string | string[] | undefined,
|
||||
subType: PickerSubType,
|
||||
): Promise<PickerMetadata | undefined> => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isAlbum = subType === 'album-picker';
|
||||
|
||||
try {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
// Multiple selection
|
||||
return isAlbum
|
||||
? await Promise.all(value.map((id) => getAlbumInfo({ id })))
|
||||
: await Promise.all(value.map((id) => getPerson({ id })));
|
||||
} else if (typeof value === 'string' && value) {
|
||||
// Single selection
|
||||
return isAlbum ? await getAlbumInfo({ id: value }) : await getPerson({ id: value });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch picker metadata:`, error);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -91,3 +91,21 @@ export type SearchFilter = {
|
||||
mediaType: MediaType;
|
||||
rating?: number | null;
|
||||
};
|
||||
|
||||
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object';
|
||||
|
||||
export type JSONSchemaProperty = {
|
||||
type: JSONSchemaType;
|
||||
title?: string;
|
||||
description?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default?: any;
|
||||
enum?: string[];
|
||||
array?: boolean;
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
uiHint?: 'albumId' | 'assetId' | 'personId';
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type SchemaConfig = any;
|
||||
|
||||
+24
-122
@@ -1,128 +1,30 @@
|
||||
export type ComponentType = 'select' | 'multiselect' | 'text' | 'switch' | 'checkbox';
|
||||
import { WorkflowTrigger } from '@immich/sdk';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export interface ComponentConfig {
|
||||
type: ComponentType;
|
||||
label?: string;
|
||||
description?: string;
|
||||
defaultValue?: unknown;
|
||||
required?: boolean;
|
||||
options?: Array<{ label: string; value: string | number | boolean }>;
|
||||
placeholder?: string;
|
||||
subType?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface JSONSchemaProperty {
|
||||
type?: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
enum?: unknown[];
|
||||
items?: JSONSchemaProperty;
|
||||
subType?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface JSONSchema {
|
||||
type?: string;
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
export const getComponentDefaultValue = (component: ComponentConfig): unknown => {
|
||||
if (component.defaultValue !== undefined) {
|
||||
return component.defaultValue;
|
||||
}
|
||||
|
||||
if (component.type === 'multiselect' || (component.type === 'text' && component.subType === 'people-picker')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (component.type === 'switch') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getComponentFromSchema = (schema: object | null): Record<string, ComponentConfig> | null => {
|
||||
if (!schema || !isJSONSchema(schema) || !schema.properties) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const components: Record<string, ComponentConfig> = {};
|
||||
const requiredFields = schema.required || [];
|
||||
|
||||
for (const [propertyName, property] of Object.entries(schema.properties)) {
|
||||
const config = getComponentForProperty(property, propertyName);
|
||||
if (config) {
|
||||
config.required = requiredFields.includes(propertyName);
|
||||
components[propertyName] = config;
|
||||
export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => {
|
||||
switch (type) {
|
||||
case WorkflowTrigger.AssetCreate: {
|
||||
return $t('trigger_asset_uploaded');
|
||||
}
|
||||
case WorkflowTrigger.PersonRecognized: {
|
||||
return $t('trigger_person_recognized');
|
||||
}
|
||||
default: {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(components).length > 0 ? components : null;
|
||||
};
|
||||
|
||||
function isJSONSchema(obj: object): obj is JSONSchema {
|
||||
return 'properties' in obj || 'type' in obj;
|
||||
}
|
||||
|
||||
function getComponentForProperty(property: JSONSchemaProperty, propertyName: string): ComponentConfig | null {
|
||||
const { type, title, enum: enumValues, description, default: defaultValue, items } = property;
|
||||
|
||||
const config: ComponentConfig = {
|
||||
type: 'text',
|
||||
label: formatLabel(propertyName),
|
||||
description,
|
||||
defaultValue,
|
||||
title,
|
||||
};
|
||||
|
||||
if (enumValues && enumValues.length > 0) {
|
||||
config.type = 'select';
|
||||
config.options = enumValues.map((value: unknown) => ({
|
||||
label: formatLabel(String(value)),
|
||||
value: value as string | number | boolean,
|
||||
}));
|
||||
return config;
|
||||
export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigger) => {
|
||||
switch (type) {
|
||||
case WorkflowTrigger.AssetCreate: {
|
||||
return $t('trigger_asset_uploaded_description');
|
||||
}
|
||||
case WorkflowTrigger.PersonRecognized: {
|
||||
return $t('trigger_person_recognized_description');
|
||||
}
|
||||
default: {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'array' && items?.enum && items.enum.length > 0) {
|
||||
config.type = 'multiselect';
|
||||
config.subType = items.subType;
|
||||
config.options = items.enum.map((value: unknown) => ({
|
||||
label: formatLabel(String(value)),
|
||||
value: value as string | number | boolean,
|
||||
}));
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
config.type = 'switch';
|
||||
return config;
|
||||
}
|
||||
|
||||
if (type === 'string') {
|
||||
config.type = 'text';
|
||||
config.subType = property.subType;
|
||||
config.placeholder = description;
|
||||
return config;
|
||||
}
|
||||
|
||||
if (type === 'array') {
|
||||
config.type = 'multiselect';
|
||||
config.subType = property.subType;
|
||||
return config;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function formatLabel(propertyName: string): string {
|
||||
return propertyName
|
||||
.replaceAll(/([A-Z])/g, ' $1')
|
||||
.replaceAll('_', ' ')
|
||||
.replace(/^./, (str) => str.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
mdiCrosshairsGps,
|
||||
mdiImageSizeSelectLarge,
|
||||
mdiLinkEdit,
|
||||
mdiStateMachine,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -16,7 +17,7 @@
|
||||
{ href: Route.duplicatesUtility(), icon: mdiContentDuplicate, label: $t('review_duplicates') },
|
||||
{ href: Route.largeFileUtility(), icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
|
||||
{ href: Route.geolocationUtility(), icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
|
||||
// { href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
|
||||
{ href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,619 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { dragAndDrop } from '$lib/attachments/drag-and-drop.svelte';
|
||||
import ControlAppBar from '$lib/components/shared-components/ControlAppBar.svelte';
|
||||
import SchemaFormFields from './SchemaFormFields.svelte';
|
||||
import WorkflowCardConnector from './WorkflowCardConnector.svelte';
|
||||
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
|
||||
import WorkflowSummarySidebar from './WorkflowSummary.svelte';
|
||||
import WorkflowTriggerCard from './WorkflowTriggerCard.svelte';
|
||||
import AddWorkflowStepModal from '$lib/modals/AddWorkflowStepModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import {
|
||||
buildWorkflowPayload,
|
||||
getActionsByContext,
|
||||
getFiltersByContext,
|
||||
handleUpdateWorkflow,
|
||||
hasWorkflowChanged,
|
||||
initializeConfigs,
|
||||
parseWorkflowJson,
|
||||
remapConfigsOnRemove,
|
||||
remapConfigsOnReorder,
|
||||
type WorkflowPayload,
|
||||
} from '$lib/services/workflow.service';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import type { PluginActionResponseDto, PluginFilterResponseDto, PluginTriggerResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Container,
|
||||
Field,
|
||||
HStack,
|
||||
Icon,
|
||||
Input,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
VStack,
|
||||
modalManager,
|
||||
toastManager,
|
||||
} from '@immich/ui';
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiCodeJson,
|
||||
mdiContentSave,
|
||||
mdiFilterOutline,
|
||||
mdiFlashOutline,
|
||||
mdiInformationOutline,
|
||||
mdiPlayCircleOutline,
|
||||
mdiPlus,
|
||||
mdiTrashCanOutline,
|
||||
mdiViewDashboard,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const triggers = data.triggers;
|
||||
const filters = data.plugins.flatMap((plugin) => plugin.filters);
|
||||
const actions = data.plugins.flatMap((plugin) => plugin.actions);
|
||||
|
||||
let previousWorkflow = data.workflow;
|
||||
let editWorkflow = $state(data.workflow);
|
||||
|
||||
let viewMode: 'visual' | 'json' = $state('visual');
|
||||
|
||||
let name: string = $derived(editWorkflow.name ?? '');
|
||||
let description: string = $derived(editWorkflow.description ?? '');
|
||||
|
||||
let selectedTrigger = $state(triggers.find((t) => t.type === editWorkflow.triggerType) ?? triggers[0]);
|
||||
|
||||
let triggerType = $derived(selectedTrigger.type);
|
||||
|
||||
let supportFilters = $derived(getFiltersByContext(filters, selectedTrigger.contextType));
|
||||
let supportActions = $derived(getActionsByContext(actions, selectedTrigger.contextType));
|
||||
|
||||
let selectedFilters: PluginFilterResponseDto[] = $derived(
|
||||
(editWorkflow.filters ?? []).flatMap((workflowFilter) =>
|
||||
supportFilters.filter((supportedFilter) => supportedFilter.id === workflowFilter.pluginFilterId),
|
||||
),
|
||||
);
|
||||
|
||||
let selectedActions: PluginActionResponseDto[] = $derived(
|
||||
(editWorkflow.actions ?? []).flatMap((workflowAction) =>
|
||||
supportActions.filter((supportedAction) => supportedAction.id === workflowAction.pluginActionId),
|
||||
),
|
||||
);
|
||||
|
||||
let filterConfigs: Record<string, unknown> = $derived(initializeConfigs('filter', editWorkflow));
|
||||
let actionConfigs: Record<string, unknown> = $derived(initializeConfigs('action', editWorkflow));
|
||||
|
||||
$effect(() => {
|
||||
editWorkflow.triggerType = triggerType;
|
||||
});
|
||||
|
||||
// Clear filters and actions when trigger changes (context changes)
|
||||
let previousContext = $state<string | undefined>(undefined);
|
||||
$effect(() => {
|
||||
const currentContext = selectedTrigger.contextType;
|
||||
if (previousContext !== undefined && previousContext !== currentContext) {
|
||||
selectedFilters = [];
|
||||
selectedActions = [];
|
||||
filterConfigs = {};
|
||||
actionConfigs = {};
|
||||
}
|
||||
previousContext = currentContext;
|
||||
});
|
||||
|
||||
const updateWorkflow = async () => {
|
||||
try {
|
||||
const updated = await handleUpdateWorkflow(
|
||||
editWorkflow.id,
|
||||
name,
|
||||
description,
|
||||
editWorkflow.enabled,
|
||||
triggerType,
|
||||
selectedFilters,
|
||||
selectedActions,
|
||||
filterConfigs,
|
||||
actionConfigs,
|
||||
);
|
||||
|
||||
previousWorkflow = updated;
|
||||
editWorkflow = updated;
|
||||
|
||||
toastManager.primary($t('workflow_update_success'), {
|
||||
closable: true,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to update workflow');
|
||||
}
|
||||
};
|
||||
|
||||
const jsonContent = $derived(
|
||||
buildWorkflowPayload(
|
||||
name,
|
||||
description,
|
||||
editWorkflow.enabled,
|
||||
triggerType,
|
||||
selectedFilters,
|
||||
selectedActions,
|
||||
filterConfigs,
|
||||
actionConfigs,
|
||||
),
|
||||
);
|
||||
|
||||
let jsonEditorContent: WorkflowPayload = $state({
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
triggerType: '',
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const syncFromJson = () => {
|
||||
const result = parseWorkflowJson(JSON.stringify(jsonEditorContent), triggers, filters, actions);
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
name = result.data.name;
|
||||
description = result.data.description;
|
||||
editWorkflow.enabled = result.data.enabled;
|
||||
|
||||
if (result.data.trigger) {
|
||||
selectedTrigger = result.data.trigger;
|
||||
}
|
||||
|
||||
selectedFilters = result.data.filters;
|
||||
selectedActions = result.data.actions;
|
||||
filterConfigs = result.data.filterConfigs;
|
||||
actionConfigs = result.data.actionConfigs;
|
||||
}
|
||||
};
|
||||
|
||||
let hasChanges: boolean = $derived(
|
||||
hasWorkflowChanged(
|
||||
previousWorkflow,
|
||||
editWorkflow.enabled,
|
||||
name,
|
||||
description,
|
||||
triggerType,
|
||||
selectedFilters,
|
||||
selectedActions,
|
||||
filterConfigs,
|
||||
actionConfigs,
|
||||
),
|
||||
);
|
||||
|
||||
let draggedFilterIndex: number | null = $state(null);
|
||||
let draggedActionIndex: number | null = $state(null);
|
||||
let dragOverFilterIndex: number | null = $state(null);
|
||||
let dragOverActionIndex: number | null = $state(null);
|
||||
|
||||
const handleFilterDragStart = (index: number) => {
|
||||
draggedFilterIndex = index;
|
||||
};
|
||||
|
||||
const handleFilterDragEnter = (index: number) => {
|
||||
if (draggedFilterIndex !== null && draggedFilterIndex !== index) {
|
||||
dragOverFilterIndex = index;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterDrop = (e: DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedFilterIndex === null || draggedFilterIndex === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remap configs to follow the new order
|
||||
filterConfigs = remapConfigsOnReorder(filterConfigs, 'filter', draggedFilterIndex, index, selectedFilters.length);
|
||||
|
||||
const newFilters = [...selectedFilters];
|
||||
const [draggedItem] = newFilters.splice(draggedFilterIndex, 1);
|
||||
newFilters.splice(index, 0, draggedItem);
|
||||
selectedFilters = newFilters;
|
||||
};
|
||||
|
||||
const handleFilterDragEnd = () => {
|
||||
draggedFilterIndex = null;
|
||||
dragOverFilterIndex = null;
|
||||
};
|
||||
|
||||
const handleActionDragStart = (index: number) => {
|
||||
draggedActionIndex = index;
|
||||
};
|
||||
|
||||
const handleActionDragEnter = (index: number) => {
|
||||
if (draggedActionIndex !== null && draggedActionIndex !== index) {
|
||||
dragOverActionIndex = index;
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionDrop = (e: DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedActionIndex === null || draggedActionIndex === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionConfigs = remapConfigsOnReorder(actionConfigs, 'action', draggedActionIndex, index, selectedActions.length);
|
||||
|
||||
const newActions = [...selectedActions];
|
||||
const [draggedItem] = newActions.splice(draggedActionIndex, 1);
|
||||
newActions.splice(index, 0, draggedItem);
|
||||
selectedActions = newActions;
|
||||
};
|
||||
|
||||
const handleActionDragEnd = () => {
|
||||
draggedActionIndex = null;
|
||||
dragOverActionIndex = null;
|
||||
};
|
||||
|
||||
const handleAddStep = async (type: 'action' | 'filter') => {
|
||||
const result = await modalManager.show(AddWorkflowStepModal, {
|
||||
filters: supportFilters,
|
||||
actions: supportActions,
|
||||
type,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
if (result.type === 'filter') {
|
||||
selectedFilters = [...selectedFilters, result.item as PluginFilterResponseDto];
|
||||
} else if (result.type === 'action') {
|
||||
selectedActions = [...selectedActions, result.item as PluginActionResponseDto];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFilter = (index: number) => {
|
||||
filterConfigs = remapConfigsOnRemove(filterConfigs, 'filter', index, selectedFilters.length);
|
||||
selectedFilters = selectedFilters.filter((_, i) => i !== index);
|
||||
};
|
||||
|
||||
const handleRemoveAction = (index: number) => {
|
||||
actionConfigs = remapConfigsOnRemove(actionConfigs, 'action', index, selectedActions.length);
|
||||
selectedActions = selectedActions.filter((_, i) => i !== index);
|
||||
};
|
||||
|
||||
const handleTriggerChange = async (newTrigger: PluginTriggerResponseDto) => {
|
||||
const confirmed = await modalManager.showDialog({
|
||||
prompt: $t('change_trigger_prompt'),
|
||||
title: $t('change_trigger'),
|
||||
confirmColor: 'primary',
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedTrigger = newTrigger;
|
||||
};
|
||||
|
||||
let allowNavigation = $state(false);
|
||||
|
||||
beforeNavigate(({ cancel, to }) => {
|
||||
if (hasChanges && !allowNavigation) {
|
||||
cancel();
|
||||
|
||||
modalManager
|
||||
.showDialog({
|
||||
prompt: $t('workflow_navigation_prompt'),
|
||||
confirmColor: 'primary',
|
||||
})
|
||||
.then((isConfirmed) => {
|
||||
if (isConfirmed && to) {
|
||||
allowNavigation = true;
|
||||
void goto(to.url);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet cardOrder(index: number)}
|
||||
<div class="flex size-8 shrink-0 place-content-center place-items-center rounded-lg border bg-light-50">
|
||||
<Text size="small" class="font-mono font-bold">
|
||||
{index + 1}
|
||||
</Text>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet stepSeparator()}
|
||||
<div class="relative flex justify-center py-4">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t-2 border-dashed border-light-200"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<Text class="bg-white px-2 dark:bg-black" fontWeight="semi-bold" size="tiny" color="muted">{$t('then')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet emptyCreateButton(title: string, description: string, onclick: () => Promise<void>)}
|
||||
<button
|
||||
type="button"
|
||||
{onclick}
|
||||
class="flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 transition-all hover:border-light-400 hover:bg-light-50"
|
||||
>
|
||||
<Icon icon={mdiPlus} size="32" />
|
||||
<Text size="small" fontWeight="medium">{title}</Text>
|
||||
<Text size="tiny">{description}</Text>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.meta.title} - Immich</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="pt-24 immich-scrollbar">
|
||||
<Container size="medium" class="p-4" center>
|
||||
{#if viewMode === 'json'}
|
||||
<WorkflowJsonEditor
|
||||
jsonContent={jsonEditorContent}
|
||||
onApply={syncFromJson}
|
||||
onContentChange={(content) => (jsonEditorContent = content)}
|
||||
/>
|
||||
{:else}
|
||||
<VStack gap={0}>
|
||||
<Card expandable>
|
||||
<CardHeader>
|
||||
<div class="flex place-items-start gap-3">
|
||||
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
|
||||
<div class="flex flex-col">
|
||||
<CardTitle>
|
||||
{$t('workflow_info')}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
<VStack gap={4}>
|
||||
<div
|
||||
class="relative w-full overflow-hidden rounded-xl border p-4"
|
||||
class:bg-primary-50={editWorkflow.enabled}
|
||||
>
|
||||
<Field
|
||||
label={editWorkflow.enabled ? $t('enabled') : $t('disabled')}
|
||||
for="workflow-enabled"
|
||||
color={editWorkflow.enabled ? 'primary' : 'secondary'}
|
||||
>
|
||||
<Switch id="workflow-enabled" bind:checked={editWorkflow.enabled} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={$t('name')} for="workflow-name" required>
|
||||
<Input id="workflow-name" placeholder={$t('workflow_name')} bind:value={name} />
|
||||
</Field>
|
||||
<Field label={$t('description')} for="workflow-description">
|
||||
<Textarea
|
||||
id="workflow-description"
|
||||
grow
|
||||
placeholder={$t('workflow_description')}
|
||||
bind:value={description}
|
||||
/>
|
||||
</Field>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div class="my-10 h-px w-[98%] bg-light-200"></div>
|
||||
|
||||
<Card expandable>
|
||||
<CardHeader class="bg-primary-50">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-primary" />
|
||||
<div class="flex flex-col">
|
||||
<CardTitle class="text-left text-primary">{$t('trigger')}</CardTitle>
|
||||
<CardDescription>{$t('trigger_description')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{#each triggers as trigger (trigger.type)}
|
||||
<WorkflowTriggerCard
|
||||
{trigger}
|
||||
selected={selectedTrigger.type === trigger.type}
|
||||
onclick={() => handleTriggerChange(trigger)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<WorkflowCardConnector />
|
||||
|
||||
<Card expandable>
|
||||
<CardHeader class="bg-warning-50">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon icon={mdiFilterOutline} size="20" class="mt-1 text-warning" />
|
||||
<div class="flex flex-col">
|
||||
<CardTitle class="text-left text-warning">{$t('filter')}</CardTitle>
|
||||
<CardDescription>{$t('filter_description')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
{#if selectedFilters.length === 0}
|
||||
{@render emptyCreateButton($t('add_filter'), $t('add_filter_description'), () => handleAddStep('filter'))}
|
||||
{:else}
|
||||
{#each selectedFilters as filter, index (index)}
|
||||
{#if index > 0}
|
||||
{@render stepSeparator()}
|
||||
{/if}
|
||||
<div
|
||||
{@attach dragAndDrop({
|
||||
index,
|
||||
onDragStart: handleFilterDragStart,
|
||||
onDragEnter: handleFilterDragEnter,
|
||||
onDrop: handleFilterDrop,
|
||||
onDragEnd: handleFilterDragEnd,
|
||||
isDragging: draggedFilterIndex === index,
|
||||
isDragOver: dragOverFilterIndex === index,
|
||||
})}
|
||||
class="mb-4 cursor-move rounded-2xl border-2 border-dashed bg-light-50 p-4 transition-all hover:border-light-300"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
{@render cardOrder(index)}
|
||||
<div class="flex-1">
|
||||
<h1 class="mb-3 text-lg font-bold">{filter.title}</h1>
|
||||
<SchemaFormFields
|
||||
schema={filter.schema}
|
||||
bind:config={filterConfigs}
|
||||
configKey={`filter_${index}`}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
size="medium"
|
||||
variant="ghost"
|
||||
color="danger"
|
||||
onclick={() => handleRemoveFilter(index)}
|
||||
leadingIcon={mdiTrashCanOutline}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
fullWidth
|
||||
variant="ghost"
|
||||
leadingIcon={mdiPlus}
|
||||
onclick={() => handleAddStep('filter')}
|
||||
>
|
||||
{$t('add_filter')}
|
||||
</Button>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<WorkflowCardConnector />
|
||||
|
||||
<Card expandable expanded>
|
||||
<CardHeader class="bg-success-50">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon icon={mdiPlayCircleOutline} size="20" class="mt-1 text-success" />
|
||||
<div class="flex flex-col">
|
||||
<CardTitle class="text-left text-success">{$t('action')}</CardTitle>
|
||||
<CardDescription>{$t('action_description')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
{#if selectedActions.length === 0}
|
||||
{@render emptyCreateButton($t('add_action'), $t('add_action_description'), () => handleAddStep('action'))}
|
||||
{:else}
|
||||
{#each selectedActions as action, index (index)}
|
||||
{#if index > 0}
|
||||
{@render stepSeparator()}
|
||||
{/if}
|
||||
<div
|
||||
{@attach dragAndDrop({
|
||||
index,
|
||||
onDragStart: handleActionDragStart,
|
||||
onDragEnter: handleActionDragEnter,
|
||||
onDrop: handleActionDrop,
|
||||
onDragEnd: handleActionDragEnd,
|
||||
isDragging: draggedActionIndex === index,
|
||||
isDragOver: dragOverActionIndex === index,
|
||||
})}
|
||||
class="mb-4 cursor-move rounded-2xl border-2 border-dashed bg-light-50 p-4 transition-all hover:border-light-300"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
{@render cardOrder(index)}
|
||||
<div class="flex-1">
|
||||
<h1 class="mb-3 text-lg font-bold">{action.title}</h1>
|
||||
<SchemaFormFields
|
||||
schema={action.schema}
|
||||
bind:config={actionConfigs}
|
||||
configKey={`action_${index}`}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
size="medium"
|
||||
variant="ghost"
|
||||
color="danger"
|
||||
onclick={() => handleRemoveAction(index)}
|
||||
leadingIcon={mdiTrashCanOutline}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<Button
|
||||
size="small"
|
||||
fullWidth
|
||||
variant="ghost"
|
||||
leadingIcon={mdiPlus}
|
||||
onclick={() => handleAddStep('action')}
|
||||
>
|
||||
{$t('add_action')}
|
||||
</Button>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
{/if}
|
||||
</Container>
|
||||
|
||||
<WorkflowSummarySidebar trigger={selectedTrigger} filters={selectedFilters} actions={selectedActions} />
|
||||
</main>
|
||||
|
||||
<ControlAppBar onClose={() => goto(Route.workflows())} backIcon={mdiArrowLeft} tailwindClasses="fixed! top-0! w-full">
|
||||
{#snippet leading()}
|
||||
<Text>{data.meta.title}</Text>
|
||||
{/snippet}
|
||||
|
||||
{#snippet trailing()}
|
||||
<HStack gap={4}>
|
||||
<HStack gap={1} class="rounded-lg border border-light-300 p-1">
|
||||
<Button
|
||||
size="small"
|
||||
variant={viewMode === 'visual' ? 'outline' : 'ghost'}
|
||||
color={viewMode === 'visual' ? 'primary' : 'secondary'}
|
||||
leadingIcon={mdiViewDashboard}
|
||||
onclick={() => (viewMode = 'visual')}
|
||||
>
|
||||
{$t('visual')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={viewMode === 'json' ? 'outline' : 'ghost'}
|
||||
color={viewMode === 'json' ? 'primary' : 'secondary'}
|
||||
leadingIcon={mdiCodeJson}
|
||||
onclick={() => {
|
||||
viewMode = 'json';
|
||||
jsonEditorContent = jsonContent;
|
||||
}}
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Button leadingIcon={mdiContentSave} size="small" color="primary" onclick={updateWorkflow} disabled={!hasChanges}>
|
||||
{$t('save')}
|
||||
</Button>
|
||||
</HStack>
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getPlugins, getPluginTriggers, getWorkflow } from '@immich/sdk';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url, params }) => {
|
||||
await authenticate(url);
|
||||
const [plugins, workflow, triggers] = await Promise.all([
|
||||
getPlugins(),
|
||||
getWorkflow({ id: params.workflowId }),
|
||||
getPluginTriggers(),
|
||||
]);
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
plugins,
|
||||
workflow,
|
||||
triggers,
|
||||
meta: {
|
||||
title: $t('edit_workflow'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -1,161 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getComponentDefaultValue, getComponentFromSchema } from '$lib/utils/workflow';
|
||||
import { Field, Input, MultiSelect, Select, Switch, Text } from '@immich/ui';
|
||||
import WorkflowPickerField from './WorkflowPickerField.svelte';
|
||||
|
||||
type Props = {
|
||||
schema: object | null;
|
||||
config: Record<string, unknown>;
|
||||
configKey?: string;
|
||||
};
|
||||
|
||||
let { schema = null, config = $bindable({}), configKey }: Props = $props();
|
||||
|
||||
const components = $derived(getComponentFromSchema(schema));
|
||||
|
||||
// Get the actual config object to work with
|
||||
const actualConfig = $derived(configKey ? (config[configKey] as Record<string, unknown>) || {} : config);
|
||||
|
||||
// Update function that handles nested config
|
||||
const updateConfig = (key: string, value: unknown) => {
|
||||
config = configKey ? { ...config, [configKey]: { ...actualConfig, [key]: value } } : { ...config, [key]: value };
|
||||
};
|
||||
|
||||
const updateConfigBatch = (updates: Record<string, unknown>) => {
|
||||
config = configKey ? { ...config, [configKey]: { ...actualConfig, ...updates } } : { ...config, ...updates };
|
||||
};
|
||||
|
||||
// Derive which keys need initialization (missing from actualConfig)
|
||||
const uninitializedKeys = $derived.by(() => {
|
||||
if (!components) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(components)
|
||||
.filter(([key]) => actualConfig[key] === undefined)
|
||||
.map(([key, component]) => ({ key, component, defaultValue: getComponentDefaultValue(component) }));
|
||||
});
|
||||
|
||||
// Derive the batch updates needed
|
||||
const pendingUpdates = $derived.by(() => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const { key, defaultValue } of uninitializedKeys) {
|
||||
updates[key] = defaultValue;
|
||||
}
|
||||
return updates;
|
||||
});
|
||||
|
||||
// Initialize config namespace if needed
|
||||
$effect(() => {
|
||||
if (configKey && !config[configKey]) {
|
||||
config = { ...config, [configKey]: {} };
|
||||
}
|
||||
});
|
||||
|
||||
// Apply pending config updates
|
||||
$effect(() => {
|
||||
if (Object.keys(pendingUpdates).length > 0) {
|
||||
updateConfigBatch(pendingUpdates);
|
||||
}
|
||||
});
|
||||
|
||||
const isPickerField = (subType: string | undefined) => subType === 'album-picker' || subType === 'people-picker';
|
||||
</script>
|
||||
|
||||
{#if components}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each Object.entries(components) as [key, component] (key)}
|
||||
{@const label = component.title || component.label || key}
|
||||
|
||||
<div class="flex flex-col gap-1 rounded-xl border bg-light p-4">
|
||||
<!-- Select component -->
|
||||
{#if component.type === 'select'}
|
||||
{#if isPickerField(component.subType)}
|
||||
<WorkflowPickerField
|
||||
{component}
|
||||
configKey={key}
|
||||
value={actualConfig[key] as string | string[]}
|
||||
onchange={(value) => updateConfig(key, value)}
|
||||
/>
|
||||
{:else}
|
||||
{@const options = component.options?.map((opt) => {
|
||||
return { label: opt.label, value: String(opt.value) };
|
||||
}) || [{ label: 'N/A', value: '' }]}
|
||||
<Field
|
||||
{label}
|
||||
required={component.required}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
>
|
||||
<Select {options} onChange={(value) => updateConfig(key, value)} value={actualConfig[key] as string} />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<!-- MultiSelect component -->
|
||||
{:else if component.type === 'multiselect'}
|
||||
{#if isPickerField(component.subType)}
|
||||
<WorkflowPickerField
|
||||
{component}
|
||||
configKey={key}
|
||||
value={actualConfig[key] as string | string[]}
|
||||
onchange={(value) => updateConfig(key, value)}
|
||||
/>
|
||||
{:else}
|
||||
{@const options = component.options?.map((opt) => {
|
||||
return { label: opt.label, value: String(opt.value) };
|
||||
}) || [{ label: 'N/A', value: '' }]}
|
||||
<Field
|
||||
{label}
|
||||
required={component.required}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
>
|
||||
<MultiSelect
|
||||
{options}
|
||||
values={(actualConfig[key] as string[]) ?? []}
|
||||
onChange={(values) => updateConfig(key, values)}
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<!-- Switch component -->
|
||||
{:else if component.type === 'switch'}
|
||||
{@const checked = Boolean(actualConfig[key])}
|
||||
<Field
|
||||
{label}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
required={component.required}
|
||||
>
|
||||
<Switch {checked} onCheckedChange={(check) => updateConfig(key, check)} />
|
||||
</Field>
|
||||
|
||||
<!-- Text input -->
|
||||
{:else if isPickerField(component.subType)}
|
||||
<WorkflowPickerField
|
||||
{component}
|
||||
configKey={key}
|
||||
value={actualConfig[key] as string | string[]}
|
||||
onchange={(value) => updateConfig(key, value)}
|
||||
/>
|
||||
{:else}
|
||||
<Field
|
||||
{label}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
required={component.required}
|
||||
>
|
||||
<Input
|
||||
id={key}
|
||||
value={actualConfig[key] as string}
|
||||
oninput={(e) => updateConfig(key, e.currentTarget.value)}
|
||||
required={component.required}
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<Text size="small" color="muted">No configuration required</Text>
|
||||
{/if}
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
animated?: boolean;
|
||||
};
|
||||
|
||||
let { animated = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center py-2">
|
||||
<div class="relative h-12 w-0.5">
|
||||
<div class="absolute inset-0 bg-linear-to-b from-primary/30 via-primary/50 to-primary/30"></div>
|
||||
{#if animated}
|
||||
<div class="flow-pulse absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent"></div>
|
||||
{/if}
|
||||
<div class="absolute top-0 left-1/2 -translate-1/2">
|
||||
<div class="size-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2">
|
||||
<div class="size-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>
|
||||
@@ -1,104 +0,0 @@
|
||||
<script lang="ts">
|
||||
import WorkflowPickerItemCard from './WorkflowPickerItemCard.svelte';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
|
||||
import { fetchPickerMetadata, type PickerMetadata } from '$lib/services/workflow.service';
|
||||
import type { ComponentConfig } from '$lib/utils/workflow';
|
||||
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Field, modalManager } from '@immich/ui';
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
component: ComponentConfig;
|
||||
configKey: string;
|
||||
value: string | string[] | undefined;
|
||||
onchange: (value: string | string[]) => void;
|
||||
};
|
||||
|
||||
let { component, configKey, value = $bindable(), onchange }: Props = $props();
|
||||
|
||||
const label = $derived(component.title || component.label || configKey);
|
||||
const subType = $derived(component.subType as 'album-picker' | 'people-picker');
|
||||
const isAlbum = $derived(subType === 'album-picker');
|
||||
const multiple = $derived(component.type === 'multiselect' || Array.isArray(value));
|
||||
|
||||
let pickerMetadata = $state<PickerMetadata | undefined>();
|
||||
|
||||
$effect(() => {
|
||||
if (!value) {
|
||||
pickerMetadata = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pickerMetadata) {
|
||||
void loadMetadata();
|
||||
}
|
||||
});
|
||||
|
||||
const loadMetadata = async () => {
|
||||
pickerMetadata = await fetchPickerMetadata(value, subType);
|
||||
};
|
||||
|
||||
const handlePicker = async () => {
|
||||
if (isAlbum) {
|
||||
const albums = await modalManager.show(AlbumPickerModal);
|
||||
if (albums && albums.length > 0) {
|
||||
const newValue = multiple ? albums.map((album) => album.id) : albums[0].id;
|
||||
onchange(newValue);
|
||||
pickerMetadata = multiple ? albums : albums[0];
|
||||
}
|
||||
} else {
|
||||
const currentIds = (Array.isArray(value) ? value : []) as string[];
|
||||
const excludedIds = multiple ? currentIds : [];
|
||||
const people = await modalManager.show(PeoplePickerModal, { multiple, excludedIds });
|
||||
if (people && people.length > 0) {
|
||||
const newValue = multiple ? people.map((person) => person.id) : people[0].id;
|
||||
onchange(newValue);
|
||||
pickerMetadata = multiple ? people : people[0];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelection = () => {
|
||||
onchange(multiple ? [] : '');
|
||||
pickerMetadata = undefined;
|
||||
};
|
||||
|
||||
const removeItemFromSelection = (itemId: string) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = value.filter((id) => id !== itemId);
|
||||
onchange(newValue);
|
||||
|
||||
if (Array.isArray(pickerMetadata)) {
|
||||
pickerMetadata = pickerMetadata.filter((item) => item.id !== itemId) as AlbumResponseDto[] | PersonResponseDto[];
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (isAlbum) {
|
||||
return multiple ? $t('select_albums') : $t('select_album');
|
||||
}
|
||||
return multiple ? $t('select_people') : $t('select_person');
|
||||
};
|
||||
</script>
|
||||
|
||||
<Field {label} required={component.required} description={component.description} requiredIndicator={component.required}>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if pickerMetadata && !Array.isArray(pickerMetadata)}
|
||||
<WorkflowPickerItemCard item={pickerMetadata} {isAlbum} onRemove={removeSelection} />
|
||||
{:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each pickerMetadata as item (item.id)}
|
||||
<WorkflowPickerItemCard {item} {isAlbum} onRemove={() => removeItemFromSelection(item.id)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={handlePicker}>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
@@ -1,57 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { Card, CardBody, IconButton, Text } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
item: AlbumResponseDto | PersonResponseDto;
|
||||
isAlbum: boolean;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
let { item, isAlbum, onRemove }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Card color="secondary">
|
||||
<CardBody class="flex items-center gap-3">
|
||||
<div class="shrink-0">
|
||||
{#if isAlbum && 'albumThumbnailAssetId' in item}
|
||||
{#if item.albumThumbnailAssetId}
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: item.albumThumbnailAssetId })}
|
||||
alt={item.albumName}
|
||||
class="size-12 rounded-lg object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="size-12 rounded-lg"></div>
|
||||
{/if}
|
||||
{:else if !isAlbum && 'name' in item}
|
||||
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="size-12 rounded-full object-cover" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<Text class="truncate" fontWeight="semi-bold">
|
||||
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
|
||||
</Text>
|
||||
{#if isAlbum && 'assetCount' in item}
|
||||
<Text size="small" color="muted">
|
||||
{$t('items_count', { values: { count: item.assetCount } })}
|
||||
</Text>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
type="button"
|
||||
onclick={onRemove}
|
||||
class="shrink-0"
|
||||
shape="round"
|
||||
aria-label={$t('remove')}
|
||||
icon={mdiClose}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -1,80 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { PluginTriggerType, type PluginTriggerResponseDto } from '@immich/sdk';
|
||||
import { Icon, Text } from '@immich/ui';
|
||||
import { mdiFaceRecognition, mdiFileUploadOutline, mdiLightningBolt } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
trigger: PluginTriggerResponseDto;
|
||||
selected: boolean;
|
||||
onclick: () => void;
|
||||
};
|
||||
|
||||
let { trigger, selected, onclick }: Props = $props();
|
||||
|
||||
const getTriggerIcon = (triggerType: PluginTriggerType) => {
|
||||
switch (triggerType) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
return mdiFileUploadOutline;
|
||||
}
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
return mdiFaceRecognition;
|
||||
}
|
||||
default: {
|
||||
return mdiLightningBolt;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTriggerName = (triggerType: PluginTriggerType) => {
|
||||
switch (triggerType) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
return $t('trigger_asset_uploaded');
|
||||
}
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
return $t('trigger_person_recognized');
|
||||
}
|
||||
default: {
|
||||
return triggerType;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTriggerDescription = (triggerType: PluginTriggerType) => {
|
||||
switch (triggerType) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
return $t('trigger_asset_uploaded_description');
|
||||
}
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
return $t('trigger_person_recognized_description');
|
||||
}
|
||||
default: {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
{onclick}
|
||||
class="group w-full cursor-pointer rounded-xl border-2 p-4 text-left {selected
|
||||
? 'border-primary text-primary'
|
||||
: 'border-light-100 text-light-400 hover:border-light-200 hover:text-light-700'}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="rounded-xl p-2 {selected
|
||||
? 'bg-primary text-light'
|
||||
: 'bg-light-300 text-light-100 group-hover:bg-light-500'}"
|
||||
>
|
||||
<Icon icon={getTriggerIcon(trigger.type)} size="24" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<Text fontWeight="semi-bold" class="mb-1">{getTriggerName(trigger.type)}</Text>
|
||||
{#if getTriggerDescription(trigger.type)}
|
||||
<Text size="small">{getTriggerDescription(trigger.type)}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
+37
-105
@@ -4,14 +4,10 @@
|
||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import {
|
||||
getWorkflowActions,
|
||||
getWorkflowsActions,
|
||||
getWorkflowShowSchemaAction,
|
||||
type WorkflowPayload,
|
||||
} from '$lib/services/workflow.service';
|
||||
import { type PluginFilterResponseDto, type WorkflowResponseDto } from '@immich/sdk';
|
||||
import { getWorkflowActions, getWorkflowsActions, getWorkflowShowSchemaAction } from '$lib/services/workflow.service';
|
||||
import { getWorkflowForShare, type WorkflowResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -20,6 +16,7 @@
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CodeBlock,
|
||||
Container,
|
||||
IconButton,
|
||||
MenuItemType,
|
||||
menuManager,
|
||||
@@ -28,7 +25,7 @@
|
||||
} from '@immich/ui';
|
||||
import { mdiClose, mdiDotsVertical } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
@@ -39,66 +36,16 @@
|
||||
|
||||
let workflows = $state<WorkflowResponseDto[]>(data.workflows);
|
||||
|
||||
const expandedWorkflows = new SvelteSet<string>();
|
||||
const pluginFilterLookup = new SvelteMap<string, PluginFilterResponseDto>();
|
||||
const pluginActionLookup = new SvelteMap<string, PluginFilterResponseDto>();
|
||||
const expandedIds = new SvelteSet<string>();
|
||||
|
||||
for (const plugin of data.plugins) {
|
||||
for (const filter of plugin.filters ?? []) {
|
||||
pluginFilterLookup.set(filter.id, { ...filter });
|
||||
}
|
||||
|
||||
for (const action of plugin.actions ?? []) {
|
||||
pluginActionLookup.set(action.id, { ...action });
|
||||
}
|
||||
}
|
||||
|
||||
const toggleShowingSchema = (id: string) => {
|
||||
if (expandedWorkflows.has(id)) {
|
||||
expandedWorkflows.delete(id);
|
||||
const toggleExpanded = (id: string) => {
|
||||
if (expandedIds.has(id)) {
|
||||
expandedIds.delete(id);
|
||||
} else {
|
||||
expandedWorkflows.add(id);
|
||||
expandedIds.add(id);
|
||||
}
|
||||
};
|
||||
|
||||
const constructPayload = (workflow: WorkflowResponseDto): WorkflowPayload => {
|
||||
const orderedFilters = [...(workflow.filters ?? [])].sort((a, b) => a.order - b.order);
|
||||
const orderedActions = [...(workflow.actions ?? [])].sort((a, b) => a.order - b.order);
|
||||
|
||||
return {
|
||||
name: workflow.name ?? '',
|
||||
description: workflow.description ?? '',
|
||||
enabled: workflow.enabled,
|
||||
triggerType: workflow.triggerType,
|
||||
filters: orderedFilters.map((filter) => {
|
||||
const meta = pluginFilterLookup.get(filter.pluginFilterId);
|
||||
const key = meta?.methodName ?? filter.pluginFilterId;
|
||||
return {
|
||||
[key]: filter.filterConfig ?? {},
|
||||
};
|
||||
}),
|
||||
actions: orderedActions.map((action) => {
|
||||
const meta = pluginActionLookup.get(action.pluginActionId);
|
||||
const key = meta?.methodName ?? action.pluginActionId;
|
||||
return {
|
||||
[key]: action.actionConfig ?? {},
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const getJson = (workflow: WorkflowResponseDto) => JSON.stringify(constructPayload(workflow), null, 2);
|
||||
|
||||
const getFilterLabel = (filterId: string) => {
|
||||
const meta = pluginFilterLookup.get(filterId);
|
||||
return meta?.title ?? $t('filter');
|
||||
};
|
||||
|
||||
const getActionLabel = (actionId: string) => {
|
||||
const meta = pluginActionLookup.get(actionId);
|
||||
return meta?.title ?? $t('action');
|
||||
};
|
||||
|
||||
const getTriggerLabel = (triggerType: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
AssetCreate: $t('asset_created'),
|
||||
@@ -121,7 +68,7 @@
|
||||
items: [
|
||||
ToggleEnabled,
|
||||
Edit,
|
||||
getWorkflowShowSchemaAction($t, expandedWorkflows.has(workflow.id), () => toggleShowingSchema(workflow.id)),
|
||||
getWorkflowShowSchemaAction($t, expandedIds.has(workflow.id), () => toggleExpanded(workflow.id)),
|
||||
MenuItemType.Divider,
|
||||
Delete,
|
||||
],
|
||||
@@ -153,9 +100,10 @@
|
||||
|
||||
<UserPageLayout title={data.meta.title} actions={[Create]} scrollbar={false}>
|
||||
<section class="flex place-content-center sm:mx-4">
|
||||
<section class="w-full pb-28 sm:w-5/6 md:w-4xl">
|
||||
<Container center size="large" class="pb-28">
|
||||
{#if workflows.length === 0}
|
||||
<EmptyPlaceholder
|
||||
fullWidth
|
||||
title={$t('create_first_workflow')}
|
||||
text={$t('workflows_help_text')}
|
||||
onClick={() => Create.onAction(Create)}
|
||||
@@ -177,11 +125,11 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="rounded-full {workflow.enabled ? 'size-3 bg-success' : 'size-3 rounded-full bg-muted'}"
|
||||
></span>
|
||||
<CardTitle>{workflow.name}</CardTitle>
|
||||
<CardTitle>{workflow.name || $t('workflow')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription class="mt-1 text-sm">
|
||||
{workflow.description || $t('workflows_help_text')}
|
||||
</CardDescription>
|
||||
{#if workflow.description}
|
||||
<CardDescription class="mt-1 text-sm">{workflow.description}</CardDescription>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -209,42 +157,24 @@
|
||||
<div class="mb-3">
|
||||
<Text size="tiny" color="muted" fontWeight="medium">{$t('trigger')}</Text>
|
||||
</div>
|
||||
{@render chipItem(getTriggerLabel(workflow.triggerType))}
|
||||
</div>
|
||||
|
||||
<!-- Filters 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('filters')}</Text>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if workflow.filters.length === 0}
|
||||
<span class="text-sm text-light-600">
|
||||
{$t('no_filters_added')}
|
||||
</span>
|
||||
{:else}
|
||||
{#each workflow.filters as workflowFilter (workflowFilter.id)}
|
||||
{@render chipItem(getFilterLabel(workflowFilter.pluginFilterId))}
|
||||
{/each}
|
||||
{/if}
|
||||
</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('actions')}</Text>
|
||||
<Text size="tiny" color="muted" fontWeight="medium">{$t('steps')}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if workflow.actions.length === 0}
|
||||
{#if workflow.steps.length === 0}
|
||||
<span class="text-sm text-light-600">
|
||||
{$t('no_actions_added')}
|
||||
{$t('no_steps')}
|
||||
</span>
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each workflow.actions as workflowAction (workflowAction.id)}
|
||||
{@render chipItem(getActionLabel(workflowAction.pluginActionId))}
|
||||
{#each workflow.steps as step, i (i)}
|
||||
{@render chipItem(pluginManager.getMethodLabel(step.method))}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -252,23 +182,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if expandedWorkflows.has(workflow.id)}
|
||||
<VStack gap={2} class="w-full rounded-2xl border border-light-200 bg-light-50 p-4">
|
||||
<CodeBlock code={getJson(workflow)} lineNumbers />
|
||||
<Button
|
||||
leadingIcon={mdiClose}
|
||||
fullWidth
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onclick={() => toggleShowingSchema(workflow.id)}>{$t('close')}</Button
|
||||
>
|
||||
</VStack>
|
||||
{#if expandedIds.has(workflow.id)}
|
||||
{#await getWorkflowForShare({ id: workflow.id }) then result}
|
||||
<VStack gap={2} class="w-full rounded-2xl border border-light-200 bg-light-50 p-4">
|
||||
<CodeBlock code={JSON.stringify(result, null, 2)} lineNumbers />
|
||||
<Button
|
||||
leadingIcon={mdiClose}
|
||||
fullWidth
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onclick={() => toggleExpanded(workflow.id)}>{$t('close')}</Button
|
||||
>
|
||||
</VStack>
|
||||
{/await}
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</Container>
|
||||
</section>
|
||||
</UserPageLayout>
|
||||
+3
-9
@@ -1,5 +1,5 @@
|
||||
import { getPlugins, getWorkflows } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { searchWorkflows } from '@immich/sdk';
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
@@ -7,17 +7,11 @@ import type { PageLoad } from './$types';
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url);
|
||||
|
||||
const isReady = false;
|
||||
if (!isReady) {
|
||||
redirect(307, '/utilities');
|
||||
}
|
||||
|
||||
const [workflows, plugins] = await Promise.all([getWorkflows(), getPlugins()]);
|
||||
const [workflows] = await Promise.all([searchWorkflows({}), pluginManager.ready()]);
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
workflows,
|
||||
plugins,
|
||||
meta: {
|
||||
title: $t('workflows'),
|
||||
},
|
||||
@@ -0,0 +1,257 @@
|
||||
<script lang="ts">
|
||||
import { goto, invalidate } from '$app/navigation';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
|
||||
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
|
||||
import WorkflowTriggerPicker from '$lib/modals/WorkflowTriggerPicker.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
|
||||
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
||||
import type { WorkflowResponseDto, WorkflowStepDto } from '@immich/sdk';
|
||||
import {
|
||||
ActionBar,
|
||||
AppShell,
|
||||
AppShellBar,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Container,
|
||||
ControlBarContent,
|
||||
ControlBarDescription,
|
||||
ControlBarHeader,
|
||||
ControlBarTitle,
|
||||
Field,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
modalManager,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
VStack,
|
||||
type ActionItem,
|
||||
} from '@immich/ui';
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiContentSave,
|
||||
mdiFlashOutline,
|
||||
mdiFormatListBulletedSquare,
|
||||
mdiInformationOutline,
|
||||
mdiPencilOutline,
|
||||
mdiPlus,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let { id, enabled, name, description, trigger, steps } = $derived(data.workflow);
|
||||
|
||||
const handleAddStep = async () => {
|
||||
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
|
||||
if (step) {
|
||||
steps = [...steps, step];
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditStep = async (step: WorkflowStepDto) => {
|
||||
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step });
|
||||
if (result) {
|
||||
Object.assign(step, result);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteStep = async (index: number) => {
|
||||
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
|
||||
if (confirmed) {
|
||||
steps.splice(index, 1);
|
||||
steps = [...steps];
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = async () => {
|
||||
// check for pending changes
|
||||
await goto(Route.workflows());
|
||||
};
|
||||
|
||||
const onChangeTrigger = async () => {
|
||||
const newTrigger = await modalManager.show(WorkflowTriggerPicker, { selected: trigger });
|
||||
if (newTrigger) {
|
||||
trigger = newTrigger;
|
||||
}
|
||||
};
|
||||
|
||||
const onWorkflowUpdate = async (response: WorkflowResponseDto) => {
|
||||
if (id === response.id) {
|
||||
data.workflow = response;
|
||||
await invalidate('workflow:data');
|
||||
}
|
||||
};
|
||||
|
||||
const Done: ActionItem = {
|
||||
title: $t('save'),
|
||||
icon: mdiContentSave,
|
||||
color: 'primary',
|
||||
onAction: () => handleUpdateWorkflow(id, { enabled, name, description, trigger, steps }),
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents {onWorkflowUpdate} />
|
||||
|
||||
<AppShell>
|
||||
<AppShellBar>
|
||||
<ActionBar static {onClose} translations={{ close: $t('back') }} closeIcon={mdiArrowLeft}>
|
||||
<ControlBarHeader>
|
||||
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
|
||||
<ControlBarDescription>{data.workflow.description}</ControlBarDescription>
|
||||
</ControlBarHeader>
|
||||
<ControlBarContent class="flex justify-end">
|
||||
<HeaderActionButton action={Done} variant="filled" />
|
||||
</ControlBarContent>
|
||||
</ActionBar>
|
||||
</AppShellBar>
|
||||
|
||||
<Container size="medium" class="pt-8 pb-24" center>
|
||||
<VStack gap={4}>
|
||||
<Card expandable>
|
||||
<CardHeader>
|
||||
<div class="flex place-items-start gap-3">
|
||||
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
|
||||
<div class="flex flex-col">
|
||||
<CardTitle>
|
||||
{$t('workflow_info')}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
<VStack gap={4}>
|
||||
<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'}>
|
||||
<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 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 class="flex items-center justify-end">
|
||||
<Button leadingIcon={mdiPencilOutline} size="small" color="secondary" onclick={onChangeTrigger}>
|
||||
{$t('edit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
<div class="flex flex-col items-start">
|
||||
<Text>{getTriggerName($t, trigger)}</Text>
|
||||
<Text size="small" color="muted">{getTriggerDescription($t, trigger)}</Text>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="bg-primary-50">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon icon={mdiFormatListBulletedSquare} size="20" class="mt-1 text-primary" />
|
||||
<CardTitle class="text-left text-primary">{$t('steps')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
{#if steps.length === 0}
|
||||
<Button leadingIcon={mdiPlus} onclick={handleAddStep}>{$t('add_step')}</Button>
|
||||
{:else}
|
||||
<Stack gap={2}>
|
||||
{#each steps as step, index (index)}
|
||||
{@const method = pluginManager.getMethod(step.method)}
|
||||
{#if index > 0}
|
||||
<hr />
|
||||
{/if}
|
||||
<div
|
||||
// {@attach dragAndDrop({
|
||||
// index,
|
||||
// onDragStart: handleFilterDragStart,
|
||||
// onDragEnter: handleFilterDragEnter,
|
||||
// onDrop: handleFilterDrop,
|
||||
// onDragEnd: handleFilterDragEnd,
|
||||
// isDragging: draggedIndex === index,
|
||||
// isDragOver: dragOverIndex === index,
|
||||
// })}
|
||||
class="flex cursor-move justify-between gap-2 rounded-2xl border-2 border-dashed bg-light-50 p-4 transition-all hover:border-light-300"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Text>{pluginManager.getMethodLabel(step.method)}</Text>
|
||||
{#if method?.description}
|
||||
<Text color="muted" size="small">{method.description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<IconButton
|
||||
icon={mdiPencilOutline}
|
||||
aria-label={$t('edit')}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
onclick={() => handleEditStep(step)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={mdiTrashCanOutline}
|
||||
aria-label={$t('delete')}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="danger"
|
||||
onclick={() => handleDeleteStep(index)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<Button size="small" fullWidth variant="ghost" leadingIcon={mdiPlus} onclick={handleAddStep}>
|
||||
{$t('add_step')}
|
||||
</Button>
|
||||
</Stack>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
</Container>
|
||||
</AppShell>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { searchWorkflows } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url, params, depends }) => {
|
||||
await authenticate(url);
|
||||
const [[workflow]] = await Promise.all([searchWorkflows({ id: params.workflowId }), pluginManager.ready()]);
|
||||
const $t = await getFormatter();
|
||||
|
||||
if (!workflow) {
|
||||
redirect(307, Route.workflows());
|
||||
}
|
||||
|
||||
depends('workflow:data');
|
||||
|
||||
return {
|
||||
workflow,
|
||||
meta: {
|
||||
title: $t('edit_workflow'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { WorkflowPayload } from '$lib/services/workflow.service';
|
||||
import type { WorkflowResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -16,9 +16,9 @@
|
||||
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
|
||||
|
||||
type Props = {
|
||||
jsonContent: WorkflowPayload;
|
||||
jsonContent: WorkflowResponseDto;
|
||||
onApply: () => void;
|
||||
onContentChange: (content: WorkflowPayload) => void;
|
||||
onContentChange: (content: WorkflowResponseDto) => void;
|
||||
};
|
||||
|
||||
let { jsonContent, onApply, onContentChange }: Props = $props();
|
||||
+11
-56
@@ -1,35 +1,16 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
PluginTriggerType,
|
||||
type PluginActionResponseDto,
|
||||
type PluginFilterResponseDto,
|
||||
type PluginTriggerResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { getTriggerName } from '$lib/utils/workflow';
|
||||
import type { WorkflowResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, Text } from '@immich/ui';
|
||||
import { mdiClose, mdiFilterOutline, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
|
||||
import { mdiClose, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
trigger: PluginTriggerResponseDto;
|
||||
filters: PluginFilterResponseDto[];
|
||||
actions: PluginActionResponseDto[];
|
||||
workflow: WorkflowResponseDto;
|
||||
};
|
||||
|
||||
let { trigger, filters, actions }: Props = $props();
|
||||
|
||||
const getTriggerName = (triggerType: PluginTriggerType) => {
|
||||
switch (triggerType) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
return $t('trigger_asset_uploaded');
|
||||
}
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
return $t('trigger_person_recognized');
|
||||
}
|
||||
default: {
|
||||
return triggerType;
|
||||
}
|
||||
}
|
||||
};
|
||||
let { workflow }: Props = $props();
|
||||
const { trigger, steps } = $derived(workflow);
|
||||
|
||||
let isOpen = $state(false);
|
||||
let position = $state({ x: 0, y: 0 });
|
||||
@@ -115,7 +96,7 @@
|
||||
<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(trigger.type)}</p>
|
||||
<p class="truncate pl-5 text-sm">{getTriggerName($t, trigger)}</p>
|
||||
</div>
|
||||
|
||||
<!-- Connector -->
|
||||
@@ -123,47 +104,21 @@
|
||||
<div class="h-3 w-0.5 bg-light-400"></div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
{#if filters.length > 0}
|
||||
<div class="rounded-lg border bg-light-100 p-3">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<Icon icon={mdiFilterOutline} size="18" class="text-warning" />
|
||||
<Text size="tiny" fontWeight="semi-bold">{$t('filters')}</Text>
|
||||
</div>
|
||||
<div class="space-y-1 pl-5">
|
||||
{#each filters as filter, 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">{filter.title}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connector -->
|
||||
<div class="flex justify-center">
|
||||
<div class="h-3 w-0.5 bg-light-400"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
{#if actions.length > 0}
|
||||
<!-- 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 actions as action, index (index)}
|
||||
{#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">{action.title}</p>
|
||||
<p class="truncate text-sm">{step.method}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
Reference in New Issue
Block a user