feat: workflow ui

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

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, HStack, LoadingSpinner, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
multiple?: boolean;
onClose: (people?: PersonResponseDto[]) => void;
}
let { multiple = false, onClose }: Props = $props();
let people: PersonResponseDto[] = $state([]);
let loading = $state(true);
let searchName = $state('');
let selectedPeople: PersonResponseDto[] = $state([]);
const filteredPeople = $derived(
searchName ? people.filter((person) => person.name.toLowerCase().includes(searchName.toLowerCase())) : people,
);
onMount(async () => {
const result = await getAllPeople({ withHidden: false });
people = result.people;
loading = false;
});
const togglePerson = (person: PersonResponseDto) => {
if (multiple) {
const index = selectedPeople.findIndex((p) => p.id === person.id);
selectedPeople = index === -1 ? [...selectedPeople, person] : selectedPeople.filter((p) => p.id !== person.id);
} else {
onClose([person]);
}
};
const handleSubmit = () => {
if (selectedPeople.length > 0) {
onClose(selectedPeople);
} else {
onClose();
}
};
</script>
<Modal title={multiple ? $t('select_people') : $t('select_person')} {onClose} size="small">
<ModalBody>
<div class="flex flex-col gap-4">
<SearchBar bind:name={searchName} placeholder={$t('search_people')} showLoadingSpinner={false} />
<div class="immich-scrollbar max-h-96 overflow-y-auto">
{#if loading}
<div class="flex justify-center p-8">
<LoadingSpinner />
</div>
{:else if filteredPeople.length > 0}
<div class="grid grid-cols-3 gap-4 p-2">
{#each filteredPeople as person (person.id)}
{@const isSelected = selectedPeople.some((p) => p.id === person.id)}
<button
type="button"
onclick={() => togglePerson(person)}
class="flex flex-col items-center gap-2 rounded-xl p-2 transition-all hover:bg-subtle {isSelected
? 'bg-primary/10 ring-2 ring-primary'
: ''}"
>
<ImageThumbnail
circle
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
widthStyle="100%"
/>
<p class="line-clamp-2 text-center text-sm font-medium">{person.name}</p>
</button>
{/each}
</div>
{:else}
<p class="py-8 text-center text-sm text-gray-500">{$t('no_people_found')}</p>
{/if}
</div>
</div>
</ModalBody>
{#if multiple && selectedPeople.length > 0}
<ModalFooter>
<HStack fullWidth gap={4}>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" fullWidth onclick={handleSubmit}>
{$t('select_count', { values: { count: selectedPeople.length } })}
</Button>
</HStack>
</ModalFooter>
{/if}
</Modal>

View File

@@ -0,0 +1,217 @@
<script lang="ts">
import ActionBuilder from '$lib/components/workflow/ActionBuilder.svelte';
import FilterBuilder from '$lib/components/workflow/FilterBuilder.svelte';
import GroupTab from '$lib/elements/GroupTab.svelte';
import { handleError } from '$lib/utils/handle-error';
import {
createWorkflow,
PluginTriggerType,
updateWorkflow,
type PluginResponseDto,
type WorkflowResponseDto,
} from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Switch, Textarea } from '@immich/ui';
import { mdiAutoFix } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
workflow?: WorkflowResponseDto;
plugins: PluginResponseDto[];
onClose: () => void;
onSave: (workflow: WorkflowResponseDto) => void;
}
let { workflow, plugins, onClose, onSave }: Props = $props();
const isEditMode = !!workflow;
// Form state
let name = $state(workflow?.name || '');
let description = $state(workflow?.description || '');
let triggerType = $state<PluginTriggerType>(workflow?.triggerType || PluginTriggerType.AssetCreate);
let enabled = $state(workflow?.enabled ?? true);
let filters = $state<Array<{ filterId: string; filterConfig?: object }>>(
workflow?.filters.map((f) => ({ filterId: f.filterId, filterConfig: f.filterConfig || undefined })) || [],
);
let actions = $state<Array<{ actionId: string; actionConfig?: object }>>(
workflow?.actions.map((a) => ({ actionId: a.actionId, actionConfig: a.actionConfig || undefined })) || [],
);
// Editor mode state
let editorMode = $state<'visual' | 'json'>('visual');
let jsonText = $state('');
let jsonError = $state('');
// Sync JSON when switching to JSON mode
const syncToJson = () => {
const workflowData = {
...(isEditMode ? { id: workflow!.id } : {}),
name,
description,
triggerType,
enabled,
filters,
actions,
};
jsonText = JSON.stringify(workflowData, null, 2);
jsonError = '';
};
// Sync visual form when switching from JSON mode
const syncFromJson = () => {
try {
const parsed = JSON.parse(jsonText);
name = parsed.name || '';
description = parsed.description || '';
triggerType = parsed.triggerType || PluginTriggerType.AssetCreate;
enabled = parsed.enabled ?? true;
filters = parsed.filters || [];
actions = parsed.actions || [];
jsonError = '';
} catch (error) {
jsonError = error instanceof Error ? error.message : 'Invalid JSON';
}
};
const handleModeChange = (newMode: 'visual' | 'json') => {
if (newMode === 'json' && editorMode === 'visual') {
syncToJson();
} else if (newMode === 'visual' && editorMode === 'json') {
syncFromJson();
}
editorMode = newMode;
};
const handleSubmit = async () => {
// If in JSON mode, sync from JSON first
if (editorMode === 'json') {
syncFromJson();
if (jsonError) {
return;
}
}
if (!name.trim()) {
handleError(new Error($t('name_required')), $t('validation_error'));
return;
}
const trigger =
triggerType === PluginTriggerType.AssetCreate
? PluginTriggerType.AssetCreate
: PluginTriggerType.PersonRecognized;
try {
let result: WorkflowResponseDto;
result = await (isEditMode
? updateWorkflow({
id: workflow!.id,
workflowUpdateDto: {
name,
description: description || undefined,
enabled,
filters,
actions,
},
})
: createWorkflow({
workflowCreateDto: {
name,
description: description || undefined,
triggerType: trigger,
enabled,
filters,
actions,
},
}));
onSave(result);
onClose();
} catch (error) {
handleError(error, isEditMode ? $t('errors.unable_to_create') : $t('errors.unable_to_create'));
}
};
</script>
<Modal title={isEditMode ? $t('edit_workflow') : $t('create_workflow')} icon={mdiAutoFix} {onClose} size="large">
<ModalBody>
<div class="mb-4">
<GroupTab
filters={['visual', 'json']}
labels={[$t('visual_builder'), $t('json_editor')]}
selected={editorMode}
label={$t('editor_mode')}
onSelect={(mode) => handleModeChange(mode as 'visual' | 'json')}
/>
</div>
{#if editorMode === 'visual'}
<form
id="workflow-form"
onsubmit={(e) => {
e.preventDefault();
void handleSubmit();
}}
class="mt-4 flex flex-col gap-4"
>
<Field label={$t('name')} required>
<Input bind:value={name} required />
</Field>
<Field label={$t('description')}>
<Textarea bind:value={description} />
</Field>
{#if !isEditMode}
<Field label={$t('trigger_type')} required>
<select bind:value={triggerType} class="immich-form-input w-full" required>
<option value={PluginTriggerType.AssetCreate}>{$t('asset_created')}</option>
<option value={PluginTriggerType.PersonRecognized}>{$t('person_recognized')}</option>
</select>
</Field>
{:else}
<Field label={$t('trigger_type')}>
<Input value={triggerType} disabled />
</Field>
{/if}
<Field label={$t('enabled')}>
<Switch bind:checked={enabled} />
</Field>
<div class="border-t pt-4 dark:border-gray-700">
<h3 class="mb-2 font-semibold">{$t('filter')}</h3>
<FilterBuilder bind:filters {triggerType} {plugins} />
</div>
<div class="border-t pt-4 dark:border-gray-700">
<h3 class="mb-2 font-semibold">{$t('actions')}</h3>
<ActionBuilder bind:actions {triggerType} {plugins} />
</div>
</form>
{:else}
<div class="mt-4 flex flex-col gap-4">
{#if jsonError}
<div class="rounded-lg bg-red-100 p-3 text-sm text-red-800 dark:bg-red-900 dark:text-red-200">
{$t('json_error')}: {jsonError}
</div>
{/if}
<Field label={$t('workflow_json')}>
<textarea bind:value={jsonText} class="immich-form-input h-96 w-full font-mono text-sm" spellcheck="false"
></textarea>
</Field>
<p class="text-sm text-gray-600 dark:text-gray-400">
{$t('workflow_json_help')}
</p>
</div>
{/if}
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button color="secondary" fullWidth onclick={onClose}>{$t('cancel')}</Button>
<Button type="submit" fullWidth form="workflow-form" onclick={handleSubmit}>
{isEditMode ? $t('save') : $t('create')}
</Button>
</HStack>
</ModalFooter>
</Modal>