This commit is contained in:
Alex Tran
2025-11-18 21:50:04 +00:00
parent 272ad7c773
commit 7eecfc43df
4 changed files with 327 additions and 25 deletions

View File

@@ -0,0 +1,89 @@
export interface DragAndDropOptions {
index: number;
onDragStart?: (index: number) => void;
onDragEnter?: (index: number) => void;
onDrop?: (e: DragEvent, index: number) => void;
onDragEnd?: () => void;
isDragging?: boolean;
isDragOver?: boolean;
}
export function dragAndDrop(node: HTMLElement, options: DragAndDropOptions) {
let { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
const handleDragStart = () => {
onDragStart?.(index);
};
const handleDragEnter = () => {
onDragEnter?.(index);
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: DragEvent) => {
onDrop?.(e, index);
};
const handleDragEnd = () => {
onDragEnd?.();
};
node.setAttribute('draggable', 'true');
node.setAttribute('role', 'button');
node.setAttribute('tabindex', '0');
node.addEventListener('dragstart', handleDragStart);
node.addEventListener('dragenter', handleDragEnter);
node.addEventListener('dragover', handleDragOver);
node.addEventListener('drop', handleDrop);
node.addEventListener('dragend', handleDragEnd);
// Update classes based on drag state
const updateClasses = (dragging: boolean, dragOver: boolean) => {
// Remove all drag-related classes first
node.classList.remove('opacity-50', 'border-gray-400', 'dark:border-gray-500', 'border-solid');
// Add back only the active ones
if (dragging) {
node.classList.add('opacity-50');
}
if (dragOver) {
node.classList.add('border-gray-400', 'dark:border-gray-500', 'border-solid');
node.classList.remove('border-transparent');
} else {
node.classList.add('border-transparent');
}
};
updateClasses(isDragging || false, isDragOver || false);
return {
update(newOptions: DragAndDropOptions) {
index = newOptions.index;
onDragStart = newOptions.onDragStart;
onDragEnter = newOptions.onDragEnter;
onDrop = newOptions.onDrop;
onDragEnd = newOptions.onDragEnd;
const newIsDragging = newOptions.isDragging || false;
const newIsDragOver = newOptions.isDragOver || false;
if (newIsDragging !== isDragging || newIsDragOver !== isDragOver) {
isDragging = newIsDragging;
isDragOver = newIsDragOver;
updateClasses(isDragging, isDragOver);
}
},
destroy() {
node.removeEventListener('dragstart', handleDragStart);
node.removeEventListener('dragenter', handleDragEnter);
node.removeEventListener('dragover', handleDragOver);
node.removeEventListener('drop', handleDrop);
node.removeEventListener('dragend', handleDragEnd);
},
};
}

View File

@@ -241,7 +241,7 @@
{@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"
class="flex flex-col gap-1 bg-gray-100 dark:bg-subtle border border-gray-200 dark:border-gray-700 p-4 rounded-xl"
>
<!-- Select component -->
{#if component.type === 'select'}

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import type { PluginActionResponseDto, PluginFilterResponseDto, PluginTriggerResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiChevronRight, mdiFilterOutline, mdiFlashOutline, mdiPlayCircleOutline } from '@mdi/js';
interface Props {
trigger: PluginTriggerResponseDto;
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
}
let { trigger, filters, actions }: Props = $props();
</script>
<div class="fixed right-20 top-1/2 -translate-y-1/2 z-10 w-64">
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg p-4">
<h3 class="text-sm font-semibold mb-4 text-gray-700 dark:text-gray-300">Workflow Summary</h3>
<div class="space-y-3">
<!-- Trigger -->
<div class="flex items-start gap-2">
<div class="shrink-0 mt-0.5">
<div class="h-6 w-6 rounded-md bg-indigo-100 dark:bg-primary/20 flex items-center justify-center">
<Icon icon={mdiFlashOutline} size="16" class="text-primary" />
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{trigger.name}</p>
</div>
</div>
<!-- Arrow -->
{#if filters.length > 0}
<div class="flex justify-center">
<Icon icon={mdiChevronRight} size="20" class="text-gray-400 rotate-90" />
</div>
<!-- Filters -->
<div class="flex items-start gap-2">
<div class="shrink-0 mt-0.5">
<div class="h-6 w-6 rounded-md bg-amber-100 dark:bg-amber-950 flex items-center justify-center">
<Icon icon={mdiFilterOutline} size="16" class="text-warning" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="space-y-1">
{#each filters as filter, index (filter.id)}
<p class="text-xs text-gray-700 dark:text-gray-300 truncate">
{index + 1}. {filter.title}
</p>
{/each}
</div>
</div>
</div>
{/if}
<!-- Arrow -->
{#if actions.length > 0}
<div class="flex justify-center">
<Icon icon={mdiChevronRight} size="20" class="text-gray-400 rotate-90" />
</div>
<!-- Actions -->
<div class="flex items-start gap-2">
<div class="shrink-0 mt-0.5">
<div class="h-6 w-6 rounded-md bg-teal-100 dark:bg-teal-950 flex items-center justify-center">
<Icon icon={mdiPlayCircleOutline} size="16" class="text-success" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="space-y-1">
{#each actions as action, index (action.id)}
<p class="text-xs text-gray-700 dark:text-gray-300 truncate">
{index + 1}. {action.title}
</p>
{/each}
</div>
</div>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -1,9 +1,11 @@
<script lang="ts">
import { dragAndDrop } from '$lib/actions/drag-and-drop';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import SchemaFormFields from '$lib/components/workflow/schema-form/SchemaFormFields.svelte';
import WorkflowCardConnector from '$lib/components/workflows/workflow-card-connector.svelte';
import WorkflowSummarySidebar from '$lib/components/workflows/workflow-summary-sidebar.svelte';
import WorkflowTriggerCard from '$lib/components/workflows/workflow-trigger-card.svelte';
import type { PluginResponseDto } from '@immich/sdk';
import type { PluginActionResponseDto, PluginFilterResponseDto } from '@immich/sdk';
import {
Button,
Card,
@@ -11,7 +13,6 @@
CardDescription,
CardHeader,
CardTitle,
CodeBlock,
Container,
Field,
HStack,
@@ -24,6 +25,7 @@
} from '@immich/ui';
import {
mdiContentSave,
mdiDragVertical,
mdiFilterOutline,
mdiFlashOutline,
mdiInformationOutline,
@@ -38,10 +40,9 @@
let { data }: Props = $props();
let plugins = $state<PluginResponseDto[]>(data.plugins);
let triggers = data.triggers;
let filters = plugins.flatMap((plugin) => plugin.filters);
let action = plugins.flatMap((plugin) => plugin.actions);
const triggers = data.triggers;
const filters = data.plugins.flatMap((plugin) => plugin.filters);
const action = data.plugins.flatMap((plugin) => plugin.actions);
let previousWorkflow = data.workflow;
let editWorkflow = $state(data.workflow);
@@ -54,6 +55,10 @@
let supportFilters = $derived(filters.filter((filter) => filter.supportedContexts.includes(selectedTrigger.context)));
let supportActions = $derived(action.filter((action) => action.supportedContexts.includes(selectedTrigger.context)));
let orderedFilters: PluginFilterResponseDto[] = $derived(supportFilters);
let orderedActions: PluginActionResponseDto[] = $derived(supportActions);
$effect(() => {
editWorkflow.triggerType = triggerType;
});
@@ -62,13 +67,73 @@
let canSave: boolean = $derived(!isEqual(previousWorkflow, editWorkflow));
let filterConfigs = $state({});
let actionConfigs = $state({});
let filterConfigs: Record<string, unknown> = $state({});
let actionConfigs: Record<string, unknown> = $state({});
$inspect(filterConfigs).with(console.log);
// Drag and drop handlers
let draggedFilterIndex: number | null = $state(null);
let draggedActionIndex: number | null = $state(null);
let dragOverFilterIndex: number | null = $state(null);
let dragOverActionIndex: number | null = $state(null);
const handleFilterDragStart = (index: number) => {
draggedFilterIndex = index;
};
const handleFilterDragEnter = (index: number) => {
if (draggedFilterIndex !== null && draggedFilterIndex !== index) {
dragOverFilterIndex = index;
}
};
const handleFilterDrop = (e: DragEvent, index: number) => {
e.preventDefault();
if (draggedFilterIndex === null || draggedFilterIndex === index) {
return;
}
const newFilters = [...orderedFilters];
const [draggedItem] = newFilters.splice(draggedFilterIndex, 1);
newFilters.splice(index, 0, draggedItem);
orderedFilters = newFilters;
};
const handleFilterDragEnd = () => {
draggedFilterIndex = null;
dragOverFilterIndex = null;
};
const handleActionDragStart = (index: number) => {
draggedActionIndex = index;
};
const handleActionDragEnter = (index: number) => {
if (draggedActionIndex !== null && draggedActionIndex !== index) {
dragOverActionIndex = index;
}
};
const handleActionDrop = (e: DragEvent, index: number) => {
e.preventDefault();
if (draggedActionIndex === null || draggedActionIndex === index) {
return;
}
const newActions = [...orderedActions];
const [draggedItem] = newActions.splice(draggedActionIndex, 1);
newActions.splice(index, 0, draggedItem);
orderedActions = newActions;
};
const handleActionDragEnd = () => {
draggedActionIndex = null;
dragOverActionIndex = null;
};
</script>
<UserPageLayout title={data.meta.title} scrollbar={false}>
<WorkflowSummarySidebar trigger={selectedTrigger} filters={orderedFilters} actions={orderedActions} />
{#snippet buttons()}
<HStack gap={4} class="me-4">
<HStack gap={2}>
@@ -141,7 +206,7 @@
<WorkflowCardConnector />
<Card expandable expanded={true}>
<Card expandable expanded={false}>
<CardHeader class="bg-amber-50 dark:bg-[#5e4100]">
<div class="flex items-start gap-3">
<Icon icon={mdiFilterOutline} size="20" class="mt-1 text-warning" />
@@ -153,15 +218,47 @@
</CardHeader>
<CardBody>
<div class="my-4">
<!-- <div class="my-4">
<p>Payload</p>
<CodeBlock code={JSON.stringify(filterConfigs, null, 2)} lineNumbers></CodeBlock>
<CodeBlock code={JSON.stringify(orderedFilterPayload, null, 2)} lineNumbers></CodeBlock>
</div> -->
{#each orderedFilters as filter, index (filter.id)}
{#if index > 0}
<div class="relative flex justify-center py-4">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t-2 border-dashed border-muted"></div>
</div>
{#each supportFilters as filter (filter.id)}
<h1 class="grid grid-cols-2 gap-4 font-bold mt-5 mb-2">{filter.title}</h1>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-white dark:bg-gray-900 px-2 font-semibold">THEN</span>
</div>
</div>
{/if}
<div
use:dragAndDrop={{
index,
onDragStart: handleFilterDragStart,
onDragEnter: handleFilterDragEnter,
onDrop: handleFilterDrop,
onDragEnd: handleFilterDragEnd,
isDragging: draggedFilterIndex === index,
isDragOver: dragOverFilterIndex === index,
}}
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-gray-50 dark:bg-gray-950/20 border-dashed border-transparent hover:border-gray-300 dark:hover:border-gray-600"
>
<div class="flex items-start gap-4">
<div class="h-8 w-8 rounded-lg borderflex place-items-center place-content-center shrink-0 border">
<p class="font-mono text-sm font-bold">
{index + 1}
</p>
</div>
<div class="flex-1">
<h1 class="font-bold text-lg mb-3">{filter.title}</h1>
<SchemaFormFields schema={filter.schema} bind:config={filterConfigs} configKey={filter.methodName} />
</div>
<Icon icon={mdiDragVertical} class="mt-1 text-primary shrink-0" />
</div>
</div>
{/each}
</CardBody>
</Card>
@@ -169,7 +266,7 @@
<WorkflowCardConnector />
<Card expandable>
<CardHeader class="bg-teal-50 dark:bg-teal-950">
<CardHeader class="bg-success/10 dark:bg-teal-950">
<div class="flex items-start gap-3">
<Icon icon={mdiPlayCircleOutline} size="20" class="mt-1 text-success" />
<div class="flex flex-col">
@@ -180,14 +277,47 @@
</CardHeader>
<CardBody>
<div class="my-4">
<!-- <div class="my-4">
<p>Payload</p>
<CodeBlock code={JSON.stringify(actionConfigs, null, 2)} lineNumbers></CodeBlock>
</div>
<CodeBlock code={JSON.stringify(orderedActionPayload, null, 2)} lineNumbers></CodeBlock>
</div> -->
{#each supportActions as action (action.id)}
<h1 class="grid grid-cols-2 gap-4 font-bold">{action.title}</h1>
{#each orderedActions as action, index (action.id)}
{#if index > 0}
<div class="relative flex justify-center py-4">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t-2 border-dashed border-muted"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-white dark:bg-gray-900 px-2 font-semibold">THEN</span>
</div>
</div>
{/if}
<div
use:dragAndDrop={{
index,
onDragStart: handleActionDragStart,
onDragEnter: handleActionDragEnter,
onDrop: handleActionDrop,
onDragEnd: handleActionDragEnd,
isDragging: draggedActionIndex === index,
isDragOver: dragOverActionIndex === index,
}}
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-gray-50 dark:bg-gray-950/20 border-dashed border-transparent hover:border-gray-300 dark:hover:border-gray-600"
>
<div class="flex items-start gap-4">
<div class="h-8 w-8 rounded-lg borderflex place-items-center place-content-center shrink-0 border">
<p class="font-mono text-sm font-bold">
{index + 1}
</p>
</div>
<div class="flex-1">
<h1 class="font-bold text-lg mb-3">{action.title}</h1>
<SchemaFormFields schema={action.schema} bind:config={actionConfigs} configKey={action.methodName} />
</div>
<Icon icon={mdiDragVertical} class="mt-1 text-primary shrink-0" />
</div>
</div>
{/each}
</CardBody>
</Card>