mirror of
https://github.com/immich-app/immich.git
synced 2025-12-23 17:25:11 +03:00
feat: workflow ui
This commit is contained in:
@@ -60,12 +60,12 @@
|
||||
{/if}
|
||||
|
||||
<main class="relative">
|
||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
|
||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2 z-10" use:useActions={use}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
{#if title || buttons}
|
||||
<div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark">
|
||||
<div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark z-10">
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if title}
|
||||
<div class="font-medium outline-none pe-8" tabindex="-1" id={headerId}>{title}</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
|
||||
import { Icon, modalManager } from '@immich/ui';
|
||||
import {
|
||||
mdiAutoFix,
|
||||
mdiCellphoneArrowDownVariant,
|
||||
mdiContentDuplicate,
|
||||
mdiCrosshairsGps,
|
||||
@@ -16,6 +17,7 @@
|
||||
{ href: AppRoute.DUPLICATES, icon: mdiContentDuplicate, label: $t('review_duplicates') },
|
||||
{ href: AppRoute.LARGE_FILES, icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
|
||||
{ href: AppRoute.GEOLOCATION, icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
|
||||
{ href: AppRoute.WORKFLOWS, icon: mdiAutoFix, label: $t('workflow') },
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
142
web/src/lib/components/workflow/ActionBuilder.svelte
Normal file
142
web/src/lib/components/workflow/ActionBuilder.svelte
Normal file
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { type PluginResponseDto, PluginContext } from '@immich/sdk';
|
||||
import { Button, Field, Icon, IconButton } from '@immich/ui';
|
||||
import { mdiChevronDown, mdiChevronUp, mdiClose, mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SchemaFormFields from './schema-form/SchemaFormFields.svelte';
|
||||
|
||||
interface Props {
|
||||
actions: Array<{ actionId: string; actionConfig?: object }>;
|
||||
triggerType: 'AssetCreate' | 'PersonRecognized';
|
||||
plugins: PluginResponseDto[];
|
||||
}
|
||||
|
||||
let { actions = $bindable([]), triggerType, plugins }: Props = $props();
|
||||
|
||||
// Map trigger type to context
|
||||
const getTriggerContext = (trigger: string): PluginContext => {
|
||||
const contextMap: Record<string, PluginContext> = {
|
||||
AssetCreate: PluginContext.Asset,
|
||||
PersonRecognized: PluginContext.Person,
|
||||
};
|
||||
return contextMap[trigger] || PluginContext.Asset;
|
||||
};
|
||||
|
||||
const triggerContext = $derived(getTriggerContext(triggerType));
|
||||
|
||||
// Get all available actions that match the trigger context
|
||||
const availableActions = $derived(
|
||||
plugins.flatMap((plugin) => plugin.actions.filter((action) => action.supportedContexts.includes(triggerContext))),
|
||||
);
|
||||
|
||||
const addAction = () => {
|
||||
if (availableActions.length > 0) {
|
||||
actions = [...actions, { actionId: availableActions[0].id, actionConfig: {} }];
|
||||
}
|
||||
};
|
||||
|
||||
const removeAction = (index: number) => {
|
||||
actions = actions.filter((_, i) => i !== index);
|
||||
};
|
||||
|
||||
const moveUp = (index: number) => {
|
||||
if (index > 0) {
|
||||
const newActions = [...actions];
|
||||
[newActions[index - 1], newActions[index]] = [newActions[index], newActions[index - 1]];
|
||||
actions = newActions;
|
||||
}
|
||||
};
|
||||
|
||||
const moveDown = (index: number) => {
|
||||
if (index < actions.length - 1) {
|
||||
const newActions = [...actions];
|
||||
[newActions[index], newActions[index + 1]] = [newActions[index + 1], newActions[index]];
|
||||
actions = newActions;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionById = (actionId: string) => {
|
||||
for (const plugin of plugins) {
|
||||
const action = plugin.actions.find((a) => a.id === actionId);
|
||||
if (action) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if actions.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{$t('no_actions_added')}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each actions as action, index (index)}
|
||||
{@const actionDef = getActionById(action.actionId)}
|
||||
<div class="rounded-lg border border-gray-300 p-3 dark:border-gray-700">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<Field label={$t('action')}>
|
||||
<select
|
||||
bind:value={action.actionId}
|
||||
class="immich-form-input w-full"
|
||||
onchange={() => {
|
||||
action.actionConfig = {};
|
||||
}}
|
||||
>
|
||||
{#each availableActions as availAction (availAction.id)}
|
||||
<option value={availAction.id}>{availAction.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="ml-2 flex gap-1">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiChevronUp}
|
||||
aria-label={$t('move_up')}
|
||||
onclick={() => moveUp(index)}
|
||||
disabled={index === 0}
|
||||
size="small"
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiChevronDown}
|
||||
aria-label={$t('move_down')}
|
||||
onclick={() => moveDown(index)}
|
||||
disabled={index === actions.length - 1}
|
||||
size="small"
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiClose}
|
||||
aria-label={$t('remove')}
|
||||
onclick={() => removeAction(index)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if actionDef}
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 mb-2">
|
||||
{actionDef.description}
|
||||
</div>
|
||||
{#if actionDef.schema}
|
||||
<SchemaFormFields schema={actionDef.schema} bind:config={action.actionConfig} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button shape="round" size="small" onclick={addAction} disabled={availableActions.length === 0} class="mt-2">
|
||||
<Icon icon={mdiPlus} size="18" />
|
||||
{$t('add_action')}
|
||||
</Button>
|
||||
142
web/src/lib/components/workflow/FilterBuilder.svelte
Normal file
142
web/src/lib/components/workflow/FilterBuilder.svelte
Normal file
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { type PluginResponseDto, PluginContext } from '@immich/sdk';
|
||||
import { Button, Field, Icon, IconButton } from '@immich/ui';
|
||||
import { mdiChevronDown, mdiChevronUp, mdiClose, mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SchemaFormFields from './schema-form/SchemaFormFields.svelte';
|
||||
|
||||
interface Props {
|
||||
filters: Array<{ filterId: string; filterConfig?: object }>;
|
||||
triggerType: 'AssetCreate' | 'PersonRecognized';
|
||||
plugins: PluginResponseDto[];
|
||||
}
|
||||
|
||||
let { filters = $bindable([]), triggerType, plugins }: Props = $props();
|
||||
|
||||
// Map trigger type to context
|
||||
const getTriggerContext = (trigger: string): PluginContext => {
|
||||
const contextMap: Record<string, PluginContext> = {
|
||||
AssetCreate: PluginContext.Asset,
|
||||
PersonRecognized: PluginContext.Person,
|
||||
};
|
||||
return contextMap[trigger] || PluginContext.Asset;
|
||||
};
|
||||
|
||||
const triggerContext = $derived(getTriggerContext(triggerType));
|
||||
|
||||
// Get all available filters that match the trigger context
|
||||
const availableFilters = $derived(
|
||||
plugins.flatMap((plugin) => plugin.filters.filter((filter) => filter.supportedContexts.includes(triggerContext))),
|
||||
);
|
||||
|
||||
const addFilter = () => {
|
||||
if (availableFilters.length > 0) {
|
||||
filters = [...filters, { filterId: availableFilters[0].id, filterConfig: {} }];
|
||||
}
|
||||
};
|
||||
|
||||
const removeFilter = (index: number) => {
|
||||
filters = filters.filter((_, i) => i !== index);
|
||||
};
|
||||
|
||||
const moveUp = (index: number) => {
|
||||
if (index > 0) {
|
||||
const newFilters = [...filters];
|
||||
[newFilters[index - 1], newFilters[index]] = [newFilters[index], newFilters[index - 1]];
|
||||
filters = newFilters;
|
||||
}
|
||||
};
|
||||
|
||||
const moveDown = (index: number) => {
|
||||
if (index < filters.length - 1) {
|
||||
const newFilters = [...filters];
|
||||
[newFilters[index], newFilters[index + 1]] = [newFilters[index + 1], newFilters[index]];
|
||||
filters = newFilters;
|
||||
}
|
||||
};
|
||||
|
||||
const getFilterById = (filterId: string) => {
|
||||
for (const plugin of plugins) {
|
||||
const filter = plugin.filters.find((f) => f.id === filterId);
|
||||
if (filter) {
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if filters.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{$t('no_filters_added')}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each filters as filter, index (index)}
|
||||
{@const filterDef = getFilterById(filter.filterId)}
|
||||
<div class="rounded-lg border border-gray-300 p-3 dark:border-gray-700">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<Field label={$t('filter')}>
|
||||
<select
|
||||
bind:value={filter.filterId}
|
||||
class="immich-form-input w-full"
|
||||
onchange={() => {
|
||||
filter.filterConfig = {};
|
||||
}}
|
||||
>
|
||||
{#each availableFilters as availFilter (availFilter.id)}
|
||||
<option value={availFilter.id}>{availFilter.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="ml-2 flex gap-1">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiChevronUp}
|
||||
aria-label={$t('move_up')}
|
||||
onclick={() => moveUp(index)}
|
||||
disabled={index === 0}
|
||||
size="small"
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiChevronDown}
|
||||
aria-label={$t('move_down')}
|
||||
onclick={() => moveDown(index)}
|
||||
disabled={index === filters.length - 1}
|
||||
size="small"
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiClose}
|
||||
aria-label={$t('remove')}
|
||||
onclick={() => removeFilter(index)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filterDef}
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 mb-2">
|
||||
{filterDef.description}
|
||||
</div>
|
||||
{#if filterDef.schema}
|
||||
<SchemaFormFields schema={filterDef.schema} bind:config={filter.filterConfig} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button shape="round" size="small" onclick={addFilter} disabled={availableFilters.length === 0} class="mt-2">
|
||||
<Icon icon={mdiPlus} size="18" />
|
||||
{$t('add_filter')}
|
||||
</Button>
|
||||
@@ -0,0 +1,326 @@
|
||||
<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 } from '$lib/utils/workflow';
|
||||
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Field, Input, MultiSelect, Select, Switch, modalManager, type SelectItem } from '@immich/ui';
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
schema: object | null;
|
||||
config: Record<string, unknown>;
|
||||
configKey?: string;
|
||||
}
|
||||
|
||||
let { schema = null, config = $bindable({}), configKey }: Props = $props();
|
||||
|
||||
const components = $derived(getComponentFromSchema(schema));
|
||||
|
||||
// Get the actual config object to work with
|
||||
const actualConfig = $derived(configKey ? (config[configKey] as Record<string, unknown>) || {} : config);
|
||||
|
||||
// Update function that handles nested config
|
||||
const updateConfig = (key: string, value: unknown) => {
|
||||
config = configKey ? { ...config, [configKey]: { ...actualConfig, [key]: value } } : { ...config, [key]: value };
|
||||
};
|
||||
|
||||
const updateConfigBatch = (updates: Record<string, unknown>) => {
|
||||
config = configKey ? { ...config, [configKey]: { ...actualConfig, ...updates } } : { ...config, ...updates };
|
||||
};
|
||||
|
||||
let selectValue = $state<SelectItem>();
|
||||
let switchValue = $state<boolean>(false);
|
||||
let multiSelectValue = $state<SelectItem[]>([]);
|
||||
let pickerMetadata = $state<
|
||||
Record<string, AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]>
|
||||
>({});
|
||||
|
||||
$effect(() => {
|
||||
// Initialize config for actions/filters with empty schemas
|
||||
if (configKey && !config[configKey]) {
|
||||
config = { ...config, [configKey]: {} };
|
||||
}
|
||||
|
||||
if (components) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, component] of Object.entries(components)) {
|
||||
// Only initialize if the key doesn't exist in config yet
|
||||
if (actualConfig[key] === undefined) {
|
||||
// Use default value if available, otherwise use appropriate empty value based on type
|
||||
const hasDefault = component.defaultValue !== undefined;
|
||||
|
||||
if (hasDefault) {
|
||||
updates[key] = component.defaultValue;
|
||||
} else {
|
||||
// Initialize with appropriate empty value based on component type
|
||||
if (
|
||||
component.type === 'multiselect' ||
|
||||
(component.type === 'text' && component.subType === 'people-picker')
|
||||
) {
|
||||
updates[key] = [];
|
||||
} else if (component.type === 'switch') {
|
||||
updates[key] = false;
|
||||
} else {
|
||||
updates[key] = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI state for components with default values
|
||||
if (hasDefault) {
|
||||
if (component.type === 'select') {
|
||||
selectValue = {
|
||||
label: formatLabel(String(component.defaultValue)),
|
||||
value: String(component.defaultValue),
|
||||
};
|
||||
}
|
||||
|
||||
if (component.type === 'switch') {
|
||||
switchValue = Boolean(component.defaultValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updateConfigBatch(updates);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 people = await modalManager.show(PeoplePickerModal, { multiple });
|
||||
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 };
|
||||
};
|
||||
</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-gray-50 dark:bg-subtle border border-gray-200 dark:border-gray-700 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)}
|
||||
{:else}
|
||||
{@const options = component.options?.map((opt) => {
|
||||
return { label: opt.label, value: String(opt.value) };
|
||||
}) || [{ label: 'N/A', value: '' }]}
|
||||
|
||||
<Field
|
||||
{label}
|
||||
required={component.required}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
>
|
||||
<Select data={options} onChange={(opt) => updateConfig(key, opt.value)} bind:value={selectValue} />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<!-- MultiSelect component -->
|
||||
{:else if component.type === 'multiselect'}
|
||||
{#if component.subType === 'album-picker' || component.subType === 'people-picker'}
|
||||
{@render pickerField(component.subType, key, label, component, true)}
|
||||
{:else}
|
||||
{@const options = component.options?.map((opt) => {
|
||||
return { label: opt.label, value: String(opt.value) };
|
||||
}) || [{ label: 'N/A', value: '' }]}
|
||||
|
||||
<Field
|
||||
{label}
|
||||
required={component.required}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
>
|
||||
<MultiSelect
|
||||
data={options}
|
||||
onChange={(opt) =>
|
||||
updateConfig(
|
||||
key,
|
||||
opt.map((o) => o.value),
|
||||
)}
|
||||
bind:values={multiSelectValue}
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<!-- Switch component -->
|
||||
{:else if component.type === 'switch'}
|
||||
<Field
|
||||
{label}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
required={component.required}
|
||||
>
|
||||
<Switch bind:checked={switchValue} onCheckedChange={(check) => updateConfig(key, check)} />
|
||||
</Field>
|
||||
|
||||
<!-- Text input -->
|
||||
{:else if component.subType === 'album-picker' || component.subType === 'people-picker'}
|
||||
{@render pickerField(component.subType, key, label, component, false)}
|
||||
{:else}
|
||||
<Field
|
||||
{label}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
required={component.required}
|
||||
>
|
||||
<Input
|
||||
id={key}
|
||||
value={actualConfig[key] as string}
|
||||
oninput={(e) => updateConfig(key, e.currentTarget.value)}
|
||||
required={component.required}
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-500">No configuration required</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
let { animated = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center py-2">
|
||||
<div class="relative h-12 w-0.5">
|
||||
<div class="absolute inset-0 bg-linear-to-b from-primary/30 via-primary/50 to-primary/30"></div>
|
||||
{#if animated}
|
||||
<div class="absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent flow-pulse"></div>
|
||||
{/if}
|
||||
<!-- Connection nodes -->
|
||||
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
|
||||
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
|
||||
</div>
|
||||
<div class="absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2">
|
||||
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes flow {
|
||||
0% {
|
||||
transform: translateY(-25%);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(25%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.flow-pulse {
|
||||
animation: flow 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { PluginTriggerType, type PluginTriggerResponseDto } from '@immich/sdk';
|
||||
import { Icon, Text } from '@immich/ui';
|
||||
import { mdiFaceRecognition, mdiFileUploadOutline, mdiLightningBolt } from '@mdi/js';
|
||||
|
||||
interface Props {
|
||||
trigger: PluginTriggerResponseDto;
|
||||
selected: boolean;
|
||||
onclick: () => void;
|
||||
}
|
||||
|
||||
let { trigger, selected, onclick }: Props = $props();
|
||||
|
||||
const getTriggerIcon = (triggerType: PluginTriggerType) => {
|
||||
switch (triggerType) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
return mdiFileUploadOutline;
|
||||
}
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
return mdiFaceRecognition;
|
||||
}
|
||||
default: {
|
||||
return mdiLightningBolt;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
{onclick}
|
||||
class="rounded-xl p-4 w-full text-left transition-all cursor-pointer border-2 {selected
|
||||
? 'border-primary text-primary'
|
||||
: 'border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-200'}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="rounded-xl p-2 bg-gray-200 {selected
|
||||
? 'bg-primary text-light'
|
||||
: 'text-gray-400 dark:text-gray-400 dark:bg-gray-900'}"
|
||||
>
|
||||
<Icon icon={getTriggerIcon(trigger.triggerType)} size="24" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<Text class="font-semibold mb-1">{trigger.name}</Text>
|
||||
{#if trigger.description}
|
||||
<Text class="text-sm text-muted-foreground">{trigger.description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
Reference in New Issue
Block a user