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>
This commit is contained in:
200
web/src/lib/components/layout/CommandPalette.svelte
Normal file
200
web/src/lib/components/layout/CommandPalette.svelte
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<!--
|
||||||
|
⌘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>
|
||||||
@@ -83,6 +83,8 @@ export const view = $state<{
|
|||||||
previewOpen: boolean;
|
previewOpen: boolean;
|
||||||
/** Ephemeral: true while the keyboard-shortcuts overlay is open. */
|
/** Ephemeral: true while the keyboard-shortcuts overlay is open. */
|
||||||
shortcutsOpen: boolean;
|
shortcutsOpen: boolean;
|
||||||
|
/** Ephemeral: true while the ⌘K command palette is open. */
|
||||||
|
paletteOpen: boolean;
|
||||||
metadataSections: Record<string, boolean>;
|
metadataSections: Record<string, boolean>;
|
||||||
}>({
|
}>({
|
||||||
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
|
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
|
||||||
@@ -110,6 +112,7 @@ export const view = $state<{
|
|||||||
tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false,
|
tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false,
|
||||||
previewOpen: false,
|
previewOpen: false,
|
||||||
shortcutsOpen: false,
|
shortcutsOpen: false,
|
||||||
|
paletteOpen: false,
|
||||||
metadataSections:
|
metadataSections:
|
||||||
initial.metadataSections && typeof initial.metadataSections === 'object'
|
initial.metadataSections && typeof initial.metadataSections === 'object'
|
||||||
? { ...initial.metadataSections }
|
? { ...initial.metadataSections }
|
||||||
@@ -175,6 +178,14 @@ export function closeShortcuts(): void {
|
|||||||
view.shortcutsOpen = false;
|
view.shortcutsOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function togglePalette(): void {
|
||||||
|
view.paletteOpen = !view.paletteOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closePalette(): void {
|
||||||
|
view.paletteOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
export function setThumbnailSize(size: ThumbnailSize): void {
|
export function setThumbnailSize(size: ThumbnailSize): void {
|
||||||
view.thumbnailSize = size;
|
view.thumbnailSize = size;
|
||||||
persist();
|
persist();
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
||||||
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
|
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
|
||||||
import ShortcutsDialog from '$lib/components/layout/ShortcutsDialog.svelte';
|
import ShortcutsDialog from '$lib/components/layout/ShortcutsDialog.svelte';
|
||||||
|
import CommandPalette from '$lib/components/layout/CommandPalette.svelte';
|
||||||
|
import { togglePalette } from '$lib/stores/view.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -59,8 +61,18 @@
|
|||||||
else stopIndexerWatch();
|
else stopIndexerWatch();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ⌘K lives at the window level (not gridKeyNav) so the palette opens
|
||||||
|
// from any route and even while a form field holds focus.
|
||||||
|
function onGlobalKey(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePalette();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onGlobalKey} />
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Mulimage</title>
|
<title>Mulimage</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
@@ -125,6 +137,8 @@
|
|||||||
<MoveToFolderDialog />
|
<MoveToFolderDialog />
|
||||||
<!-- Keyboard-shortcut reference, toggled by `?` via gridKeyNav. -->
|
<!-- Keyboard-shortcut reference, toggled by `?` via gridKeyNav. -->
|
||||||
<ShortcutsDialog />
|
<ShortcutsDialog />
|
||||||
|
<!-- ⌘K palette — jump to sections/heaps/folders + global actions. -->
|
||||||
|
<CommandPalette />
|
||||||
{:else}
|
{:else}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user