mirror of
https://github.com/immich-app/immich.git
synced 2025-12-22 23:26:58 -08:00
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:
80
web/src/lib/modals/AddWorkflowStepModal.svelte
Normal file
80
web/src/lib/modals/AddWorkflowStepModal.svelte
Normal 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>
|
||||
108
web/src/lib/modals/PeoplePickerModal.svelte
Normal file
108
web/src/lib/modals/PeoplePickerModal.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user