6a559bfcd2
Full-stack implementation of provider-scoped Actions with extensible executor architecture. First action type: Immich auto_organize (sort assets into albums by person, CLIP search, date range, favorites). Core: - ActionTypeDefinition registry + ActionExecutor ABC with execute/validate/dry-run - ImmichActionExecutor with multi-album support and client-side filtering - ImmichClient write methods: add/remove assets, create album, paginated search Server: - Action, ActionRule, ActionExecution DB models - Full CRUD API + manual execute + dry-run + execution history endpoints - APScheduler integration (interval + cron) for automated execution - Action type discovery API + provider people endpoint Frontend: - Actions page with CRUD, execute/dry-run buttons, inline rule editor - RuleEditor: person/album MultiEntitySelect pickers, criteria config - ExecutionHistory: expandable per-rule result details - MultiEntitySelect reusable component (searchable multi-pick palette) - Notification tracker album picker migrated to MultiEntitySelect - Fixed MdiIcon race condition (icons missing after cache-clearing reload)
94 lines
3.7 KiB
Svelte
94 lines
3.7 KiB
Svelte
<script lang="ts">
|
|
import { getMdiPath, getAllMdiNames } from '$lib/mdi-lookup.svelte';
|
|
|
|
let { value = '', onselect } = $props<{
|
|
value: string;
|
|
onselect: (icon: string) => void;
|
|
}>();
|
|
|
|
let open = $state(false);
|
|
let search = $state('');
|
|
let buttonEl: HTMLButtonElement;
|
|
let dropdownStyle = $state('');
|
|
|
|
const popular = [
|
|
'mdiServer', 'mdiCamera', 'mdiImage', 'mdiVideo', 'mdiBell', 'mdiSend',
|
|
'mdiRobot', 'mdiHome', 'mdiStar', 'mdiHeart', 'mdiAccount', 'mdiFolder',
|
|
'mdiFolderImage', 'mdiAlbum', 'mdiImageMultiple', 'mdiCloudUpload',
|
|
'mdiEye', 'mdiCog', 'mdiTelegram', 'mdiWebhook', 'mdiMessageText',
|
|
'mdiCalendar', 'mdiClock', 'mdiMapMarker', 'mdiTag', 'mdiFilter',
|
|
'mdiSort', 'mdiMagnify', 'mdiPencil', 'mdiDelete', 'mdiPlus',
|
|
'mdiCheck', 'mdiClose', 'mdiAlert', 'mdiInformation', 'mdiShield',
|
|
'mdiLink', 'mdiDownload', 'mdiUpload', 'mdiRefresh', 'mdiPlay',
|
|
'mdiPause', 'mdiStop', 'mdiSkipNext', 'mdiMusic', 'mdiMovie',
|
|
'mdiFileDocument', 'mdiEmail', 'mdiPhone', 'mdiChat', 'mdiShare',
|
|
];
|
|
|
|
const filtered = $derived.by(() => {
|
|
const allIcons = getAllMdiNames();
|
|
if (!search) return popular.filter(p => allIcons.includes(p));
|
|
const q = search.toLowerCase();
|
|
return allIcons.filter(k => k.toLowerCase().includes(q)).slice(0, 60);
|
|
});
|
|
|
|
function toggleOpen() {
|
|
if (!open && buttonEl) {
|
|
const rect = buttonEl.getBoundingClientRect();
|
|
dropdownStyle = `position:fixed; z-index:9999; top:${rect.bottom + 4}px; left:${rect.left}px;`;
|
|
}
|
|
open = !open;
|
|
if (!open) search = '';
|
|
}
|
|
|
|
function select(iconName: string) {
|
|
onselect(iconName);
|
|
open = false;
|
|
search = '';
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape' && open) {
|
|
open = false;
|
|
search = '';
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
|
|
|
<div class="inline-block">
|
|
<button type="button" bind:this={buttonEl} onclick={toggleOpen}
|
|
class="flex items-center justify-center gap-1 px-2 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] hover:bg-[var(--color-muted)] transition-colors">
|
|
{#if value && getMdiPath(value)}
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getMdiPath(value)} /></svg>
|
|
{:else}
|
|
<span class="text-[var(--color-muted-foreground)] text-xs">Icon</span>
|
|
{/if}
|
|
<span class="text-xs text-[var(--color-muted-foreground)]">▼</span>
|
|
</button>
|
|
</div>
|
|
|
|
{#if open}
|
|
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
|
|
role="presentation"
|
|
onclick={() => { open = false; search = ''; }}></div>
|
|
|
|
<div style="{dropdownStyle} width: 20rem; background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; box-shadow: 0 10px 25px rgba(0,0,0,0.3); padding: 0.75rem;"
|
|
class="">
|
|
<input type="text" bind:value={search} placeholder="Search icons..."
|
|
class="w-full px-2 py-1 mb-2 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
|
<div style="display: grid; grid-template-columns: repeat(8, 1fr); gap: 0.25rem; max-height: 14rem; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin;">
|
|
<button type="button" onclick={() => select('')}
|
|
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] text-xs text-[var(--color-muted-foreground)]"
|
|
title="No icon">✕</button>
|
|
{#each filtered as iconName}
|
|
<button type="button" onclick={() => select(iconName)}
|
|
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] {value === iconName ? 'bg-[var(--color-accent)]' : ''}"
|
|
title={iconName.replace('mdi', '')}>
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getMdiPath(iconName)} /></svg>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|