feat: workflow ui (#24190)

* feat: workflow ui

* wip

* wip

* wip

* pr feedback

* refactor: picker field

* use showDialog directly

* better test

* refactor step selection modal

* move enable button to info form

* use  for Props

* pr feedback

* refactor ActionItem

* refactor ActionItem

* more refactor

* fix: new schemaformfield has value of the same type

* chore: clean up
This commit is contained in:
Alex
2025-12-20 21:07:07 -06:00
committed by GitHub
parent 4b3b458bb6
commit 28f6064240
49 changed files with 4017 additions and 304 deletions

View File

@@ -0,0 +1,80 @@
<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 p-3 rounded-lg text-left bg-light-100 hover:border-primary border text-dark"
>
<div class="flex-1">
<Text color="primary" class="font-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>

View File

@@ -0,0 +1,108 @@
<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 { handleError } from '$lib/utils/handle-error';
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';
type Props = {
multiple?: boolean;
excludedIds?: string[];
onClose: (people?: PersonResponseDto[]) => void;
};
let { multiple = false, excludedIds = [], onClose }: Props = $props();
let people: PersonResponseDto[] = $state([]);
let loading = $state(true);
let searchName = $state('');
let selectedPeople: PersonResponseDto[] = $state([]);
const filteredPeople = $derived(
people
.filter((person) => !excludedIds.includes(person.id))
.filter((person) => !searchName || person.name.toLowerCase().includes(searchName.toLowerCase())),
);
onMount(async () => {
try {
loading = true;
const result = await getAllPeople({ withHidden: false });
people = result.people;
loading = false;
} catch (error) {
handleError(error, $t('get_people_error'));
}
});
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}
<ModalFooter>
<HStack fullWidth gap={4}>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" fullWidth onclick={handleSubmit} disabled={selectedPeople.length === 0}>
{$t('select_count', { values: { count: selectedPeople.length } })}
</Button>
</HStack>
</ModalFooter>
{/if}
</Modal>