refactor: picker field

This commit is contained in:
Alex Tran
2025-12-04 03:26:00 +00:00
parent bd4355a75f
commit 12ecd65e61
2 changed files with 187 additions and 220 deletions

View File

@@ -1,12 +1,7 @@
<script lang="ts">
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { formatLabel, getComponentFromSchema, type ComponentConfig } from '$lib/utils/workflow';
import { getAlbumInfo, getPerson, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { Button, Field, Input, MultiSelect, Select, Switch, Text, modalManager, type SelectItem } from '@immich/ui';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import { formatLabel, getComponentFromSchema } from '$lib/utils/workflow';
import { Field, Input, MultiSelect, Select, Switch, Text, type SelectItem } from '@immich/ui';
import WorkflowPickerField from './WorkflowPickerField.svelte';
interface Props {
schema: object | null;
@@ -33,18 +28,6 @@
let selectValue = $state<SelectItem>();
let switchValue = $state<boolean>(false);
let multiSelectValue = $state<SelectItem[]>([]);
let pickerMetadata = $state<
Record<string, AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]>
>({});
// Fetch metadata for existing picker values (albums/people)
$effect(() => {
if (!components) {
return;
}
void fetchMetadata(components);
});
$effect(() => {
// Initialize config for actions/filters with empty schemas
@@ -99,202 +82,9 @@
}
});
const fetchMetadata = async (components: Record<string, ComponentConfig>) => {
const metadataUpdates: Record<
string,
AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]
> = {};
for (const [key, component] of Object.entries(components)) {
const value = actualConfig[key];
if (!value || pickerMetadata[key]) {
continue; // Skip if no value or already loaded
}
const isAlbumPicker = component.subType === 'album-picker';
const isPeoplePicker = component.subType === 'people-picker';
if (!isAlbumPicker && !isPeoplePicker) {
continue;
}
try {
if (Array.isArray(value) && value.length > 0) {
// Multiple selection
if (isAlbumPicker) {
const albums = await Promise.all(value.map((id) => getAlbumInfo({ id })));
metadataUpdates[key] = albums;
} else if (isPeoplePicker) {
const people = await Promise.all(value.map((id) => getPerson({ id })));
metadataUpdates[key] = people;
}
} else if (typeof value === 'string' && value) {
// Single selection
if (isAlbumPicker) {
const album = await getAlbumInfo({ id: value });
metadataUpdates[key] = album;
} else if (isPeoplePicker) {
const person = await getPerson({ id: value });
metadataUpdates[key] = person;
}
}
} catch (error) {
console.error(`Failed to fetch metadata for ${key}:`, error);
}
}
if (Object.keys(metadataUpdates).length > 0) {
pickerMetadata = { ...pickerMetadata, ...metadataUpdates };
}
};
const handleAlbumPicker = async (key: string, multiple: boolean) => {
const albums = await modalManager.show(AlbumPickerModal, { shared: false });
if (albums && albums.length > 0) {
const value = multiple ? albums.map((a) => a.id) : albums[0].id;
updateConfig(key, value);
pickerMetadata = {
...pickerMetadata,
[key]: multiple ? albums : albums[0],
};
}
};
const handlePeoplePicker = async (key: string, multiple: boolean) => {
const currentIds = (actualConfig[key] as string[] | undefined) ?? [];
const excludedIds = multiple ? currentIds : [];
const people = await modalManager.show(PeoplePickerModal, { multiple, excludedIds });
if (people && people.length > 0) {
const value = multiple ? people.map((p) => p.id) : people[0].id;
updateConfig(key, value);
pickerMetadata = {
...pickerMetadata,
[key]: multiple ? people : people[0],
};
}
};
const removeSelection = (key: string) => {
const { [key]: _, ...rest } = actualConfig;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [key]: _removed, ...restMetadata } = pickerMetadata;
config = configKey ? { ...config, [configKey]: rest } : rest;
pickerMetadata = restMetadata;
};
const removeItemFromSelection = (key: string, itemId: string) => {
const currentIds = actualConfig[key] as string[];
const currentMetadata = pickerMetadata[key] as (AlbumResponseDto | PersonResponseDto)[];
updateConfig(
key,
currentIds.filter((id) => id !== itemId),
);
pickerMetadata = {
...pickerMetadata,
[key]: currentMetadata.filter((item) => item.id !== itemId) as AlbumResponseDto[] | PersonResponseDto[],
};
};
const renderPicker = (subType: 'album-picker' | 'people-picker', multiple: boolean) => {
const isAlbum = subType === 'album-picker';
const handler = isAlbum ? handleAlbumPicker : handlePeoplePicker;
const selectSingleLabel = isAlbum ? 'select_album' : 'select_person';
const selectMultiLabel = isAlbum ? 'select_albums' : 'select_people';
const buttonText = multiple ? $t(selectMultiLabel) : $t(selectSingleLabel);
return { handler, buttonText };
};
const isPickerField = (subType: string | undefined) => subType === 'album-picker' || subType === 'people-picker';
</script>
{#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'}
<div
class="flex items-center gap-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-3 shadow-sm"
>
<div class="shrink-0">
{#if isAlbum && 'albumThumbnailAssetId' in item}
{#if item.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
alt={item.albumName}
class="{sizeClass} rounded-lg object-cover"
/>
{:else}
<div class="{sizeClass} rounded-lg bg-gray-200 dark:bg-gray-700"></div>
{/if}
{:else if !isAlbum && 'name' in item}
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="{sizeClass} rounded-full object-cover" />
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="{textSizeClass} text-gray-900 dark:text-gray-100 truncate">
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
</p>
{#if isAlbum && 'assetCount' in item}
<p class="{countSizeClass} text-gray-500 dark:text-gray-400">
{$t('items_count', { values: { count: item.assetCount } })}
</p>
{/if}
</div>
<button
type="button"
onclick={onRemove}
class="shrink-0 rounded-full p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={$t('remove')}
>
<svg class={iconSizeClass} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/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'}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<div class="flex flex-col gap-3">
{#if metadata && !Array.isArray(metadata)}
{@render pickerItemCard(metadata, isAlbum, 'large', () => removeSelection(key))}
{:else if metadata && Array.isArray(metadata) && metadata.length > 0}
<div class="flex flex-col gap-2">
{#each metadata as item (item.id)}
{@render pickerItemCard(item, isAlbum, 'small', () => removeItemFromSelection(key, item.id))}
{/each}
</div>
{/if}
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={() => picker.handler(key, multiple)}>
{picker.buttonText}
</Button>
</div>
</Field>
{/snippet}
{#if components}
<div class="flex flex-col gap-2">
{#each Object.entries(components) as [key, component] (key)}
@@ -303,8 +93,13 @@
<div class="flex flex-col gap-1 bg-light-50 border p-4 rounded-xl">
<!-- Select component -->
{#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)}
<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) };
@@ -322,8 +117,13 @@
<!-- MultiSelect component -->
{: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)}
<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) };
@@ -359,8 +159,13 @@
</Field>
<!-- Text input -->
{:else if component.subType === 'album-picker' || component.subType === 'people-picker'}
{@render pickerField(component.subType, key, label, component, false)}
{:else if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
<Field
{label}

View File

@@ -0,0 +1,162 @@
<script lang="ts">
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<AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]>();
// 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');
};
</script>
{#snippet pickerItemCard(item: AlbumResponseDto | PersonResponseDto, onRemove: () => void)}
<Card color="secondary">
<CardBody class="flex items-center gap-3">
<div class="shrink-0">
{#if isAlbum && 'albumThumbnailAssetId' in item}
{#if item.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
alt={item.albumName}
class="h-12 w-12 rounded-lg object-cover"
/>
{:else}
<div class="h-12 w-12 rounded-lg"></div>
{/if}
{:else if !isAlbum && 'name' in item}
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="h-12 w-12 rounded-full object-cover" />
{/if}
</div>
<div class="min-w-0 flex-1">
<Text class="font-semibold truncate">
{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>
{/snippet}
<Field {label} required={component.required} description={component.description} requiredIndicator={component.required}>
<div class="flex flex-col gap-3">
{#if pickerMetadata && !Array.isArray(pickerMetadata)}
{@render pickerItemCard(pickerMetadata, removeSelection)}
{:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
<div class="flex flex-col gap-2">
{#each pickerMetadata as item (item.id)}
{@render pickerItemCard(item, () => removeItemFromSelection(item.id))}
{/each}
</div>
{/if}
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={handlePicker}>
{getButtonText()}
</Button>
</div>
</Field>