Compare commits

...

2 Commits

Author SHA1 Message Date
Alex Tran
91f2b5a387 use showDialog directly 2025-12-04 04:18:35 +00:00
Alex Tran
288ba44825 refactor: picker field 2025-12-04 04:08:51 +00:00
8 changed files with 209 additions and 284 deletions

View File

@@ -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",

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,212 +82,24 @@
}
});
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)}
{@const label = component.title || component.label || key}
<div class="flex flex-col gap-1 bg-light-50 border p-4 rounded-xl">
<div class="flex flex-col gap-1 border bg-light 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>

View File

@@ -1,16 +0,0 @@
<script lang="ts">
import { ConfirmModal } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
onClose: (confirmed: boolean) => void;
};
let { onClose }: Props = $props();
</script>
<ConfirmModal
confirmColor="danger"
prompt={$t('workflow_delete_prompt')}
onClose={(confirmed) => (confirmed ? onClose(true) : onClose(false))}
/>

View File

@@ -1,16 +0,0 @@
<script lang="ts">
import { ConfirmModal } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
onClose: (confirmed: boolean) => void;
};
let { onClose }: Props = $props();
</script>
<ConfirmModal
confirmColor="primary"
prompt={$t('workflow_navigation_prompt')}
onClose={(confirmed) => (confirmed ? onClose(true) : onClose(false))}
/>

View File

@@ -1,19 +0,0 @@
<script lang="ts">
import { ConfirmModal } from '@immich/ui';
import { mdiLightningBolt } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
onClose: (confirmed: boolean) => void;
};
let { onClose }: Props = $props();
</script>
<ConfirmModal
confirmColor="primary"
title={$t('change_trigger')}
icon={mdiLightningBolt}
prompt={$t('change_trigger_prompt')}
onClose={(confirmed) => (confirmed ? onClose(true) : onClose(false))}
/>

View File

@@ -2,7 +2,6 @@
import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { AppRoute } from '$lib/constants';
import WorkflowDeleteConfirmModal from '$lib/modals/WorkflowDeleteConfirmModal.svelte';
import type { WorkflowPayload } from '$lib/services/workflow.service';
import { handleError } from '$lib/utils/handle-error';
import {
@@ -109,7 +108,11 @@
const handleDeleteWorkflow = async (workflow: WorkflowResponseDto) => {
try {
const confirmed = await modalManager.show(WorkflowDeleteConfirmModal);
const confirmed = await modalManager.showDialog({
prompt: $t('workflow_delete_prompt'),
confirmColor: 'danger',
});
if (!confirmed) {
return;
}

View File

@@ -9,8 +9,6 @@
import WorkflowTriggerCard from '$lib/components/workflows/WorkflowTriggerCard.svelte';
import { AppRoute } from '$lib/constants';
import AddWorkflowStepModal from '$lib/modals/AddWorkflowStepModal.svelte';
import WorkflowNavigationConfirmModal from '$lib/modals/WorkflowNavigationConfirmModal.svelte';
import WorkflowTriggerUpdateConfirmModal from '$lib/modals/WorkflowTriggerUpdateConfirmModal.svelte';
import {
buildWorkflowPayload,
getActionsByContext,
@@ -287,7 +285,11 @@
};
const handleTriggerChange = async (newTrigger: PluginTriggerResponseDto) => {
const confirmed = await modalManager.show(WorkflowTriggerUpdateConfirmModal);
const confirmed = await modalManager.showDialog({
prompt: $t('change_trigger_prompt'),
title: $t('change_trigger'),
confirmColor: 'primary',
});
if (!confirmed) {
return;
@@ -303,7 +305,10 @@
cancel();
modalManager
.show(WorkflowNavigationConfirmModal)
.showDialog({
prompt: $t('workflow_navigation_prompt'),
confirmColor: 'primary',
})
.then((isConfirmed) => {
if (isConfirmed && to) {
allowNavigation = true;
@@ -440,7 +445,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"
>
<div class="flex items-start gap-4">
{@render cardOrder(index)}
@@ -472,7 +477,7 @@
leadingIcon={mdiPlus}
onclick={() => handleAddStep('filter')}
>
Add more
{$t('add_filter')}
</Button>
{/if}
</CardBody>
@@ -509,7 +514,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"
>
<div class="flex items-start gap-4">
{@render cardOrder(index)}
@@ -540,7 +545,7 @@
leadingIcon={mdiPlus}
onclick={() => handleAddStep('action')}
>
Add more
{$t('add_action')}
</Button>
{/if}
</CardBody>
@@ -567,7 +572,7 @@
leadingIcon={mdiViewDashboard}
onclick={() => (viewMode = 'visual')}
>
Visual
{$t('visual')}
</Button>
<Button
size="small"