|
1 | 1 | <script lang="ts"> |
2 | | - import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte'; |
3 | | - import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte'; |
4 | | - import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils'; |
5 | | - import { formatLabel, getComponentFromSchema, type ComponentConfig } from '$lib/utils/workflow'; |
6 | | - import { getAlbumInfo, getPerson, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; |
7 | | - import { Button, Field, Input, MultiSelect, Select, Switch, Text, modalManager, type SelectItem } from '@immich/ui'; |
8 | | - import { mdiPlus } from '@mdi/js'; |
9 | | - import { t } from 'svelte-i18n'; |
| 2 | + import { formatLabel, getComponentFromSchema } from '$lib/utils/workflow'; |
| 3 | + import { Field, Input, MultiSelect, Select, Switch, Text, type SelectItem } from '@immich/ui'; |
| 4 | + import WorkflowPickerField from './WorkflowPickerField.svelte'; |
10 | 5 |
|
11 | 6 | interface Props { |
12 | 7 | schema: object | null; |
|
33 | 28 | let selectValue = $state<SelectItem>(); |
34 | 29 | let switchValue = $state<boolean>(false); |
35 | 30 | let multiSelectValue = $state<SelectItem[]>([]); |
36 | | - let pickerMetadata = $state< |
37 | | - Record<string, AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]> |
38 | | - >({}); |
39 | | -
|
40 | | - // Fetch metadata for existing picker values (albums/people) |
41 | | - $effect(() => { |
42 | | - if (!components) { |
43 | | - return; |
44 | | - } |
45 | | -
|
46 | | - void fetchMetadata(components); |
47 | | - }); |
48 | 31 |
|
49 | 32 | $effect(() => { |
50 | 33 | // Initialize config for actions/filters with empty schemas |
|
99 | 82 | } |
100 | 83 | }); |
101 | 84 |
|
102 | | - const fetchMetadata = async (components: Record<string, ComponentConfig>) => { |
103 | | - const metadataUpdates: Record< |
104 | | - string, |
105 | | - AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[] |
106 | | - > = {}; |
107 | | -
|
108 | | - for (const [key, component] of Object.entries(components)) { |
109 | | - const value = actualConfig[key]; |
110 | | - if (!value || pickerMetadata[key]) { |
111 | | - continue; // Skip if no value or already loaded |
112 | | - } |
113 | | -
|
114 | | - const isAlbumPicker = component.subType === 'album-picker'; |
115 | | - const isPeoplePicker = component.subType === 'people-picker'; |
116 | | -
|
117 | | - if (!isAlbumPicker && !isPeoplePicker) { |
118 | | - continue; |
119 | | - } |
120 | | -
|
121 | | - try { |
122 | | - if (Array.isArray(value) && value.length > 0) { |
123 | | - // Multiple selection |
124 | | - if (isAlbumPicker) { |
125 | | - const albums = await Promise.all(value.map((id) => getAlbumInfo({ id }))); |
126 | | - metadataUpdates[key] = albums; |
127 | | - } else if (isPeoplePicker) { |
128 | | - const people = await Promise.all(value.map((id) => getPerson({ id }))); |
129 | | - metadataUpdates[key] = people; |
130 | | - } |
131 | | - } else if (typeof value === 'string' && value) { |
132 | | - // Single selection |
133 | | - if (isAlbumPicker) { |
134 | | - const album = await getAlbumInfo({ id: value }); |
135 | | - metadataUpdates[key] = album; |
136 | | - } else if (isPeoplePicker) { |
137 | | - const person = await getPerson({ id: value }); |
138 | | - metadataUpdates[key] = person; |
139 | | - } |
140 | | - } |
141 | | - } catch (error) { |
142 | | - console.error(`Failed to fetch metadata for ${key}:`, error); |
143 | | - } |
144 | | - } |
145 | | -
|
146 | | - if (Object.keys(metadataUpdates).length > 0) { |
147 | | - pickerMetadata = { ...pickerMetadata, ...metadataUpdates }; |
148 | | - } |
149 | | - }; |
150 | | -
|
151 | | - const handleAlbumPicker = async (key: string, multiple: boolean) => { |
152 | | - const albums = await modalManager.show(AlbumPickerModal, { shared: false }); |
153 | | - if (albums && albums.length > 0) { |
154 | | - const value = multiple ? albums.map((a) => a.id) : albums[0].id; |
155 | | - updateConfig(key, value); |
156 | | - pickerMetadata = { |
157 | | - ...pickerMetadata, |
158 | | - [key]: multiple ? albums : albums[0], |
159 | | - }; |
160 | | - } |
161 | | - }; |
162 | | -
|
163 | | - const handlePeoplePicker = async (key: string, multiple: boolean) => { |
164 | | - const currentIds = (actualConfig[key] as string[] | undefined) ?? []; |
165 | | - const excludedIds = multiple ? currentIds : []; |
166 | | - const people = await modalManager.show(PeoplePickerModal, { multiple, excludedIds }); |
167 | | - if (people && people.length > 0) { |
168 | | - const value = multiple ? people.map((p) => p.id) : people[0].id; |
169 | | - updateConfig(key, value); |
170 | | - pickerMetadata = { |
171 | | - ...pickerMetadata, |
172 | | - [key]: multiple ? people : people[0], |
173 | | - }; |
174 | | - } |
175 | | - }; |
176 | | -
|
177 | | - const removeSelection = (key: string) => { |
178 | | - const { [key]: _, ...rest } = actualConfig; |
179 | | - // eslint-disable-next-line @typescript-eslint/no-unused-vars |
180 | | - const { [key]: _removed, ...restMetadata } = pickerMetadata; |
181 | | -
|
182 | | - config = configKey ? { ...config, [configKey]: rest } : rest; |
183 | | - pickerMetadata = restMetadata; |
184 | | - }; |
185 | | -
|
186 | | - const removeItemFromSelection = (key: string, itemId: string) => { |
187 | | - const currentIds = actualConfig[key] as string[]; |
188 | | - const currentMetadata = pickerMetadata[key] as (AlbumResponseDto | PersonResponseDto)[]; |
189 | | -
|
190 | | - updateConfig( |
191 | | - key, |
192 | | - currentIds.filter((id) => id !== itemId), |
193 | | - ); |
194 | | - pickerMetadata = { |
195 | | - ...pickerMetadata, |
196 | | - [key]: currentMetadata.filter((item) => item.id !== itemId) as AlbumResponseDto[] | PersonResponseDto[], |
197 | | - }; |
198 | | - }; |
199 | | -
|
200 | | - const renderPicker = (subType: 'album-picker' | 'people-picker', multiple: boolean) => { |
201 | | - const isAlbum = subType === 'album-picker'; |
202 | | - const handler = isAlbum ? handleAlbumPicker : handlePeoplePicker; |
203 | | - const selectSingleLabel = isAlbum ? 'select_album' : 'select_person'; |
204 | | - const selectMultiLabel = isAlbum ? 'select_albums' : 'select_people'; |
205 | | -
|
206 | | - const buttonText = multiple ? $t(selectMultiLabel) : $t(selectSingleLabel); |
207 | | -
|
208 | | - return { handler, buttonText }; |
209 | | - }; |
| 85 | + const isPickerField = (subType: string | undefined) => subType === 'album-picker' || subType === 'people-picker'; |
210 | 86 | </script> |
211 | 87 |
|
212 | | -{#snippet pickerItemCard( |
213 | | - item: AlbumResponseDto | PersonResponseDto, |
214 | | - isAlbum: boolean, |
215 | | - size: 'large' | 'small', |
216 | | - onRemove: () => void, |
217 | | -)} |
218 | | - {@const sizeClass = size === 'large' ? 'h-16 w-16' : 'h-12 w-12'} |
219 | | - {@const textSizeClass = size === 'large' ? 'font-medium' : 'font-medium text-sm'} |
220 | | - {@const iconSizeClass = size === 'large' ? 'h-5 w-5' : 'h-4 w-4'} |
221 | | - {@const countSizeClass = size === 'large' ? 'text-sm' : 'text-xs'} |
222 | | - |
223 | | - <div |
224 | | - 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" |
225 | | - > |
226 | | - <div class="shrink-0"> |
227 | | - {#if isAlbum && 'albumThumbnailAssetId' in item} |
228 | | - {#if item.albumThumbnailAssetId} |
229 | | - <img |
230 | | - src={getAssetThumbnailUrl(item.albumThumbnailAssetId)} |
231 | | - alt={item.albumName} |
232 | | - class="{sizeClass} rounded-lg object-cover" |
233 | | - /> |
234 | | - {:else} |
235 | | - <div class="{sizeClass} rounded-lg bg-gray-200 dark:bg-gray-700"></div> |
236 | | - {/if} |
237 | | - {:else if !isAlbum && 'name' in item} |
238 | | - <img src={getPeopleThumbnailUrl(item)} alt={item.name} class="{sizeClass} rounded-full object-cover" /> |
239 | | - {/if} |
240 | | - </div> |
241 | | - <div class="flex-1 min-w-0"> |
242 | | - <p class="{textSizeClass} text-gray-900 dark:text-gray-100 truncate"> |
243 | | - {isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''} |
244 | | - </p> |
245 | | - {#if isAlbum && 'assetCount' in item} |
246 | | - <p class="{countSizeClass} text-gray-500 dark:text-gray-400"> |
247 | | - {$t('items_count', { values: { count: item.assetCount } })} |
248 | | - </p> |
249 | | - {/if} |
250 | | - </div> |
251 | | - <button |
252 | | - type="button" |
253 | | - onclick={onRemove} |
254 | | - class="shrink-0 rounded-full p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" |
255 | | - aria-label={$t('remove')} |
256 | | - > |
257 | | - <svg class={iconSizeClass} fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
258 | | - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> |
259 | | - </svg> |
260 | | - </button> |
261 | | - </div> |
262 | | -{/snippet} |
263 | | - |
264 | | -{#snippet pickerField( |
265 | | - subType: string, |
266 | | - key: string, |
267 | | - label: string, |
268 | | - component: { required?: boolean; description?: string }, |
269 | | - multiple: boolean, |
270 | | -)} |
271 | | - {@const picker = renderPicker(subType as 'album-picker' | 'people-picker', multiple)} |
272 | | - {@const metadata = pickerMetadata[key]} |
273 | | - {@const isAlbum = subType === 'album-picker'} |
274 | | - |
275 | | - <Field |
276 | | - {label} |
277 | | - required={component.required} |
278 | | - description={component.description} |
279 | | - requiredIndicator={component.required} |
280 | | - > |
281 | | - <div class="flex flex-col gap-3"> |
282 | | - {#if metadata && !Array.isArray(metadata)} |
283 | | - {@render pickerItemCard(metadata, isAlbum, 'large', () => removeSelection(key))} |
284 | | - {:else if metadata && Array.isArray(metadata) && metadata.length > 0} |
285 | | - <div class="flex flex-col gap-2"> |
286 | | - {#each metadata as item (item.id)} |
287 | | - {@render pickerItemCard(item, isAlbum, 'small', () => removeItemFromSelection(key, item.id))} |
288 | | - {/each} |
289 | | - </div> |
290 | | - {/if} |
291 | | - <Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={() => picker.handler(key, multiple)}> |
292 | | - {picker.buttonText} |
293 | | - </Button> |
294 | | - </div> |
295 | | - </Field> |
296 | | -{/snippet} |
297 | | - |
298 | 88 | {#if components} |
299 | 89 | <div class="flex flex-col gap-2"> |
300 | 90 | {#each Object.entries(components) as [key, component] (key)} |
|
303 | 93 | <div class="flex flex-col gap-1 bg-light-50 border p-4 rounded-xl"> |
304 | 94 | <!-- Select component --> |
305 | 95 | {#if component.type === 'select'} |
306 | | - {#if component.subType === 'album-picker' || component.subType === 'people-picker'} |
307 | | - {@render pickerField(component.subType, key, label, component, false)} |
| 96 | + {#if isPickerField(component.subType)} |
| 97 | + <WorkflowPickerField |
| 98 | + {component} |
| 99 | + configKey={key} |
| 100 | + value={actualConfig[key] as string | string[]} |
| 101 | + onchange={(value) => updateConfig(key, value)} |
| 102 | + /> |
308 | 103 | {:else} |
309 | 104 | {@const options = component.options?.map((opt) => { |
310 | 105 | return { label: opt.label, value: String(opt.value) }; |
|
322 | 117 |
|
323 | 118 | <!-- MultiSelect component --> |
324 | 119 | {:else if component.type === 'multiselect'} |
325 | | - {#if component.subType === 'album-picker' || component.subType === 'people-picker'} |
326 | | - {@render pickerField(component.subType, key, label, component, true)} |
| 120 | + {#if isPickerField(component.subType)} |
| 121 | + <WorkflowPickerField |
| 122 | + {component} |
| 123 | + configKey={key} |
| 124 | + value={actualConfig[key] as string | string[]} |
| 125 | + onchange={(value) => updateConfig(key, value)} |
| 126 | + /> |
327 | 127 | {:else} |
328 | 128 | {@const options = component.options?.map((opt) => { |
329 | 129 | return { label: opt.label, value: String(opt.value) }; |
|
359 | 159 | </Field> |
360 | 160 |
|
361 | 161 | <!-- Text input --> |
362 | | - {:else if component.subType === 'album-picker' || component.subType === 'people-picker'} |
363 | | - {@render pickerField(component.subType, key, label, component, false)} |
| 162 | + {:else if isPickerField(component.subType)} |
| 163 | + <WorkflowPickerField |
| 164 | + {component} |
| 165 | + configKey={key} |
| 166 | + value={actualConfig[key] as string | string[]} |
| 167 | + onchange={(value) => updateConfig(key, value)} |
| 168 | + /> |
364 | 169 | {:else} |
365 | 170 | <Field |
366 | 171 | {label} |
|
0 commit comments