mirror of
https://github.com/immich-app/immich.git
synced 2025-12-05 20:40:29 -08:00
Compare commits
5 Commits
1c64d21148
...
77578947f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77578947f8 | ||
|
|
76ec9e3ebf | ||
|
|
1e238e7a48 | ||
|
|
63e38f347e | ||
|
|
6222c4e97f |
105
web/src/lib/attachments/drag-and-drop.svelte.ts
Normal file
105
web/src/lib/attachments/drag-and-drop.svelte.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
|
||||
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(options: DragAndDropOptions): Attachment {
|
||||
return (node: Element) => {
|
||||
const element = node as HTMLElement;
|
||||
const { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
|
||||
|
||||
const isFormElement = (el: HTMLElement) => {
|
||||
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT';
|
||||
};
|
||||
|
||||
const handleDragStart = (e: DragEvent) => {
|
||||
// Prevent drag if it originated from an input, textarea, or select element
|
||||
const target = e.target as HTMLElement;
|
||||
if (isFormElement(target)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onDragStart?.(index);
|
||||
};
|
||||
|
||||
const handleDragEnter = () => {
|
||||
onDragEnter?.(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
onDrop?.(e, index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
onDragEnd?.();
|
||||
};
|
||||
|
||||
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (isFormElement(target)) {
|
||||
element.setAttribute('draggable', 'false');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocusOut = (e: FocusEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (isFormElement(target)) {
|
||||
element.setAttribute('draggable', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
// Update classes based on drag state
|
||||
const updateClasses = (dragging: boolean, dragOver: boolean) => {
|
||||
// Remove all drag-related classes first
|
||||
element.classList.remove('opacity-50', 'border-light-500', 'border-solid');
|
||||
|
||||
// Add back only the active ones
|
||||
if (dragging) {
|
||||
element.classList.add('opacity-50');
|
||||
}
|
||||
|
||||
if (dragOver) {
|
||||
element.classList.add('border-light-500', 'border-solid');
|
||||
element.classList.remove('border-transparent');
|
||||
} else {
|
||||
element.classList.add('border-transparent');
|
||||
}
|
||||
};
|
||||
|
||||
element.setAttribute('draggable', 'true');
|
||||
element.setAttribute('role', 'button');
|
||||
element.setAttribute('tabindex', '0');
|
||||
|
||||
element.addEventListener('dragstart', handleDragStart);
|
||||
element.addEventListener('dragenter', handleDragEnter);
|
||||
element.addEventListener('dragover', handleDragOver);
|
||||
element.addEventListener('drop', handleDrop);
|
||||
element.addEventListener('dragend', handleDragEnd);
|
||||
element.addEventListener('focusin', handleFocusIn);
|
||||
element.addEventListener('focusout', handleFocusOut);
|
||||
|
||||
updateClasses(isDragging || false, isDragOver || false);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('dragstart', handleDragStart);
|
||||
element.removeEventListener('dragenter', handleDragEnter);
|
||||
element.removeEventListener('dragover', handleDragOver);
|
||||
element.removeEventListener('drop', handleDrop);
|
||||
element.removeEventListener('dragend', handleDragEnd);
|
||||
element.removeEventListener('focusin', handleFocusIn);
|
||||
element.removeEventListener('focusout', handleFocusOut);
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
import { Field, Input, MultiSelect, Select, Switch, Text, type SelectItem } from '@immich/ui';
|
||||
import WorkflowPickerField from './WorkflowPickerField.svelte';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
schema: object | null;
|
||||
config: Record<string, unknown>;
|
||||
configKey?: string;
|
||||
}
|
||||
};
|
||||
|
||||
let { schema = null, config = $bindable({}), configKey }: Props = $props();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
type Props = {
|
||||
animated?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
let { animated = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
import { mdiCodeJson } from '@mdi/js';
|
||||
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
jsonContent: WorkflowPayload;
|
||||
onApply: () => void;
|
||||
onContentChange: (content: WorkflowPayload) => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { jsonContent, onApply, onContentChange }: Props = $props();
|
||||
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<script lang="ts">
|
||||
import WorkflowPickerItemCard from '$lib/components/workflows/WorkflowPickerItemCard.svelte';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
|
||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { fetchPickerMetadata, type PickerMetadata } from '$lib/services/workflow.service';
|
||||
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 type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Field, modalManager } from '@immich/ui';
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
component: ComponentConfig;
|
||||
configKey: string;
|
||||
value: string | string[] | undefined;
|
||||
onchange: (value: string | string[]) => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { component, configKey, value = $bindable(), onchange }: Props = $props();
|
||||
|
||||
@@ -22,7 +23,7 @@
|
||||
const isAlbum = $derived(subType === 'album-picker');
|
||||
const multiple = $derived(component.type === 'multiselect' || Array.isArray(value));
|
||||
|
||||
let pickerMetadata = $state<AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]>();
|
||||
let pickerMetadata = $state<PickerMetadata | undefined>();
|
||||
|
||||
$effect(() => {
|
||||
if (!value) {
|
||||
@@ -30,34 +31,20 @@
|
||||
return;
|
||||
}
|
||||
|
||||
void fetchMetadata();
|
||||
if (!pickerMetadata) {
|
||||
void loadMetadata();
|
||||
}
|
||||
});
|
||||
|
||||
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 loadMetadata = async () => {
|
||||
pickerMetadata = await fetchPickerMetadata(value, subType);
|
||||
};
|
||||
|
||||
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;
|
||||
const newValue = multiple ? albums.map((album) => album.id) : albums[0].id;
|
||||
onchange(newValue);
|
||||
pickerMetadata = multiple ? albums : albums[0];
|
||||
}
|
||||
@@ -66,7 +53,7 @@
|
||||
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;
|
||||
const newValue = multiple ? people.map((person) => person.id) : people[0].id;
|
||||
onchange(newValue);
|
||||
pickerMetadata = multiple ? people : people[0];
|
||||
}
|
||||
@@ -99,58 +86,14 @@
|
||||
};
|
||||
</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)}
|
||||
<WorkflowPickerItemCard item={pickerMetadata} {isAlbum} onRemove={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))}
|
||||
<WorkflowPickerItemCard {item} {isAlbum} onRemove={() => removeItemFromSelection(item.id)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { Card, CardBody, IconButton, Text } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
item: AlbumResponseDto | PersonResponseDto;
|
||||
isAlbum: boolean;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
let { item, isAlbum, onRemove }: Props = $props();
|
||||
</script>
|
||||
|
||||
<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>
|
||||
@@ -9,11 +9,11 @@
|
||||
import { mdiClose, mdiFilterOutline, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
trigger: PluginTriggerResponseDto;
|
||||
filters: PluginFilterResponseDto[];
|
||||
actions: PluginActionResponseDto[];
|
||||
}
|
||||
};
|
||||
|
||||
let { trigger, filters, actions }: Props = $props();
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
import { mdiFaceRecognition, mdiFileUploadOutline, mdiLightningBolt } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
trigger: PluginTriggerResponseDto;
|
||||
selected: boolean;
|
||||
onclick: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { trigger, selected, onclick }: Props = $props();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
SharedLinkResponseDto,
|
||||
SystemConfigDto,
|
||||
UserAdminResponseDto,
|
||||
WorkflowResponseDto,
|
||||
} from '@immich/sdk';
|
||||
|
||||
export type Events = {
|
||||
@@ -42,6 +43,9 @@ export type Events = {
|
||||
LibraryUpdate: [LibraryResponseDto];
|
||||
LibraryDelete: [{ id: string }];
|
||||
|
||||
WorkflowUpdate: [WorkflowResponseDto];
|
||||
WorkflowDelete: [WorkflowResponseDto];
|
||||
|
||||
ReleaseEvent: [ReleaseEvent];
|
||||
};
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
import { mdiFilterOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
filters: PluginFilterResponseDto[];
|
||||
actions: PluginActionResponseDto[];
|
||||
onClose: (result?: { type: 'filter' | 'action'; item: PluginFilterResponseDto | PluginActionResponseDto }) => void;
|
||||
type?: 'filter' | 'action';
|
||||
}
|
||||
};
|
||||
|
||||
let { filters, actions, onClose, type }: Props = $props();
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
multiple?: boolean;
|
||||
excludedIds?: string[];
|
||||
onClose: (people?: PersonResponseDto[]) => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { multiple = false, excludedIds = [], onClose }: Props = $props();
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import {
|
||||
createWorkflow,
|
||||
deleteWorkflow,
|
||||
getAlbumInfo,
|
||||
getPerson,
|
||||
PluginTriggerType,
|
||||
updateWorkflow as updateWorkflowApi,
|
||||
updateWorkflow,
|
||||
type AlbumResponseDto,
|
||||
type PersonResponseDto,
|
||||
type PluginActionResponseDto,
|
||||
type PluginContextType,
|
||||
type PluginFilterResponseDto,
|
||||
@@ -10,6 +21,12 @@ import {
|
||||
type WorkflowResponseDto,
|
||||
type WorkflowUpdateDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay } from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export type PickerSubType = 'album-picker' | 'people-picker';
|
||||
export type PickerMetadata = AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[];
|
||||
|
||||
export interface WorkflowPayload {
|
||||
name: string;
|
||||
@@ -295,5 +312,137 @@ export const handleUpdateWorkflow = async (
|
||||
triggerType,
|
||||
};
|
||||
|
||||
return updateWorkflowApi({ id: workflowId, workflowUpdateDto: updateDto });
|
||||
return updateWorkflow({ id: workflowId, workflowUpdateDto: updateDto });
|
||||
};
|
||||
|
||||
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
|
||||
const ToggleEnabled: ActionItem = {
|
||||
title: workflow.enabled ? $t('disable') : $t('enable'),
|
||||
icon: workflow.enabled ? mdiPause : mdiPlay,
|
||||
color: workflow.enabled ? 'danger' : 'primary',
|
||||
onAction: async () => {
|
||||
await handleToggleWorkflowEnabled(workflow);
|
||||
},
|
||||
};
|
||||
|
||||
const Edit: ActionItem = {
|
||||
title: $t('edit'),
|
||||
icon: mdiPencil,
|
||||
onAction: () => handleNavigateToWorkflow(workflow),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
title: $t('delete'),
|
||||
icon: mdiDelete,
|
||||
color: 'danger',
|
||||
onAction: async () => {
|
||||
await handleDeleteWorkflow(workflow);
|
||||
},
|
||||
};
|
||||
|
||||
return { ToggleEnabled, Edit, Delete };
|
||||
};
|
||||
|
||||
export const getWorkflowShowSchemaAction = (
|
||||
$t: MessageFormatter,
|
||||
isExpanded: boolean,
|
||||
onToggle: () => void,
|
||||
): ActionItem => ({
|
||||
title: isExpanded ? $t('hide_schema') : $t('show_schema'),
|
||||
icon: mdiCodeJson,
|
||||
onAction: onToggle,
|
||||
});
|
||||
|
||||
export const handleCreateWorkflow = async (): Promise<WorkflowResponseDto | undefined> => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const workflow = await createWorkflow({
|
||||
workflowCreateDto: {
|
||||
name: $t('untitled_workflow'),
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
filters: [],
|
||||
actions: [],
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
|
||||
return workflow;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_create'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleToggleWorkflowEnabled = async (
|
||||
workflow: WorkflowResponseDto,
|
||||
): Promise<WorkflowResponseDto | undefined> => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const updated = await updateWorkflow({
|
||||
id: workflow.id,
|
||||
workflowUpdateDto: { enabled: !workflow.enabled },
|
||||
});
|
||||
|
||||
eventManager.emit('WorkflowUpdate', updated);
|
||||
toastManager.success($t('workflow_updated'));
|
||||
return updated;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_workflow'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promise<boolean> => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
const confirmed = await modalManager.showDialog({
|
||||
prompt: $t('workflow_delete_prompt'),
|
||||
confirmColor: 'danger',
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteWorkflow({ id: workflow.id });
|
||||
eventManager.emit('WorkflowDelete', workflow);
|
||||
toastManager.success($t('workflow_deleted'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_workflow'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const handleNavigateToWorkflow = async (workflow: WorkflowResponseDto): Promise<void> => {
|
||||
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
|
||||
};
|
||||
|
||||
export const fetchPickerMetadata = async (
|
||||
value: string | string[] | undefined,
|
||||
subType: PickerSubType,
|
||||
): Promise<PickerMetadata | undefined> => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isAlbum = subType === 'album-picker';
|
||||
|
||||
try {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
// Multiple selection
|
||||
return isAlbum
|
||||
? await Promise.all(value.map((id) => getAlbumInfo({ id })))
|
||||
: await Promise.all(value.map((id) => getPerson({ id })));
|
||||
} else if (typeof value === 'string' && value) {
|
||||
// Single selection
|
||||
return isAlbum ? await getAlbumInfo({ id: value }) : await getPerson({ id: value });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch picker metadata:`, error);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import emptyWorkflows from '$lib/assets/empty-workflows.svg';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import type { WorkflowPayload } from '$lib/services/workflow.service';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
createWorkflow,
|
||||
deleteWorkflow,
|
||||
PluginTriggerType,
|
||||
updateWorkflow,
|
||||
type PluginFilterResponseDto,
|
||||
type WorkflowResponseDto,
|
||||
} from '@immich/sdk';
|
||||
getWorkflowActions,
|
||||
getWorkflowShowSchemaAction,
|
||||
handleCreateWorkflow,
|
||||
type WorkflowPayload,
|
||||
} from '$lib/services/workflow.service';
|
||||
import type { PluginFilterResponseDto, WorkflowResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -27,23 +23,22 @@
|
||||
IconButton,
|
||||
MenuItemType,
|
||||
menuManager,
|
||||
modalManager,
|
||||
Text,
|
||||
toastManager,
|
||||
VStack,
|
||||
} from '@immich/ui';
|
||||
import { mdiClose, mdiCodeJson, mdiDelete, mdiDotsVertical, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
|
||||
import { mdiClose, mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
data: PageData;
|
||||
}
|
||||
};
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let workflows = $state<WorkflowResponseDto[]>(data.workflows);
|
||||
|
||||
const expandedWorkflows = new SvelteSet<string>();
|
||||
|
||||
const pluginFilterLookup = new SvelteMap<string, PluginFilterResponseDto>();
|
||||
@@ -95,54 +90,14 @@
|
||||
|
||||
const getJson = (workflow: WorkflowResponseDto) => JSON.stringify(constructPayload(workflow), null, 2);
|
||||
|
||||
const handleToggleEnabled = async (workflow: WorkflowResponseDto) => {
|
||||
try {
|
||||
const updated = await updateWorkflow({
|
||||
id: workflow.id,
|
||||
workflowUpdateDto: { enabled: !workflow.enabled },
|
||||
});
|
||||
workflows = workflows.map((w) => (w.id === updated.id ? updated : w));
|
||||
toastManager.success($t('workflow_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_workflow'));
|
||||
}
|
||||
const onWorkflowUpdate = (updatedWorkflow: WorkflowResponseDto) => {
|
||||
workflows = workflows.map((currentWorkflow) =>
|
||||
currentWorkflow.id === updatedWorkflow.id ? updatedWorkflow : currentWorkflow,
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteWorkflow = async (workflow: WorkflowResponseDto) => {
|
||||
try {
|
||||
const confirmed = await modalManager.showDialog({
|
||||
prompt: $t('workflow_delete_prompt'),
|
||||
confirmColor: 'danger',
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteWorkflow({ id: workflow.id });
|
||||
workflows = workflows.filter((w) => w.id !== workflow.id);
|
||||
toastManager.success($t('workflow_deleted'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_workflow'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditWorkflow = async (workflow: WorkflowResponseDto) => {
|
||||
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
|
||||
};
|
||||
|
||||
const handleCreateWorkflow = async () => {
|
||||
const workflow = await createWorkflow({
|
||||
workflowCreateDto: {
|
||||
name: 'New workflow',
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
filters: [],
|
||||
actions: [],
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
|
||||
const onWorkflowDelete = (deletedWorkflow: WorkflowResponseDto) => {
|
||||
workflows = workflows.filter((currentWorkflow) => currentWorkflow.id !== deletedWorkflow.id);
|
||||
};
|
||||
|
||||
const getFilterLabel = (filterId: string) => {
|
||||
@@ -168,8 +123,25 @@
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(createdAt));
|
||||
|
||||
const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => {
|
||||
const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow);
|
||||
void menuManager.show({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
position: 'top-left',
|
||||
items: [
|
||||
ToggleEnabled,
|
||||
Edit,
|
||||
getWorkflowShowSchemaAction($t, expandedWorkflows.has(workflow.id), () => toggleShowingSchema(workflow.id)),
|
||||
MenuItemType.Divider,
|
||||
Delete,
|
||||
],
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents {onWorkflowUpdate} {onWorkflowDelete} />
|
||||
|
||||
{#snippet chipItem(title: string)}
|
||||
<span class="rounded-xl border border-gray-200/80 px-3 py-1.5 text-sm dark:border-gray-600 bg-light">
|
||||
<span class="font-medium text-dark">{title}</span>
|
||||
@@ -232,38 +204,7 @@
|
||||
color="secondary"
|
||||
icon={mdiDotsVertical}
|
||||
aria-label={$t('menu')}
|
||||
onclick={(event: MouseEvent) => {
|
||||
void menuManager.show({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
position: 'top-left',
|
||||
items: [
|
||||
{
|
||||
title: workflow.enabled ? $t('disable') : $t('enable'),
|
||||
color: workflow.enabled ? 'danger' : 'primary',
|
||||
icon: workflow.enabled ? mdiPause : mdiPlay,
|
||||
onAction: () => void handleToggleEnabled(workflow),
|
||||
},
|
||||
{
|
||||
title: $t('edit'),
|
||||
icon: mdiPencil,
|
||||
onAction: () => void handleEditWorkflow(workflow),
|
||||
},
|
||||
|
||||
{
|
||||
title: expandedWorkflows.has(workflow.id) ? $t('hide_schema') : $t('show_schema'),
|
||||
icon: mdiCodeJson,
|
||||
onAction: () => toggleShowingSchema(workflow.id),
|
||||
},
|
||||
MenuItemType.Divider,
|
||||
{
|
||||
title: $t('delete'),
|
||||
icon: mdiDelete,
|
||||
color: 'danger',
|
||||
onAction: () => void handleDeleteWorkflow(workflow),
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
onclick={(event: MouseEvent) => showWorkflowMenu(event, workflow)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { dragAndDrop } from '$lib/actions/drag-and-drop';
|
||||
import { dragAndDrop } from '$lib/attachments/drag-and-drop.svelte';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import SchemaFormFields from '$lib/components/workflows/SchemaFormFields.svelte';
|
||||
import WorkflowCardConnector from '$lib/components/workflows/WorkflowCardConnector.svelte';
|
||||
@@ -56,9 +56,9 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
data: PageData;
|
||||
}
|
||||
};
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
@@ -321,7 +321,7 @@
|
||||
</script>
|
||||
|
||||
{#snippet cardOrder(index: number)}
|
||||
<div class="h-8 w-8 rounded-lg flex place-items-center place-content-center shrink-0 border">
|
||||
<div class="h-8 w-8 rounded-lg flex place-items-center place-content-center shrink-0 border bg-light-50">
|
||||
<Text size="small" class="font-mono font-bold">
|
||||
{index + 1}
|
||||
</Text>
|
||||
@@ -455,7 +455,7 @@
|
||||
{@render stepSeparator()}
|
||||
{/if}
|
||||
<div
|
||||
use:dragAndDrop={{
|
||||
{@attach dragAndDrop({
|
||||
index,
|
||||
onDragStart: handleFilterDragStart,
|
||||
onDragEnter: handleFilterDragEnter,
|
||||
@@ -463,7 +463,7 @@
|
||||
onDragEnd: handleFilterDragEnd,
|
||||
isDragging: draggedFilterIndex === index,
|
||||
isDragOver: dragOverFilterIndex === index,
|
||||
}}
|
||||
})}
|
||||
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">
|
||||
@@ -524,7 +524,7 @@
|
||||
{@render stepSeparator()}
|
||||
{/if}
|
||||
<div
|
||||
use:dragAndDrop={{
|
||||
{@attach dragAndDrop({
|
||||
index,
|
||||
onDragStart: handleActionDragStart,
|
||||
onDragEnter: handleActionDragEnter,
|
||||
@@ -532,7 +532,7 @@
|
||||
onDragEnd: handleActionDragEnd,
|
||||
isDragging: draggedActionIndex === index,
|
||||
isDragOver: dragOverActionIndex === index,
|
||||
}}
|
||||
})}
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user