Files
mule-image/web/src/lib/components/layout/CommandPalette.svelte
dtoro 9ba8d625bc feat(web): ⌘K command palette
bits-ui Command in a dialog: jump to sections, heaps, folders (from the
already-warm sidebar queries), plus dark-mode and shortcut-overlay
actions. Global ⌘K binding in the layout works from any route and while
inputs hold focus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:22:31 +02:00

201 lines
6.6 KiB
Svelte

<!--
⌘K command palette. Jump to any section, heap, folder, or tag category,
plus a few global actions (dark mode, shortcut overlay). Data comes from
the same TanStack queries the sidebar already keeps warm (['heaps'],
['folders', …]), so opening the palette costs no extra fetches once the
app has booted. bits-ui's Command owns filtering and keyboard selection.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { Command, Dialog } from 'bits-ui';
import { createQuery } from '@tanstack/svelte-query';
import { toggleMode } from 'mode-watcher';
import {
Archive,
EyeOff,
Folder,
Image,
Keyboard,
Layers,
ListChecks,
Moon,
NotebookPen,
Tags,
Users
} from 'lucide-svelte';
import { listFolders, listHeaps, type PpAlbum, type PpFolder } from '$lib/services/photoprism';
import { isAuthenticated, userLibraryBase } from '$lib/stores/session.svelte';
import { closePalette, toggleShortcuts, view } from '$lib/stores/view.svelte';
const heapsQuery = createQuery<PpAlbum[]>(() => ({
queryKey: ['heaps'],
queryFn: listHeaps,
enabled: isAuthenticated() && view.paletteOpen
}));
const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders', userLibraryBase()],
queryFn: listFolders,
staleTime: 30_000,
enabled: isAuthenticated() && view.paletteOpen
}));
function run(fn: () => void) {
closePalette();
fn();
}
const go = (path: string) => () => run(() => void goto(path));
interface Entry {
label: string;
icon: typeof Image;
action: () => void;
}
const SECTIONS: Entry[] = [
{ label: 'All photos', icon: Image, action: go('/') },
{ label: 'Review queue', icon: ListChecks, action: go('/review') },
{ label: 'Archive', icon: Archive, action: go('/?section=archive') },
{ label: 'Hidden', icon: EyeOff, action: go('/?section=hidden') },
{ label: 'Notes', icon: NotebookPen, action: go('/notes') },
{ label: 'Tags', icon: Tags, action: go('/tags/labels') },
{ label: 'People', icon: Users, action: go('/tags/people') },
{ label: 'Duplicates', icon: Layers, action: go('/review?tab=stacks') }
];
const ACTIONS: Entry[] = [
{ label: 'Toggle dark mode', icon: Moon, action: () => run(toggleMode) },
{ label: 'Keyboard shortcuts', icon: Keyboard, action: () => run(toggleShortcuts) }
];
// Folders can number in the hundreds; the palette lists them all and
// lets Command's fuzzy filter narrow. Sorted shallow-first so top-level
// folders surface before deep ones on an empty query.
const folderEntries = $derived(
[...(foldersQuery.data ?? [])]
.sort(
(a, b) =>
a.Path.split('/').length - b.Path.split('/').length || a.Path.localeCompare(b.Path)
)
.slice(0, 400)
);
</script>
<Dialog.Root
open={view.paletteOpen}
onOpenChange={(o) => {
if (!o) closePalette();
}}
>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 z-[80] bg-black/50 backdrop-blur-sm" />
<Dialog.Content
class="fixed left-1/2 top-24 z-[81] w-[min(560px,92vw)] -translate-x-1/2 overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-xl"
aria-label="Command palette"
>
<Command.Root class="flex max-h-[60vh] flex-col">
<Command.Input
class="w-full border-b border-border bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
placeholder="Jump to a view, heap, or folder…"
/>
<Command.List class="overflow-y-auto p-1.5">
<Command.Viewport>
<Command.Empty class="px-3 py-6 text-center text-xs text-muted-foreground">
No matches.
</Command.Empty>
<Command.Group>
<Command.GroupHeading
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Go to
</Command.GroupHeading>
<Command.GroupItems>
{#each SECTIONS as s (s.label)}
<Command.Item
value={s.label}
onSelect={s.action}
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
>
<s.icon class="h-3.5 w-3.5 text-muted-foreground" />
{s.label}
</Command.Item>
{/each}
</Command.GroupItems>
</Command.Group>
{#if (heapsQuery.data ?? []).length > 0}
<Command.Group>
<Command.GroupHeading
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Heaps
</Command.GroupHeading>
<Command.GroupItems>
{#each heapsQuery.data ?? [] as heap (heap.UID)}
<Command.Item
value={`heap ${heap.Title}`}
onSelect={go(`/?section=heap&heap=${heap.UID}`)}
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
>
<Layers class="h-3.5 w-3.5 text-muted-foreground" />
{heap.Title}
{#if heap.PhotoCount}
<span class="ml-auto text-[10px] text-muted-foreground">
{heap.PhotoCount}
</span>
{/if}
</Command.Item>
{/each}
</Command.GroupItems>
</Command.Group>
{/if}
{#if folderEntries.length > 0}
<Command.Group>
<Command.GroupHeading
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Folders
</Command.GroupHeading>
<Command.GroupItems>
{#each folderEntries as folder (folder.Path)}
<Command.Item
value={`folder ${folder.Path}`}
onSelect={go(`/?folder=${encodeURIComponent(folder.Path)}`)}
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
>
<Folder class="h-3.5 w-3.5 text-muted-foreground" />
<span class="truncate">{folder.Path}</span>
</Command.Item>
{/each}
</Command.GroupItems>
</Command.Group>
{/if}
<Command.Group>
<Command.GroupHeading
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Actions
</Command.GroupHeading>
<Command.GroupItems>
{#each ACTIONS as a (a.label)}
<Command.Item
value={a.label}
onSelect={a.action}
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
>
<a.icon class="h-3.5 w-3.5 text-muted-foreground" />
{a.label}
</Command.Item>
{/each}
</Command.GroupItems>
</Command.Group>
</Command.Viewport>
</Command.List>
</Command.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>