mirror of
https://github.com/immich-app/immich.git
synced 2025-12-23 01:11:36 +03:00
merge main
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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?.()}
|
||||
|
||||
@@ -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?.()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const load = (async ({ url }) => {
|
||||
workflows,
|
||||
plugins,
|
||||
meta: {
|
||||
title: $t('workflow'),
|
||||
title: $t('workflows'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user