From 9ef1b4c2f935343e3b32227be184e9dc41409883 Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 3 Jul 2026 13:15:00 +0200 Subject: [PATCH] feat(web): keyboard rating/color marks, search focus, shortcuts overlay Lightroom-style keys in grid+preview: 0-5 rating (re-key toggles), 6-9 color labels, optimistic marks cache patch with rollback. / focuses the search box, ? opens a new shortcut-reference overlay that inertly swallows other keys while open. Co-Authored-By: Claude Fable 5 --- web/src/lib/actions/gridKeyNav.ts | 103 ++++++++++++++- .../components/layout/ShortcutsDialog.svelte | 120 ++++++++++++++++++ web/src/lib/stores/view.svelte.ts | 11 ++ web/src/routes/+layout.svelte | 3 + web/src/routes/+page.svelte | 1 + 5 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/components/layout/ShortcutsDialog.svelte diff --git a/web/src/lib/actions/gridKeyNav.ts b/web/src/lib/actions/gridKeyNav.ts index c31454b..8f66bb4 100644 --- a/web/src/lib/actions/gridKeyNav.ts +++ b/web/src/lib/actions/gridKeyNav.ts @@ -7,7 +7,10 @@ import { batchArchive, batchDelete, batchRestore, + bulkSetMarks, removeFromHeap, + type PhotoMark, + type PhotoMarksMap, type PpAlbum } from '$lib/services/photoprism'; import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions'; @@ -36,7 +39,14 @@ import { setDetail, markRemoved } from '$lib/stores/bulkAction.svelte'; -import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte'; +import { + closeShortcuts, + openPreview, + toggleLeftSidebar, + toggleRightSidebar, + toggleShortcuts, + view +} from '$lib/stores/view.svelte'; /** * Optional parameters the host passes via `use:gridKeyNav={...}`. @@ -68,8 +78,8 @@ export interface GridKeyNavParams { * heap N (bare s adds to the currently-viewed heap), b/Tab toggles * left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes, * ⌘A selects all visible. - * Rating + color labels are mouse-driven via the metadata sidebar — no - * keyboard shortcuts. + * 0–5 rating, 6–9 Lightroom color labels, / focuses search, + * ? opens the shortcut reference overlay. * * Archive / restore target a synthesized "cull target list" — in priority: * 1. multi-selection set @@ -377,6 +387,55 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { await addCullTargetsToHeap(heaps[idx - 1]); } + // ── Rating / color-label keys (Lightroom layout) ───────────────────── + // Bare 0–5 set the rating (0 clears; re-keying the current value also + // clears, matching the sidebar's click-to-toggle). 6–9 toggle the four + // Lightroom color labels. Multi-selection stamps the whole set. + const COLOR_KEYS: Record = { '6': 'red', '7': 'yellow', '8': 'green', '9': 'blue' }; + + async function markCullTargets(patch: PhotoMark, label: string) { + const ids = cullTargets(); + if (ids.length === 0) { + toast.message('Nothing to mark', { + description: 'Click a photo or select some first' + }); + return; + } + // Optimistic cache patch — the tile badges and facet panels read + // ['marks'], so stamping it up front makes the keystroke feel instant. + const prevMap = queryClient.getQueryData(['marks']) ?? {}; + const next: PhotoMarksMap = { ...prevMap }; + for (const id of ids) { + const merged: PhotoMark = { ...next[id], ...patch }; + if (!merged.rating) delete merged.rating; + if (!merged.color) delete merged.color; + next[id] = merged; + } + queryClient.setQueryData(['marks'], next); + try { + await bulkSetMarks(ids, patch); + void queryClient.invalidateQueries({ queryKey: ['marks'] }); + toast.success(ids.length === 1 ? label : `${label} · ${ids.length} photos`); + } catch (err) { + queryClient.setQueryData(['marks'], prevMap); + toast.error(err instanceof Error ? err.message : 'Mark failed'); + } + } + + function ratingOfFirstTarget(): number { + const ids = cullTargets(); + if (ids.length === 0) return 0; + const marks = queryClient.getQueryData(['marks']) ?? {}; + return marks[ids[0]]?.rating ?? 0; + } + + function colorOfFirstTarget(): string { + const ids = cullTargets(); + if (ids.length === 0) return ''; + const marks = queryClient.getQueryData(['marks']) ?? {}; + return marks[ids[0]]?.color ?? ''; + } + async function addCullTargetsToActiveHeap() { if (filters.section !== 'heap' || !filters.heapUid) { toast.message('Press S then 1–9 to pick a heap'); @@ -396,6 +455,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return; + // Shortcuts overlay: Esc or ? closes it; every other key is inert + // while it's up so the reference card can't trigger the actions it + // documents. + if (view.shortcutsOpen) { + if (e.key === 'Escape' || e.key === '?') { + e.preventDefault(); + closeShortcuts(); + } + return; + } + // Modal owns arrow / Escape / Space while it's open — it handles // its own linear nav, close-on-Esc, and close-on-Space. Action // keys (X/S/U/A/Z) still pass through because they target the @@ -431,6 +501,22 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { const meta = e.metaKey || e.ctrlKey; const shift = e.shiftKey; + // Bare digits: rating (0–5, re-key toggles off) and Lightroom color + // labels (6–9). Runs after the S-chord so "s 3" still files to heap 3. + if (!meta && !shift && /^[0-9]$/.test(e.key)) { + e.preventDefault(); + const n = parseInt(e.key, 10); + if (n <= 5) { + const value = n === 0 || ratingOfFirstTarget() === n ? 0 : n; + void markCullTargets({ rating: value }, value ? `Rated ${value}★` : 'Rating cleared'); + } else { + const color = COLOR_KEYS[e.key]; + const value = colorOfFirstTarget() === color ? '' : color; + void markCullTargets({ color: value }, value ? `Labeled ${value}` : 'Color cleared'); + } + return; + } + // Space on a focused tile opens the full-screen preview modal. // Matches the dblclick gesture so the user has both keyboard and // mouse paths to the same surface. `e.code === 'Space'` covers @@ -477,6 +563,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { clearSelection(); setFocused(null); return; + case '/': + // Jump to the search box (any page that renders one tags it + // with data-search-input). + if (meta) return; + e.preventDefault(); + document.querySelector('[data-search-input]')?.focus(); + return; + case '?': + e.preventDefault(); + toggleShortcuts(); + return; case 'Tab': // Tab in the grid context = mule-image's left-sidebar toggle. // Browsers reserve Tab for focus traversal — preventDefault diff --git a/web/src/lib/components/layout/ShortcutsDialog.svelte b/web/src/lib/components/layout/ShortcutsDialog.svelte new file mode 100644 index 0000000..3afd17c --- /dev/null +++ b/web/src/lib/components/layout/ShortcutsDialog.svelte @@ -0,0 +1,120 @@ + + + +{#if view.shortcutsOpen} + +
{ + if (e.target === e.currentTarget) closeShortcuts(); + }} + > + +
+{/if} diff --git a/web/src/lib/stores/view.svelte.ts b/web/src/lib/stores/view.svelte.ts index e402a98..28f919a 100644 --- a/web/src/lib/stores/view.svelte.ts +++ b/web/src/lib/stores/view.svelte.ts @@ -81,6 +81,8 @@ export const view = $state<{ * persisted — a refresh always returns to the grid. */ previewOpen: boolean; + /** Ephemeral: true while the keyboard-shortcuts overlay is open. */ + shortcutsOpen: boolean; metadataSections: Record; }>({ rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false, @@ -107,6 +109,7 @@ export const view = $state<{ ), tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false, previewOpen: false, + shortcutsOpen: false, metadataSections: initial.metadataSections && typeof initial.metadataSections === 'object' ? { ...initial.metadataSections } @@ -164,6 +167,14 @@ export function togglePreview(): void { view.previewOpen = !view.previewOpen; } +export function toggleShortcuts(): void { + view.shortcutsOpen = !view.shortcutsOpen; +} + +export function closeShortcuts(): void { + view.shortcutsOpen = false; +} + export function setThumbnailSize(size: ThumbnailSize): void { view.thumbnailSize = size; persist(); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index eff62c5..b552a0f 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -20,6 +20,7 @@ import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte'; import PreviewModal from '$lib/components/preview/PreviewModal.svelte'; import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte'; + import ShortcutsDialog from '$lib/components/layout/ShortcutsDialog.svelte'; let { children } = $props(); @@ -122,6 +123,8 @@ store. Opened from the heap/folder kebabs, the BulkActionBar button, and the `m` shortcut — all through openMove(). --> + + {:else} {@render children?.()} {/if} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 31c2370..fde817c 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -843,6 +843,7 @@