feat: workflows & plugins (#26727)

feat: plugins

chore: better types

feat: plugins
This commit is contained in:
Jason Rasmussen
2026-05-18 11:09:33 -04:00
committed by GitHub
parent 7384799f19
commit 3d075f2bf8
144 changed files with 6099 additions and 7419 deletions
@@ -1,18 +1,19 @@
<script lang="ts">
import type { HeaderButtonActionItem } from '$lib/types';
import { Button } from '@immich/ui';
import { Button, type Variants } from '@immich/ui';
type Props = {
action: HeaderButtonActionItem;
variant?: Variants;
};
const { action }: Props = $props();
const { action, variant }: Props = $props();
const { title, icon, color = 'secondary', onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
<Button
variant="ghost"
variant={variant ?? 'ghost'}
size="small"
{color}
leadingIcon={icon}
@@ -0,0 +1,59 @@
<script lang="ts">
import AlbumThumbnail from '$lib/components/album-page/AlbumThumbnail.svelte';
import { Button, Text, Label, modalManager } from '@immich/ui';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import { t } from 'svelte-i18n';
type Props = {
label: string;
description?: string;
albumIds: string[];
array?: boolean;
};
let { array, label, description, albumIds = $bindable([]) }: Props = $props();
const onAlbums = async () => {
const albums = await modalManager.show(AlbumPickerModal);
if (!albums || albums.length === 0) {
return;
}
albumIds = array ? [...albumIds, ...albums.map((album) => album.id)] : [albums[0].id];
};
</script>
{#snippet button()}
<Button size="small" shape="round" color="secondary" onclick={() => onAlbums()}>{$t('choose')}</Button>
{/snippet}
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<Label for="album-picker" size="small" class="font-medium" {label} />
{#if description}
<Text color="muted" size="small">{description}</Text>
{/if}
</div>
{#if array}
<div class="flex flex-col gap-2">
{#each albumIds as albumId, i (i)}
<AlbumThumbnail
{albumId}
onDelete={() => {
albumIds.splice(i, 1);
albumIds = [...albumIds];
}}
/>
{/each}
{@render button()}
</div>
{:else}
{@const albumId = albumIds[0]}
{#if albumId}
<AlbumThumbnail {albumId} onDelete={() => (albumIds = [])} />
{:else}
{@render button()}
{/if}
{/if}
</div>
@@ -0,0 +1,99 @@
<script lang="ts">
import SchemaAlbumPicker from '$lib/components/SchemaAlbumPicker.svelte';
import Self from '$lib/components/SchemaConfiguration.svelte';
import type { JSONSchemaProperty, SchemaConfig } from '$lib/types';
import { CodeBlock, Field, Input, Label, MultiSelect, NumberInput, Select, Switch, Text } from '@immich/ui';
type Props = {
schema: JSONSchemaProperty;
root?: boolean;
key?: string;
config: SchemaConfig;
};
let { schema, key = '', root = false, config = $bindable() }: Props = $props();
const label = $derived(schema.title ?? key);
const description = $derived(schema.description);
const getValue = <T,>(defaultValue?: T) => (root === true ? config : (config?.[key] ?? defaultValue)) as T;
const setValue = <T,>(value: T) => {
if (root === true) {
config = value;
} else {
if (config === undefined) {
config = {};
}
config[key] = value;
}
};
const getUiHintValue = () => {
if (schema.array) {
return getValue<string[]>([]);
}
const values = getValue<string>();
return values ? [values] : [];
};
const setUiHintValue = (values: string[]) => setValue(schema.array ? values : values[0]);
const getBoolean = (defaultValue = false) => getValue<boolean>(defaultValue);
const getString = () => getValue<string>();
const getEnum = () => getValue<string[]>([]);
const getNumber = () => getValue<number>();
</script>
<!-- Empty schema object -->
{#if Object.keys(schema).length === 0}
<!-- noop -->
<!-- nested configuration (also top level objects) -->
{:else if schema.type === 'object'}
{#if !root}
<div class="flex flex-col gap-2">
<Label size="small" class="font-medium" {label}></Label>
{#if description}
<Text color="muted" size="small">{description}</Text>
{/if}
</div>
{/if}
<div class="flex flex-col gap-4 {root ? '' : 'border-l-4 border-gray-200 ps-2'}">
{#each Object.entries(schema.properties ?? {}) as [childKey, childSchema] (childKey)}
<Self schema={childSchema} key={childKey} bind:config={getValue, setValue} />
{/each}
</div>
{:else if schema.uiHint === 'albumId'}
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
{:else if schema.enum && schema.array}
<Field {label} {description}>
<MultiSelect options={schema.enum} bind:values={getEnum, setValue} />
</Field>
{:else if schema.enum}
<Field {label} {description}>
<Select options={schema.enum} bind:value={getString, setValue} />
</Field>
{:else if schema.array}
<!-- {@const values = getValue<string[]>([])}
<Field {label} {description}>
{#each values as value, i (i)}
<Input bind:value={() => getValue<string>(), setValue} />
{/each}
</Field> -->
{:else if schema.type === 'boolean'}
<Field {label} {description}>
<Switch bind:checked={() => getBoolean(schema.default ?? false), setValue} />
</Field>
{:else if schema.type === 'number'}
<Field {label} {description}>
<NumberInput bind:value={getNumber, setValue} />
</Field>
{:else if schema.type === 'string'}
<Field {label} {description}>
<Input bind:value={() => getValue<string>(), setValue} />
</Field>
{:else}
<Text>Unknown schema</Text>
<CodeBlock code={JSON.stringify(schema, null, 2)} />
{/if}
@@ -23,7 +23,7 @@
showDateRange = false,
showItemCount = false,
preload = false,
onShowContextMenu = undefined,
onShowContextMenu,
}: Props = $props();
const showAlbumContextMenu = (e: MouseEvent) => {
@@ -0,0 +1,50 @@
<script lang="ts">
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAlbumInfo } from '@immich/sdk';
import { IconButton, LoadingSpinner } from '@immich/ui';
import { mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
albumId: string;
onDelete: () => unknown;
}
let { albumId, onDelete }: Props = $props();
</script>
<div>
{#await getAlbumInfo({ ...authManager.params, id: albumId })}
<LoadingSpinner />
{:then album}
<div class="flex gap-2">
<AlbumCover {album} class="size-24" />
<p
class="line-clamp-2 grow text-lg/6 font-semibold text-black group-hover:text-primary dark:text-white"
data-testid="album-name"
title={album.albumName}
>
{album.albumName}
</p>
{#if album.description}
<p
class="line-clamp-2 grow text-lg/6 font-semibold text-black group-hover:text-primary dark:text-white"
data-testid="album-name"
>
{album.description}
</p>
{/if}
<div class="">
<IconButton
icon={mdiTrashCanOutline}
shape="round"
color="danger"
variant="ghost"
onclick={onDelete}
aria-label={$t('remove')}
/>
</div>
</div>
{/await}
</div>