merge main

This commit is contained in:
Alex Tran
2025-11-25 22:18:03 +00:00
57 changed files with 2473 additions and 2121 deletions

View File

@@ -11,7 +11,17 @@ export interface DragAndDropOptions {
export function dragAndDrop(node: HTMLElement, options: DragAndDropOptions) {
let { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
const handleDragStart = () => {
const isFormElement = (element: HTMLElement) => {
return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.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);
};
@@ -31,6 +41,21 @@ export function dragAndDrop(node: HTMLElement, options: DragAndDropOptions) {
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)) {
node.setAttribute('draggable', 'false');
}
};
const handleFocusOut = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
node.setAttribute('draggable', 'true');
}
};
node.setAttribute('draggable', 'true');
node.setAttribute('role', 'button');
node.setAttribute('tabindex', '0');
@@ -40,12 +65,14 @@ export function dragAndDrop(node: HTMLElement, options: DragAndDropOptions) {
node.addEventListener('dragover', handleDragOver);
node.addEventListener('drop', handleDrop);
node.addEventListener('dragend', handleDragEnd);
node.addEventListener('focusin', handleFocusIn);
node.addEventListener('focusout', handleFocusOut);
// 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');
@@ -68,10 +95,10 @@ export function dragAndDrop(node: HTMLElement, options: DragAndDropOptions) {
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;
@@ -84,6 +111,8 @@ export function dragAndDrop(node: HTMLElement, options: DragAndDropOptions) {
node.removeEventListener('dragover', handleDragOver);
node.removeEventListener('drop', handleDrop);
node.removeEventListener('dragend', handleDragEnd);
node.removeEventListener('focusin', handleFocusIn);
node.removeEventListener('focusout', handleFocusOut);
},
};
}

View File

