diff --git a/i18n/en.json b/i18n/en.json
index 3d2a7625f4..6d8449dcc7 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1,5 +1,4 @@
{
- "get_people_error": "Error getting people",
"about": "About",
"account": "Account",
"account_settings": "Account Settings",
@@ -1160,6 +1159,7 @@
"general": "General",
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
"get_help": "Get Help",
+ "get_people_error": "Error getting people",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
"getting_started": "Getting Started",
"go_back": "Go back",
@@ -2256,6 +2256,7 @@
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
+ "visual": "Visual",
"visual_builder": "Visual builder",
"waiting": "Waiting",
"warning": "Warning",
diff --git a/web/src/lib/components/workflows/SchemaFormFields.svelte b/web/src/lib/components/workflows/SchemaFormFields.svelte
index b88d6bbd97..090683c77f 100644
--- a/web/src/lib/components/workflows/SchemaFormFields.svelte
+++ b/web/src/lib/components/workflows/SchemaFormFields.svelte
@@ -1,12 +1,7 @@
-{#snippet pickerItemCard(
- item: AlbumResponseDto | PersonResponseDto,
- isAlbum: boolean,
- size: 'large' | 'small',
- onRemove: () => void,
-)}
- {@const sizeClass = size === 'large' ? 'h-16 w-16' : 'h-12 w-12'}
- {@const textSizeClass = size === 'large' ? 'font-medium' : 'font-medium text-sm'}
- {@const iconSizeClass = size === 'large' ? 'h-5 w-5' : 'h-4 w-4'}
- {@const countSizeClass = size === 'large' ? 'text-sm' : 'text-xs'}
-
-
-
- {#if isAlbum && 'albumThumbnailAssetId' in item}
- {#if item.albumThumbnailAssetId}
-
})
- {:else}
-
- {/if}
- {:else if !isAlbum && 'name' in item}
-
})
- {/if}
-
-
-
- {isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
-
- {#if isAlbum && 'assetCount' in item}
-
- {$t('items_count', { values: { count: item.assetCount } })}
-
- {/if}
-
-
-
-{/snippet}
-
-{#snippet pickerField(
- subType: string,
- key: string,
- label: string,
- component: { required?: boolean; description?: string },
- multiple: boolean,
-)}
- {@const picker = renderPicker(subType as 'album-picker' | 'people-picker', multiple)}
- {@const metadata = pickerMetadata[key]}
- {@const isAlbum = subType === 'album-picker'}
-
-
-
- {#if metadata && !Array.isArray(metadata)}
- {@render pickerItemCard(metadata, isAlbum, 'large', () => removeSelection(key))}
- {:else if metadata && Array.isArray(metadata) && metadata.length > 0}
-
- {#each metadata as item (item.id)}
- {@render pickerItemCard(item, isAlbum, 'small', () => removeItemFromSelection(key, item.id))}
- {/each}
-
- {/if}
-
-
-
-{/snippet}
-
{#if components}
{#each Object.entries(components) as [key, component] (key)}
{@const label = component.title || component.label || key}
-
+
{#if component.type === 'select'}
- {#if component.subType === 'album-picker' || component.subType === 'people-picker'}
- {@render pickerField(component.subType, key, label, component, false)}
+ {#if isPickerField(component.subType)}
+
updateConfig(key, value)}
+ />
{:else}
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
@@ -322,8 +117,13 @@
{:else if component.type === 'multiselect'}
- {#if component.subType === 'album-picker' || component.subType === 'people-picker'}
- {@render pickerField(component.subType, key, label, component, true)}
+ {#if isPickerField(component.subType)}
+ updateConfig(key, value)}
+ />
{:else}
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
@@ -359,8 +159,13 @@
- {:else if component.subType === 'album-picker' || component.subType === 'people-picker'}
- {@render pickerField(component.subType, key, label, component, false)}
+ {:else if isPickerField(component.subType)}
+ updateConfig(key, value)}
+ />
{:else}
+ import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
+ import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
+ import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
+ import type { ComponentConfig } from '$lib/utils/workflow';
+ import { getAlbumInfo, getPerson, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
+ import { Button, Card, CardBody, Field, IconButton, modalManager, Text } from '@immich/ui';
+ import { mdiClose, mdiPlus } from '@mdi/js';
+ import { t } from 'svelte-i18n';
+
+ interface 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();
+
+ // Fetch metadata for existing picker values (albums/people)
+ $effect(() => {
+ if (!value) {
+ pickerMetadata = undefined;
+ return;
+ }
+
+ void fetchMetadata();
+ });
+
+ const fetchMetadata = async () => {
+ if (!value || pickerMetadata) {
+ return;
+ }
+
+ try {
+ if (Array.isArray(value) && value.length > 0) {
+ // Multiple selection
+ pickerMetadata = await (isAlbum
+ ? Promise.all(value.map((id) => getAlbumInfo({ id })))
+ : Promise.all(value.map((id) => getPerson({ id }))));
+ } else if (typeof value === 'string' && value) {
+ // Single selection
+ pickerMetadata = await (isAlbum ? getAlbumInfo({ id: value }) : getPerson({ id: value }));
+ }
+ } catch (error) {
+ console.error(`Failed to fetch metadata for ${configKey}:`, error);
+ }
+ };
+
+ const handlePicker = async () => {
+ if (isAlbum) {
+ const albums = await modalManager.show(AlbumPickerModal, { shared: false });
+ if (albums && albums.length > 0) {
+ const newValue = multiple ? albums.map((a) => a.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((p) => p.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');
+ };
+
+
+{#snippet pickerItemCard(item: AlbumResponseDto | PersonResponseDto, onRemove: () => void)}
+
+
+
+ {#if isAlbum && 'albumThumbnailAssetId' in item}
+ {#if item.albumThumbnailAssetId}
+
})
+ {:else}
+
+ {/if}
+ {:else if !isAlbum && 'name' in item}
+
})
+ {/if}
+
+
+
+ {isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
+
+ {#if isAlbum && 'assetCount' in item}
+
+ {$t('items_count', { values: { count: item.assetCount } })}
+
+ {/if}
+
+
+
+
+
+{/snippet}
+
+
+
+ {#if pickerMetadata && !Array.isArray(pickerMetadata)}
+ {@render pickerItemCard(pickerMetadata, removeSelection)}
+ {:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
+
+ {#each pickerMetadata as item (item.id)}
+ {@render pickerItemCard(item, () => removeItemFromSelection(item.id))}
+ {/each}
+
+ {/if}
+
+
+
diff --git a/web/src/routes/(user)/utilities/workflows/[workflowId]/+page.svelte b/web/src/routes/(user)/utilities/workflows/[workflowId]/+page.svelte
index 2d3d88fccb..03113c1c39 100644
--- a/web/src/routes/(user)/utilities/workflows/[workflowId]/+page.svelte
+++ b/web/src/routes/(user)/utilities/workflows/[workflowId]/+page.svelte
@@ -440,7 +440,7 @@
isDragging: draggedFilterIndex === index,
isDragOver: dragOverFilterIndex === index,
}}
- class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-light-50 border-dashed border-transparent hover:border-light-300"
+ class="mb-4 cursor-move rounded-2xl border-2 p-4 transition-all bg-light-50 border-dashed hover:border-light-300"
>
{@render cardOrder(index)}
@@ -472,7 +472,7 @@
leadingIcon={mdiPlus}
onclick={() => handleAddStep('filter')}
>
- Add more
+ {$t('add_filter')}
{/if}
@@ -509,7 +509,7 @@
isDragging: draggedActionIndex === index,
isDragOver: dragOverActionIndex === index,
}}
- class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-light-50 border-dashed border-transparent hover:border-light-300"
+ class="mb-4 cursor-move rounded-2xl border-2 p-4 transition-all bg-light-50 border-dashed hover:border-light-300"
>
{@render cardOrder(index)}
@@ -540,7 +540,7 @@
leadingIcon={mdiPlus}
onclick={() => handleAddStep('action')}
>
- Add more
+ {$t('add_action')}
{/if}
@@ -567,7 +567,7 @@
leadingIcon={mdiViewDashboard}
onclick={() => (viewMode = 'visual')}
>
- Visual
+ {$t('visual')}