@@ -1,16 +1,14 @@
<script lang="ts">
import type { ActionItem } from '$lib/types';
import { IconButton, type IconButtonProps } from '@immich/ui';
import { IconButton, type ActionItem } from '@immich/ui';
type Props = {
action: ActionItem;
};
const { action }: Props = $props();
const { title, icon, color = 'secondary', props: other = {}, onSelect } = $derived(action);
const onclick = (event: Event) => onSelect?.({ event, item: action });
const { title, icon, color = 'secondary', onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
<IconButton variant="ghost" {color} shape="round" {...other as IconButtonProps} {icon} aria-label={title} {onclick} />
<IconButton variant="ghost" shape="round" {color} {icon} aria-label={title} onclick={() => onAction(action)} />
{/if}

View File

@@ -1,18 +1,17 @@
<script lang="ts">
import type { ActionItem } from '$lib/types';
import { Button, type ButtonProps, Text } from '@immich/ui';
import { type ActionItem, Button, Text } from '@immich/ui';
type Props = {
action: ActionItem;
title?: string;
};
const { action }: Props = $props();
const { title, icon, color = 'secondary', props: other = {}, onSelect } = $derived(action);
const onclick = (event: Event) => onSelect?.({ event, item: action });
const { action, title: titleAttr }: Props = $props();
const { title, icon, color = 'secondary', onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
<Button variant="ghost" size="small" {color} {...other as ButtonProps} leadingIcon={icon} {onclick}>
<Button variant="ghost" size="small" {color} leadingIcon={icon} onclick={() => onAction(action)} title={titleAttr}>
<Text class="hidden md:block">{title}</Text>
</Button>
{/if}

View File

@@ -1,16 +1,14 @@
<script lang="ts">
import type { ActionItem } from '$lib/types';
import { IconButton, type IconButtonProps } from '@immich/ui';
import { IconButton, type ActionItem } from '@immich/ui';
type Props = {
action: ActionItem;
};
const { action }: Props = $props();
const { title, icon, props: other = {}, onSelect } = $derived(action);
const onclick = (event: Event) => onSelect?.({ event, item: action });
const { title, icon, onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
<IconButton shape="round" color="primary" {...other as IconButtonProps} {icon} aria-label={title} {onclick} />
<IconButton shape="round" color="primary" {icon} aria-label={title} onclick={() => onAction(action)} />
{/if}

View File

@@ -32,7 +32,7 @@
.filter(Boolean)
.join(' • ');
const SharedLinkActions = $derived(getSharedLinkActions($t, sharedLink));
const { ViewQrCode, Copy } = $derived(getSharedLinkActions($t, sharedLink));
</script>
<div class="flex justify-between items-center">
@@ -41,7 +41,7 @@
<Text size="tiny" color="muted">{getShareProperties()}</Text>
</div>
<div class="flex">
<ActionButton action={SharedLinkActions.ViewQrCode} />
<ActionButton action={SharedLinkActions.Copy} />
<ActionButton action={ViewQrCode} />
<ActionButton action={Copy} />
</div>
</div>

View File

@@ -264,20 +264,20 @@
<!-- Favorite asset star -->
{#if !authManager.isSharedLink && asset.isFavorite}
<div class="absolute bottom-2 start-2">
<Icon icon={mdiHeart} size="24" class="text-white" />
<Icon data-icon-favorite icon={mdiHeart} size="24" class="text-white" />
</div>
{/if}
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
</div>
{/if}
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mdiRotate360} size="24" />
<Icon data-icon-equirectangular icon={mdiRotate360} size="24" />
</span>
</div>
{/if}
@@ -285,7 +285,7 @@
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mdiFileGifBox} size="24" />
<Icon data-icon-playable icon={mdiFileGifBox} size="24" />
</span>
</div>
{/if}
@@ -300,7 +300,7 @@
>
<span class="pe-2 pt-2 flex place-items-center gap-1">
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
<Icon icon={mdiCameraBurst} size="24" />
<Icon data-icon-stack icon={mdiCameraBurst} size="24" />
</span>
</div>
{/if}
@@ -366,7 +366,7 @@
/>
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mdiMotionPauseOutline} size="24" />
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
</span>
</div>
</div>
@@ -406,13 +406,13 @@
{disabled}
>
{#if disabled}
<Icon icon={mdiCheckCircle} size="24" class="text-zinc-800" />
<Icon data-icon-select icon={mdiCheckCircle} size="24" class="text-zinc-800" />
{:else if selected}
<div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]">
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
<Icon data-icon-select icon={mdiCheckCircle} size="24" class="text-primary" />
</div>
{:else}
<Icon icon={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
<Icon data-icon-select icon={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
{/if}
</button>
{/if}

View File

@@ -4,16 +4,16 @@
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import AdminSidebar from '$lib/sidebars/AdminSidebar.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { AppShell, AppShellHeader, AppShellSidebar, Scrollable } from '@immich/ui';
import { AppShell, AppShellHeader, AppShellSidebar, Scrollable, type BreadcrumbItem } from '@immich/ui';
import type { Snippet } from 'svelte';
type Props = {
title: string;
breadcrumbs: BreadcrumbItem[];
buttons?: Snippet;
children?: Snippet;
};
let { title, buttons, children }: Props = $props();
let { breadcrumbs, buttons, children }: Props = $props();
</script>
<AppShell>
@@ -24,7 +24,7 @@
<AdminSidebar />
</AppShellSidebar>
<TitleLayout {title} {buttons}>
<TitleLayout {breadcrumbs} {buttons}>
<Scrollable class="grow">
<PageContent>
{@render children?.()}

View File

@@ -1,26 +1,20 @@
<script lang="ts">
import { Text } from '@immich/ui';
import { Breadcrumbs, type BreadcrumbItem } from '@immich/ui';
import { mdiSlashForward } from '@mdi/js';
import type { Snippet } from 'svelte';
interface Props {
id?: string;
title?: string;
description?: string;
type Props = {
breadcrumbs: BreadcrumbItem[];
buttons?: Snippet;
children?: Snippet;
}
};
let { id, title, description, buttons, children }: Props = $props();
let { breadcrumbs, buttons, children }: Props = $props();
</script>
<div class="h-full flex flex-col">
<div class="flex h-16 w-full place-items-center justify-between border-b p-2">
<div class="flex gap-1">
<div class="font-medium outline-none" tabindex="-1" {id}>{title}</div>
{#if description}
<Text color="muted">{description}</Text>
{/if}
</div>
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
{@render buttons?.()}
</div>
{@render children?.()}

View File

@@ -60,12 +60,12 @@
{/if}
<main class="relative">
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2 z-10" use:useActions={use}>
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" 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 z-10">
<div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark">
<div class="flex gap-2 items-center">
{#if title}
<div class="font-medium outline-none pe-8" tabindex="-1" id={headerId}>{title}</div>

View File

@@ -6,6 +6,7 @@
import { getSharedLinkActions } from '$lib/services/shared-link.service';
import { locale } from '$lib/stores/preferences.store';
import { SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
import { ContextMenuButton, MenuItemType } from '@immich/ui';
import { DateTime, type ToRelativeUnit } from 'luxon';
import { t } from 'svelte-i18n';
@@ -31,7 +32,7 @@
}
};
const SharedLinkActions = $derived(getSharedLinkActions($t, sharedLink));
const { Edit, Copy, Delete } = $derived(getSharedLinkActions($t, sharedLink));
</script>
<div
@@ -95,13 +96,17 @@
</svelte:element>
<div class="flex flex-auto flex-col place-content-center place-items-end text-end ms-4">
<div class="sm:flex hidden">
<ActionButton action={SharedLinkActions.Edit} />
<ActionButton action={SharedLinkActions.Copy} />
<ActionButton action={SharedLinkActions.Delete} />
<ActionButton action={Edit} />
<ActionButton action={Copy} />
<ActionButton action={Delete} />
</div>
<div class="sm:hidden">
<ActionButton action={SharedLinkActions.ContextMenu} />
<ContextMenuButton
aria-label={$t('shared_link_options')}
position="top-right"
items={[Edit, Copy, MenuItemType.Divider, Delete]}
/>
</div>
</div>
</div>

View File

@@ -351,7 +351,10 @@
void onScrub?.(scrubData);
};
const getTouch = (event: TouchEvent) => {
// desktop safari does not support this since Apple does not have desktop touch devices
// eslint-disable-next-line tscompat/tscompat
if (event.touches.length === 1) {
// eslint-disable-next-line tscompat/tscompat
return event.touches[0];
}
return null;
@@ -362,6 +365,8 @@
isHover = false;
return;
}
// desktop safari does not support this since Apple does not have desktop touch devices
// eslint-disable-next-line tscompat/tscompat
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
const isHoverScrollbar =
findElementBestY(elements, 0, 'scrubber', 'time-label', 'lead-in', 'lead-out') !== undefined;
@@ -370,6 +375,7 @@
if (isHoverScrollbar) {
handleMouseEvent({
// eslint-disable-next-line tscompat/tscompat
clientY: touch.clientY,
isDragging: true,
});
@@ -388,6 +394,7 @@
const touch = getTouch(event);
if (touch && isDragging) {
handleMouseEvent({
// eslint-disable-next-line tscompat/tscompat
clientY: touch.clientY,
});
} else {

View File

@@ -4,12 +4,12 @@
import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
import { Icon, modalManager } from '@immich/ui';
import {
mdiAutoFix,
mdiCellphoneArrowDownVariant,
mdiContentDuplicate,
mdiCrosshairsGps,
mdiImageSizeSelectLarge,
mdiLinkEdit,
mdiStateMachine,
} from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -17,7 +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') },
{ href: AppRoute.WORKFLOWS, icon: mdiStateMachine, label: $t('workflows') },
];
</script>

View File

@@ -4,7 +4,7 @@
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { formatLabel, getComponentFromSchema } from '$lib/utils/workflow';
import { getAlbumInfo, getPerson, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { Button, Field, Input, MultiSelect, Select, Switch, modalManager, type SelectItem } from '@immich/ui';
import { Button, Field, Input, MultiSelect, Select, Switch, Text, modalManager, type SelectItem } from '@immich/ui';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -298,9 +298,7 @@
{#each Object.entries(components) as [key, component] (key)}
{@const label = component.title || component.label || key}
<div
class="flex flex-col gap-1 bg-light dark:bg-black/50 border border-gray-200 dark:border-gray-700 p-4 rounded-xl"
>
<div class="flex flex-col gap-1 bg-light-50 border p-4 rounded-xl">
<!-- Select component -->
{#if component.type === 'select'}
{#if component.subType === 'album-picker' || component.subType === 'people-picker'}
@@ -380,5 +378,5 @@
{/each}
</div>
{:else}
<p class="text-sm text-gray-500">No configuration required</p>
<Text size="small" color="muted">No configuration required</Text>
{/if}

View File

@@ -56,9 +56,7 @@
</CardHeader>
<CardBody>
<VStack gap={2}>
<div
class="w-full h-[600px] rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600 {editorClass}"
>
<div class="w-full h-[600px] rounded-lg overflow-hidden border {editorClass}">
<JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
</div>
</VStack>

View File

@@ -1,14 +1,8 @@
<script lang="ts">
import type { PluginActionResponseDto, PluginFilterResponseDto, PluginTriggerResponseDto } from '@immich/sdk';
import { Icon, IconButton } from '@immich/ui';
import {
mdiClose,
mdiDrag,
mdiFilterOutline,
mdiFlashOutline,
mdiPlayCircleOutline,
mdiViewDashboardOutline,
} from '@mdi/js';
import { Icon, IconButton, Text } from '@immich/ui';
import { mdiClose, mdiFilterOutline, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
trigger: PluginTriggerResponseDto;
@@ -69,18 +63,17 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerEl}
class="hidden sm:block fixed w-64 z-50 hover:cursor-grab"
class="hidden sm:block fixed w-64 z-50 hover:cursor-grab select-none"
style="left: {position.x}px; top: {position.y}px;"
class:cursor-grabbing={isDragging}
onmousedown={handleMouseDown}
>
<div
class="rounded-xl border hover:shadow-xl hover:border-dashed bg-light-50 shadow-sm p-4 hover:border-primary-200 transition-all"
class="rounded-xl border-transparent border-2 hover:shadow-xl hover:border-dashed bg-light-50 shadow-sm p-4 hover:border-light-300 transition-all"
>
<div class="flex items-center justify-between mb-4 cursor-grab select-none">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Workflow Summary</h3>
<Text size="small" class="font-semibold">{$t('workflow_summary')}</Text>
<div class="flex items-center gap-1">
<Icon icon={mdiDrag} size="18" class="text-gray-400" />
<IconButton
icon={mdiClose}
size="small"
@@ -100,36 +93,32 @@
<!-- Trigger -->
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-1">
<Icon icon={mdiFlashOutline} size="14" class="text-primary" />
<span class="text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>Trigger</span
>
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('trigger')}</span>
</div>
<p class="text-sm text-gray-900 dark:text-gray-100 truncate pl-5">{trigger.name}</p>
<p class="text-sm truncate pl-5">{trigger.name}</p>
</div>
<!-- Connector -->
<div class="flex justify-center">
<div class="w-0.5 h-3 bg-gray-300 dark:bg-gray-600"></div>
<div class="w-0.5 h-3 bg-light-400"></div>
</div>
<!-- Filters -->
{#if filters.length > 0}
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-2">
<Icon icon={mdiFilterOutline} size="14" class="text-warning" />
<span class="text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>Filters ({filters.length})</span
>
<Icon icon={mdiFilterOutline} size="18" class="text-warning" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('filters')}</span>
</div>
<div class="space-y-1 pl-5">
{#each filters as filter, index (filter.id)}
<div class="flex items-center gap-2">
<span
class="shrink-0 h-4 w-4 rounded-full bg-gray-200 dark:bg-gray-700 text-[10px] font-medium text-gray-600 dark:text-gray-300 flex items-center justify-center"
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
>{index + 1}</span
>
<p class="text-sm text-gray-700 dark:text-gray-300 truncate">{filter.title}</p>
<p class="text-sm truncate">{filter.title}</p>
</div>
{/each}
</div>
@@ -137,7 +126,7 @@
<!-- Connector -->
<div class="flex justify-center">
<div class="w-0.5 h-3 bg-gray-300 dark:bg-gray-600"></div>
<div class="w-0.5 h-3 bg-light-400"></div>
</div>
{/if}
@@ -145,19 +134,17 @@
{#if actions.length > 0}
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-2">
<Icon icon={mdiPlayCircleOutline} size="14" class="text-success" />
<span class="text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>Actions ({actions.length})</span
>
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('actions')}</span>
</div>
<div class="space-y-1 pl-5">
{#each actions as action, index (action.id)}
<div class="flex items-center gap-2">
<span
class="shrink-0 h-4 w-4 rounded-full bg-gray-200 dark:bg-gray-700 text-[10px] font-medium text-gray-600 dark:text-gray-300 flex items-center justify-center"
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
>{index + 1}</span
>
<p class="text-sm text-gray-700 dark:text-gray-300 truncate">{action.title}</p>
<p class="text-sm truncate">{action.title}</p>
</div>
{/each}
</div>
@@ -170,7 +157,7 @@
<button
type="button"
class="hidden sm:flex fixed right-6 bottom-6 z-50 h-14 w-14 items-center justify-center rounded-full bg-primary text-light shadow-lg hover:bg-primary/90 transition-colors"
title="Show workflow summary"
title={$t('workflow_summary')}
onclick={() => (isOpen = true)}
>
<Icon icon={mdiViewDashboardOutline} size="24" />

View File

@@ -29,22 +29,22 @@
<button
type="button"
{onclick}
class="rounded-xl p-4 w-full text-left transition-all cursor-pointer border-2 {selected
class="group rounded-xl p-4 w-full text-left cursor-pointer border-2 {selected
? 'border-primary text-primary'
: 'border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-200'}"
: 'border-light-100 hover:border-light-200 text-light-400 hover:text-light-700'}"
>
<div class="flex items-center gap-3">
<div
class="rounded-xl p-2 bg-gray-200 {selected
class="rounded-xl p-2 {selected
? 'bg-primary text-light'
: 'text-gray-400 dark:text-gray-400 dark:bg-gray-900'}"
: 'text-light-100 bg-light-300 group-hover:bg-light-500'}"
>
<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>
<Text class="text-sm">{trigger.description}</Text>
{/if}
</div>
</div>

View File

@@ -1,52 +0,0 @@
// Preset workflow templates for common use cases
export interface WorkflowTemplate {
name: string;
description: string;
triggerType: 'AssetCreate' | 'PersonRecognized';
enabled: boolean;
filters: Array<{ filterId: string; filterConfig?: object }>;
actions: Array<{ actionId: string; actionConfig?: object }>;
}
// Note: These templates use placeholder filter/action IDs that need to be resolved
// from the actual plugin data at runtime
export const workflowTemplates = {
archiveOldPhotos: {
name: 'Archive Old Photos',
description: 'Automatically archive photos matching certain criteria',
triggerType: 'AssetCreate' as const,
enabled: false,
filters: [
// This will need to be populated with actual filter IDs from plugins
],
actions: [
// This will need to be populated with actual action IDs from plugins
],
},
favoritePhotos: {
name: 'Auto-Favorite Photos',
description: 'Automatically mark photos as favorites based on criteria',
triggerType: 'AssetCreate' as const,
enabled: false,
filters: [],
actions: [],
},
addToAlbum: {
name: 'Add to Album',
description: 'Automatically add assets to a specific album',
triggerType: 'AssetCreate' as const,
enabled: false,
filters: [],
actions: [],
},
blank: {
name: 'New Workflow',
description: '',
triggerType: 'AssetCreate' as const,
enabled: true,
filters: [],
actions: [],
},
};
export type WorkflowTemplateName = keyof typeof workflowTemplates;

View File

@@ -2,6 +2,7 @@
import type { PluginActionResponseDto, PluginFilterResponseDto } from '@immich/sdk';
import { Icon, Modal, ModalBody } from '@immich/ui';
import { mdiFilterOutline, mdiPlayCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
filters: PluginFilterResponseDto[];
@@ -25,34 +26,32 @@
};
</script>
<Modal title="Add Workflow Step" icon={false} onClose={() => onClose()}>
<Modal title={$t('add_workflow_step')} icon={false} onClose={() => onClose()}>
<ModalBody>
<div class="space-y-6">
<!-- Filters Section -->
{#if availableFilters.length > 0 && (!type || type === 'filter')}
<div>
<div class="flex items-center gap-2 mb-3">
<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>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Filters</h3>
</div>
<div class="grid grid-cols-1 gap-2">
{#each availableFilters as filter (filter.id)}
<button
type="button"
onclick={() => handleSelect('filter', filter)}
class="flex items-start gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-amber-300 dark:hover:border-amber-700 hover:bg-amber-50/50 dark:hover:bg-amber-950/20 transition-all text-left"
>
<div class="flex-1">
<p class="font-medium text-sm text-gray-900 dark:text-gray-100">{filter.title}</p>
{#if filter.description}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{filter.description}</p>
{/if}
</div>
</button>
{/each}
<div class="flex items-center gap-2 mb-3">
<div class="h-6 w-6 rounded-md bg-warning-100 flex items-center justify-center">
<Icon icon={mdiFilterOutline} size="16" class="text-warning" />
</div>
<h3 class="text-sm font-semibold">Filters</h3>
</div>
<div class="grid grid-cols-1 gap-2">
{#each availableFilters as filter (filter.id)}
<button
type="button"
onclick={() => handleSelect('filter', filter)}
class="flex items-start gap-3 p-3 rounded-lg border bg-light-100 hover:border-warning-500 dark:hover:border-warning-500 text-left"
>
<div class="flex-1">
<p class="font-medium text-sm">{filter.title}</p>
{#if filter.description}
<p class="text-xs text-light-500 mt-1">{filter.description}</p>
{/if}
</div>
</button>
{/each}
</div>
{/if}
@@ -60,22 +59,22 @@
{#if availableActions.length > 0 && (!type || type === 'action')}
<div>
<div class="flex items-center gap-2 mb-3">
<div class="h-6 w-6 rounded-md bg-teal-100 dark:bg-teal-950 flex items-center justify-center">
<div class="h-6 w-6 rounded-md bg-success-50 flex items-center justify-center">
<Icon icon={mdiPlayCircleOutline} size="16" class="text-success" />
</div>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Actions</h3>
<h3 class="text-sm font-semibold">Actions</h3>
</div>
<div class="grid grid-cols-1 gap-2">
{#each availableActions as action (action.id)}
<button
type="button"
onclick={() => handleSelect('action', action)}
class="flex items-start gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-teal-300 dark:hover:border-teal-700 hover:bg-teal-50/50 dark:hover:bg-teal-950/20 transition-all text-left"
class="flex items-start gap-3 p-3 rounded-lg border bg-light-100 hover:border-success-500 dark:hover:border-success-500 text-left"
>
<div class="flex-1">
<p class="font-medium text-sm text-gray-900 dark:text-gray-100">{action.title}</p>
<p class="font-medium text-sm">{action.title}</p>
{#if action.description}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{action.description}</p>
<p class="text-xs text-light-500 mt-1">{action.description}</p>
{/if}
</div>
</button>

View File

@@ -7,7 +7,6 @@ import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
import LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte';
import type { ActionItem } from '$lib/types';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
@@ -20,7 +19,7 @@ import {
updateLibrary,
type LibraryResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
@@ -28,13 +27,13 @@ export const getLibrariesActions = ($t: MessageFormatter) => {
const ScanAll: ActionItem = {
title: $t('scan_all_libraries'),
icon: mdiSync,
onSelect: () => void handleScanAllLibraries(),
onAction: () => void handleScanAllLibraries(),
};
const Create: ActionItem = {
title: $t('create_library'),
icon: mdiPlusBoxOutline,
onSelect: () => void handleCreateLibrary(),
onAction: () => void handleCreateLibrary(),
};
return { ScanAll, Create };
@@ -44,32 +43,32 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
const Rename: ActionItem = {
icon: mdiPencilOutline,
title: $t('rename'),
onSelect: () => void modalManager.show(LibraryRenameModal, { library }),
onAction: () => void modalManager.show(LibraryRenameModal, { library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
color: 'danger',
onSelect: () => void handleDeleteLibrary(library),
onAction: () => void handleDeleteLibrary(library),
};
const AddFolder: ActionItem = {
icon: mdiPlusBoxOutline,
title: $t('add'),
onSelect: () => void modalManager.show(LibraryFolderAddModal, { library }),
onAction: () => void modalManager.show(LibraryFolderAddModal, { library }),
};
const AddExclusionPattern: ActionItem = {
icon: mdiPlusBoxOutline,
title: $t('add'),
onSelect: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }),
onAction: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }),
};
const Scan: ActionItem = {
icon: mdiSync,
title: $t('scan_library'),
onSelect: () => void handleScanLibrary(library),
onAction: () => void handleScanLibrary(library),
};
return { Rename, Delete, AddFolder, AddExclusionPattern, Scan };
@@ -79,13 +78,13 @@ export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryRe
const Edit: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onSelect: () => void modalManager.show(LibraryFolderEditModal, { folder, library }),
onAction: () => void modalManager.show(LibraryFolderEditModal, { folder, library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
onSelect: () => void handleDeleteLibraryFolder(library, folder),
onAction: () => void handleDeleteLibraryFolder(library, folder),
};
return { Edit, Delete };
@@ -99,13 +98,13 @@ export const getLibraryExclusionPatternActions = (
const Edit: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onSelect: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
onAction: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
onSelect: () => void handleDeleteExclusionPattern(library, exclusionPattern),
onAction: () => void handleDeleteExclusionPattern(library, exclusionPattern),
};
return { Edit, Delete };

View File

@@ -16,48 +16,37 @@ import {
type SharedLinkEditDto,
type SharedLinkResponseDto,
} from '@immich/sdk';
import { MenuItemType, menuManager, modalManager, toastManager, type MenuItem } from '@immich/ui';
import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiDotsVertical, mdiQrcode } from '@mdi/js';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiQrcode } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLinkResponseDto) => {
const Edit: MenuItem = {
const Edit: ActionItem = {
title: $t('edit_link'),
icon: mdiCircleEditOutline,
onSelect: () => void goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`),
onAction: () => void goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`),
};
const Delete: MenuItem = {
const Delete: ActionItem = {
title: $t('delete_link'),
icon: mdiDelete,
color: 'danger',
onSelect: () => void handleDeleteSharedLink(sharedLink),
onAction: () => void handleDeleteSharedLink(sharedLink),
};
const Copy: MenuItem = {
const Copy: ActionItem = {
title: $t('copy_link'),
icon: mdiContentCopy,
onSelect: () => void copyToClipboard(asUrl(sharedLink)),
onAction: () => void copyToClipboard(asUrl(sharedLink)),
};
const ViewQrCode: MenuItem = {
const ViewQrCode: ActionItem = {
title: $t('view_qr_code'),
icon: mdiQrcode,
onSelect: () => void handleShowSharedLinkQrCode(sharedLink),
onAction: () => void handleShowSharedLinkQrCode(sharedLink),
};
const ContextMenu: MenuItem = {
title: $t('shared_link_options'),
icon: mdiDotsVertical,
onSelect: ({ event }) =>
void menuManager.show({
target: event.currentTarget as HTMLElement,
position: 'top-right',
items: [Edit, Copy, MenuItemType.Divider, Delete],
}),
};
return { Edit, Delete, Copy, ViewQrCode, ContextMenu };
return { Edit, Delete, Copy, ViewQrCode };
};
const asUrl = (sharedLink: SharedLinkResponseDto) => {

View File

@@ -1,12 +1,11 @@
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { ActionItem } from '$lib/types';
import { copyToClipboard } from '$lib/utils';
import { downloadBlob } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { getConfig, updateConfig, type ServerFeaturesDto, type SystemConfigDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { toastManager, type ActionItem } from '@immich/ui';
import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
import { isEqual } from 'lodash-es';
import type { MessageFormatter } from 'svelte-i18n';
@@ -19,20 +18,20 @@ export const getSystemConfigActions = (
const CopyToClipboard: ActionItem = {
title: $t('copy_to_clipboard'),
icon: mdiContentCopy,
onSelect: () => void handleCopyToClipboard(config),
onAction: () => void handleCopyToClipboard(config),
};
const Download: ActionItem = {
title: $t('export_as_json'),
icon: mdiDownload,
onSelect: () => handleDownloadConfig(config),
onAction: () => handleDownloadConfig(config),
};
const Upload: ActionItem = {
title: $t('import_from_json'),
icon: mdiUpload,
$if: () => !featureFlags.configFile,
onSelect: () => handleUploadConfig(),
onAction: () => handleUploadConfig(),
};
return { CopyToClipboard, Download, Upload };

View File

@@ -1,13 +1,11 @@
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
import UserCreateModal from '$lib/modals/UserCreateModal.svelte';
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
import UserEditModal from '$lib/modals/UserEditModal.svelte';
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
import { user as authUser } from '$lib/stores/user.store';
import type { ActionItem } from '$lib/types';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
@@ -21,45 +19,33 @@ import {
type UserAdminResponseDto,
type UserAdminUpdateDto,
} from '@immich/sdk';
import { MenuItemType, menuManager, modalManager, toastManager } from '@immich/ui';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiDeleteRestore,
mdiDotsVertical,
mdiEyeOutline,
mdiLockReset,
mdiLockSmart,
mdiPencilOutline,
mdiPlusBoxOutline,
mdiTrashCanOutline,
} from '@mdi/js';
import { DateTime } from 'luxon';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
const getDeleteDate = (deletedAt: string): Date =>
DateTime.fromISO(deletedAt).plus({ days: serverConfigManager.value.userDeleteDelay }).toJSDate();
export const getUserAdminsActions = ($t: MessageFormatter) => {
const Create: ActionItem = {
title: $t('create_user'),
icon: mdiPlusBoxOutline,
onSelect: () => void modalManager.show(UserCreateModal, {}),
onAction: () => void modalManager.show(UserCreateModal, {}),
};
return { Create };
};
export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => {
const View: ActionItem = {
icon: mdiEyeOutline,
title: $t('view'),
onSelect: () => void goto(`/admin/users/${user.id}`),
};
const Update: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onSelect: () => void modalManager.show(UserEditModal, { user }),
onAction: () => void modalManager.show(UserEditModal, { user }),
};
const Delete: ActionItem = {
@@ -67,7 +53,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
title: $t('delete'),
color: 'danger',
$if: () => get(authUser).id !== user.id && !user.deletedAt,
onSelect: () => void modalManager.show(UserDeleteConfirmModal, { user }),
onAction: () => void modalManager.show(UserDeleteConfirmModal, { user }),
};
const Restore: ActionItem = {
@@ -75,47 +61,23 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
title: $t('restore'),
color: 'primary',
$if: () => !!user.deletedAt && user.status === UserStatus.Deleted,
onSelect: () => void modalManager.show(UserRestoreConfirmModal, { user }),
props: {
title: $t('admin.user_restore_scheduled_removal', {
values: { date: getDeleteDate(user.deletedAt!) },
}),
},
onAction: () => void modalManager.show(UserRestoreConfirmModal, { user }),
};
const ResetPassword: ActionItem = {
icon: mdiLockReset,
title: $t('reset_password'),
$if: () => get(authUser).id !== user.id,
onSelect: () => void handleResetPasswordUserAdmin(user),
onAction: () => void handleResetPasswordUserAdmin(user),
};
const ResetPinCode: ActionItem = {
icon: mdiLockSmart,
title: $t('reset_pin_code'),
onSelect: () => void handleResetPinCodeUserAdmin(user),
onAction: () => void handleResetPinCodeUserAdmin(user),
};
const ContextMenu: ActionItem = {
icon: mdiDotsVertical,
title: $t('actions'),
onSelect: ({ event }) =>
void menuManager.show({
target: event.currentTarget as HTMLElement,
position: 'top-right',
items: [
View,
Update,
ResetPassword,
ResetPinCode,
get(authUser).id === user.id ? undefined : MenuItemType.Divider,
Restore,
Delete,
].filter(Boolean),
}),
};
return { View, Update, Delete, Restore, ResetPassword, ResetPinCode, ContextMenu };
return { Update, Delete, Restore, ResetPassword, ResetPinCode };
};
export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
@@ -172,6 +134,10 @@ export const handleRestoreUserAdmin = async (user: UserAdminResponseDto) => {
}
};
export const handleNavigateUserAdmin = async (user: UserAdminResponseDto) => {
await goto(`/admin/users/${user.id}`);
};
// TODO move password reset server-side
const generatePassword = (length: number = 16) => {
let generatedPassword = '';

View File

@@ -1,8 +1,4 @@
import type { ServerVersionResponseDto } from '@immich/sdk';
import type { MenuItem } from '@immich/ui';
import type { HTMLAttributes } from 'svelte/elements';
export type ActionItem = MenuItem & { props?: Omit<HTMLAttributes<HTMLElement>, 'color'> };
export interface ReleaseEvent {
isAvailable: boolean;

View File

@@ -162,7 +162,7 @@ export const getQueueName = derived(t, ($t) => {
[QueueName.Notifications]: $t('notifications'),
[QueueName.BackupDatabase]: $t('admin.backup_database'),
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
[QueueName.Workflow]: $t('workflow'),
[QueueName.Workflow]: $t('workflows'),
};
return names[name];

View File

@@ -567,7 +567,15 @@
onClick={() => updateThumbnailUsingCurrentSelection()}
/>
{/if}
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
{/if}

View File

@@ -85,7 +85,11 @@
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
/>
{#if $preferences.tags.enabled}
<TagAction menuItem />

View File

@@ -511,7 +511,11 @@
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
/>
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />

View File

@@ -146,7 +146,14 @@
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem onArchive={(assetIds) => timelineManager.removeAssets(assetIds)} />
<ArchiveAction
menuItem
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
/>
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}

View File

@@ -187,7 +187,7 @@
<div class="flex flex-col items-center justify-center gap-4 py-20">
<Icon icon={mdiPlay} size="64" class="text-immich-primary dark:text-immich-dark-primary" />
<h2 class="text-2xl font-semibold">{$t('no_workflows_yet')}</h2>
<p class="text-center text-sm text-gray-600 dark:text-gray-400">
<p class="text-center text-sm text-muted">
{$t('workflows_help_text')}
</p>
<Button shape="round" color="primary" onclick={handleCreateWorkflow}>
@@ -198,7 +198,7 @@
{:else}
<div class="my-6 grid gap-6">
{#each workflows as workflow (workflow.id)}
<Card class="border border-gray-200/70 shadow-xl shadow-gray-900/5 dark:border-gray-700/60">
<Card class="border border-light-200">
<CardHeader
class={`flex flex-col px-8 py-6 gap-4 sm:flex-row sm:items-center sm:gap-6 ${
workflow.enabled
@@ -220,7 +220,7 @@
<div class="flex items-center gap-4">
<div class="text-right">
<Text size="tiny" class="text-gray-500 dark:text-gray-400">{$t('created_at')}</Text>
<Text size="tiny">{$t('created_at')}</Text>
<Text size="small" class="font-medium">
{formatTimestamp(workflow.createdAt)}
</Text>
@@ -240,25 +240,25 @@
title: workflow.enabled ? $t('disable') : $t('enable'),
color: workflow.enabled ? 'danger' : 'primary',
icon: workflow.enabled ? mdiPause : mdiPlay,
onSelect: () => void handleToggleEnabled(workflow),
onAction: () => void handleToggleEnabled(workflow),
},
{
title: $t('edit'),
icon: mdiPencil,
onSelect: () => void handleEditWorkflow(workflow),
onAction: () => void handleEditWorkflow(workflow),
},
{
title: expandedWorkflows.has(workflow.id) ? $t('hide_schema') : $t('show_schema'),
icon: mdiCodeJson,
onSelect: () => toggleShowingSchema(workflow.id),
onAction: () => toggleShowingSchema(workflow.id),
},
MenuItemType.Divider,
{
title: $t('delete'),
icon: mdiDelete,
color: 'danger',
onSelect: () => void handleDeleteWorkflow(workflow),
onAction: () => void handleDeleteWorkflow(workflow),
},
],
});
@@ -284,7 +284,7 @@
</div>
<div class="flex flex-wrap gap-2">
{#if workflow.filters.length === 0}
<span class="text-sm text-gray-500 dark:text-gray-400">
<span class="text-sm text-light-600">
{$t('no_filters_added')}
</span>
{:else}
@@ -303,7 +303,7 @@
<div>
{#if workflow.actions.length === 0}
<span class="text-sm text-gray-500 dark:text-gray-400">
<span class="text-sm text-light-600">
{$t('no_actions_added')}
</span>
{:else}

View File

@@ -12,7 +12,7 @@ export const load = (async ({ url }) => {
workflows,
plugins,
meta: {
title: $t('workflow'),
title: $t('workflows'),
},
};
}) satisfies PageLoad;

View File

@@ -7,6 +7,7 @@
import WorkflowJsonEditor from '$lib/components/workflows/WorkflowJsonEditor.svelte';
import WorkflowSummarySidebar from '$lib/components/workflows/WorkflowSummary.svelte';
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';
@@ -30,6 +31,7 @@
Textarea,
VStack,
modalManager,
toastManager,
} from '@immich/ui';
import {
mdiArrowLeft,
@@ -104,18 +106,6 @@
const updateWorkflow = async () => {
try {
console.log('Updating workflow with:', {
id: editWorkflow.id,
name,
description,
enabled: editWorkflow.enabled,
triggerType,
orderedFilters: orderedFilters.map((f) => ({ id: f.id, methodName: f.methodName })),
orderedActions: orderedActions.map((a) => ({ id: a.id, methodName: a.methodName })),
filterConfigs,
actionConfigs,
});
const updated = await workflowService.updateWorkflow(
editWorkflow.id,
name,
@@ -131,8 +121,11 @@
// Update the previous workflow state to the new values
previousWorkflow = updated;
editWorkflow = updated;
toastManager.success($t('workflow_update_success'), {
closable: true,
});
} catch (error) {
console.log('error', error);
handleError(error, 'Failed to update workflow');
}
};
@@ -312,22 +305,20 @@
</script>
{#snippet cardOrder(index: number)}
<div
class="h-8 w-8 rounded-lg borderflex place-items-center place-content-center shrink-0 border dark:border-gray-500"
>
<p class="font-mono text-sm font-bold">
<div class="h-8 w-8 rounded-lg flex place-items-center place-content-center shrink-0 border">
<Text size="small" class="font-mono font-bold">
{index + 1}
</p>
</Text>
</div>
{/snippet}
{#snippet stepSeparator()}
<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-gray-300 dark:border-gray-700"></div>
<div class="w-full border-t-2 border-dashed border-light-200"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-white dark:bg-black px-2 font-semibold text-gray-500">THEN</span>
<span class="bg-white dark:bg-black px-2 font-semibold text-light-500">THEN</span>
</div>
</div>
{/snippet}
@@ -336,11 +327,11 @@
<button
type="button"
{onclick}
class="w-full p-8 rounded-lg border-2 border-dashed border-gray-300 hover:bg-gray-50 dark:hover:bg-gray-900 dark:border-gray-600 transition-all flex flex-col items-center justify-center gap-2"
class="w-full p-8 rounded-lg border-2 border-dashed hover:border-light-400 hover:bg-light-50 transition-all flex flex-col items-center justify-center gap-2"
>
<Icon icon={mdiPlus} size="32" />
<p class="text-sm font-medium">{title}</p>
<p class="text-xs">{description}</p>
<Text size="small" class="font-medium">{title}</Text>
<Text size="tiny">{description}</Text>
</button>
{/snippet}
@@ -373,25 +364,25 @@
<CardBody>
<VStack gap={6}>
<Field class="text-sm" label="Name" for="workflow-name" required>
<Input placeholder="Workflow name" bind:value={name} />
<Field class="text-sm" label={$t('name')} for="workflow-name" required>
<Input id="workflow-name" placeholder={$t('workflow_name')} bind:value={name} />
</Field>
<Field class="text-sm" label="Description" for="workflow-description">
<Textarea placeholder="Workflow description" bind:value={description} />
<Field class="text-sm" label={$t('description')} for="workflow-description">
<Textarea id="workflow-description" placeholder={$t('workflow_description')} bind:value={description} />
</Field>
</VStack>
</CardBody>
</Card>
<div class="my-10 h-px w-[98%] bg-gray-200 dark:bg-gray-700"></div>
<div class="my-10 h-px w-[98%] bg-light-200"></div>
<Card expandable>
<CardHeader class="bg-primary-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-primary" />
<div class="flex flex-col">
<CardTitle class="text-left text-primary">Trigger</CardTitle>
<CardDescription>An event that kick off the workflow</CardDescription>
<CardTitle class="text-left text-primary">{$t('trigger')}</CardTitle>
<CardDescription>{$t('trigger_description')}</CardDescription>
</div>
</div>
</CardHeader>
@@ -416,17 +407,15 @@
<div class="flex items-start gap-3">
<Icon icon={mdiFilterOutline} size="20" class="mt-1 text-warning" />
<div class="flex flex-col">
<CardTitle class="text-left text-warning">Filter</CardTitle>
<CardDescription>Conditions to filter the target assets</CardDescription>
<CardTitle class="text-left text-warning">{$t('filter')}</CardTitle>
<CardDescription>{$t('filter_description')}</CardDescription>
</div>
</div>
</CardHeader>
<CardBody>
{#if orderedFilters.length === 0}
{@render emptyCreateButton('Add Filter', 'Click to add a filter condition', () =>
handleAddStep('filter'),
)}
{@render emptyCreateButton($t('add_filter'), $t('add_filter_description'), () => handleAddStep('filter'))}
{:else}
{#each orderedFilters as filter, index (filter.id)}
{#if index > 0}
@@ -442,7 +431,7 @@
isDragging: draggedFilterIndex === index,
isDragOver: dragOverFilterIndex === index,
}}
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-neutral-50 dark:bg-neutral-900/50 border-dashed border-transparent hover:border-gray-300 dark:hover:border-gray-600"
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-light-50 border-dashed border-transparent hover:border-light-300"
>
<div class="flex items-start gap-4">
{@render cardOrder(index)}
@@ -487,17 +476,15 @@
<div class="flex items-start gap-3">
<Icon icon={mdiPlayCircleOutline} size="20" class="mt-1 text-success" />
<div class="flex flex-col">
<CardTitle class="text-left text-success">Action</CardTitle>
<CardDescription>A set of action to perform on the filtered assets</CardDescription>
<CardTitle class="text-left text-success">{$t('action')}</CardTitle>
<CardDescription>{$t('action_description')}</CardDescription>
</div>
</div>
</CardHeader>
<CardBody>
{#if orderedActions.length === 0}
{@render emptyCreateButton('Add Action', 'Click to add an action to perform', () =>
handleAddStep('action'),
)}
{@render emptyCreateButton($t('add_action'), $t('add_action_description'), () => handleAddStep('action'))}
{:else}
{#each orderedActions as action, index (action.id)}
{#if index > 0}
@@ -513,7 +500,7 @@
isDragging: draggedActionIndex === index,
isDragOver: dragOverActionIndex === index,
}}
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-neutral-50 dark:bg-neutral-900/50 border-dashed border-transparent hover:border-gray-300 dark:hover:border-gray-600"
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-light-50 border-dashed border-transparent hover:border-light-300"
>
<div class="flex items-start gap-4">
{@render cardOrder(index)}
@@ -554,18 +541,14 @@
</Container>
</main>
<ControlAppBar
onClose={() => goto('/utilities/workflows')}
backIcon={mdiArrowLeft}
tailwindClasses="fixed! top-0! w-full"
>
<ControlAppBar onClose={() => goto(AppRoute.WORKFLOWS)} backIcon={mdiArrowLeft} tailwindClasses="fixed! top-0! w-full">
{#snippet leading()}
<p>{data.meta.title}</p>
{/snippet}
{#snippet trailing()}
<HStack gap={4}>
<HStack gap={1} class="border rounded-lg p-1 dark:border-gray-600">
<HStack gap={1} class="border rounded-lg p-1 border-light-300">
<Button
size="small"
variant={viewMode === 'visual' ? 'outline' : 'ghost'}

View File

@@ -58,7 +58,7 @@
});
</script>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={0}>
{#if pausedJobs.length > 0}

View File

@@ -58,7 +58,7 @@
onLibraryDelete={handleDeleteLibrary}
/>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<div class="flex justify-end gap-2">
{#if libraries.length > 0}

View File

@@ -39,7 +39,12 @@
onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)}
/>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout
breadcrumbs={[
{ title: $t('admin.external_library_management'), href: AppRoute.ADMIN_LIBRARY_MANAGEMENT },
{ title: library.name },
]}
>
{#snippet buttons()}
<div class="flex justify-end gap-2">
<HeaderButton action={Scan} />

View File

@@ -24,7 +24,7 @@
});
</script>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
<section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 sm:w-5/6 md:w-212.5">
<ServerStatisticsPanel {stats} />

View File

@@ -215,7 +215,7 @@
);
</script>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={1}>
<div class="hidden lg:block">

View File

@@ -2,12 +2,11 @@
import HeaderButton from '$lib/components/HeaderButton.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { getUserAdminActions, getUserAdminsActions } from '$lib/services/user-admin.service';
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units';
import { type UserAdminResponseDto } from '@immich/sdk';
import { HStack, Icon } from '@immich/ui';
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, HStack, Icon } from '@immich/ui';
import { mdiInfinity } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -20,9 +19,11 @@
let allUsers: UserAdminResponseDto[] = $state(data.allUsers);
const onUpdate = (user: UserAdminResponseDto) => {
const onUpdate = async (user: UserAdminResponseDto) => {
const index = allUsers.findIndex(({ id }) => id === user.id);
if (index !== -1) {
if (index === -1) {
allUsers = await searchUsersAdmin({ withDeleted: true });
} else {
allUsers[index] = user;
}
};
@@ -42,7 +43,7 @@
{onUserAdminDeleted}
/>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={1}>
<HeaderButton action={Create} />
@@ -60,12 +61,10 @@
>
<th class="hidden sm:block w-3/12 text-center text-sm font-medium">{$t('name')}</th>
<th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">{$t('has_quota')}</th>
<th class="w-4/12 lg:w-3/12 xl:w-2/12 text-center text-sm font-medium">{$t('action')}</th>
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each allUsers as user (user.id)}
{@const { View, ContextMenu } = getUserAdminActions($t, user)}
<tr
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
? 'bg-red-300 dark:bg-red-900'
@@ -87,8 +86,7 @@
<td
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
>
<TableButton action={View} />
<TableButton action={ContextMenu} />
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
</td>
</tr>
{/each}

View File

@@ -8,6 +8,7 @@
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
import { AppRoute } from '$lib/constants';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { getUserAdminActions } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { createDateFormatter, findLocale } from '$lib/utils';
@@ -15,6 +16,7 @@
import { type UserAdminResponseDto } from '@immich/sdk';
import {
Alert,
Badge,
Card,
CardBody,
CardHeader,
@@ -39,6 +41,7 @@
mdiPlayCircle,
mdiTrashCanOutline,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -77,7 +80,7 @@
return 'bg-primary';
};
const UserAdminActions = $derived(getUserAdminActions($t, user));
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
const onUpdate = (update: UserAdminResponseDto) => {
if (update.id === user.id) {
@@ -90,6 +93,9 @@
await goto(AppRoute.ADMIN_USERS);
}
};
const getDeleteDate = (deletedAt: string): Date =>
DateTime.fromISO(deletedAt).plus({ days: serverConfigManager.value.userDeleteDelay }).toJSDate();
</script>
<OnEvents
@@ -99,14 +105,19 @@
{onUserAdminDeleted}
/>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}
>
{#snippet buttons()}
<HStack gap={0}>
<HeaderButton action={UserAdminActions.ResetPassword} />
<HeaderButton action={UserAdminActions.ResetPinCode} />
<HeaderButton action={UserAdminActions.Update} />
<HeaderButton action={UserAdminActions.Restore} />
<HeaderButton action={UserAdminActions.Delete} />
<HeaderButton action={ResetPassword} />
<HeaderButton action={ResetPinCode} />
<HeaderButton action={Update} />
<HeaderButton
action={Restore}
title={$t('admin.user_restore_scheduled_removal', { values: { date: getDeleteDate(user.deletedAt!) } })}
/>
<HeaderButton action={Delete} />
</HStack>
{/snippet}
<div>
@@ -116,9 +127,16 @@
{/if}
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<div class="col-span-full flex gap-4 items-center my-4">
<UserAvatar {user} size="md" />
<Heading tag="h1" size="large">{user.name}</Heading>
<div class="col-span-full flex flex-col gap-4 my-4">
<div class="flex items-center gap-4">
<UserAvatar {user} size="md" />
<Heading tag="h1" size="large">{user.name}</Heading>
</div>
{#if user.isAdmin}
<div>
<Badge color="primary" size="small">{$t('admin.admin_user')}</Badge>
</div>
{/if}
</div>
<div class="col-span-full">
<div class="flex flex-col lg:flex-row gap-4 w-full">