From ef6e1def3d661858df0f32dce59dfdd696fcd85c Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 24 Apr 2026 12:40:12 +0200 Subject: [PATCH] feat(discovery): keyboard shortcuts for queue review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship 2 MR 8. Operator-productivity layer on top of the detail drawer: j/k to walk rows, Enter to open, a/r to accept-reject the selection, e/s to jump into the drawer with AI enrich / Similar already visible, ? for a help modal listing everything. Escape closes the drawer (or the help modal if it's open). Implementation - selectedId $state drives a subtle indigo ring on the highlighted row. Follows drawerId when the drawer opens so Esc → j leaves you on the same row. Auto-resets to queue[0] if the selected row scrolls off the page (pagination / refresh). - Global listener. isTypingTarget() bails out when focus is inside an input/textarea/select/contenteditable so typing in the drawer's edit form doesn't trigger shortcuts. Cmd/Ctrl/Alt combos also skipped so browser shortcuts stay intact. - selectRelative() updates selectedId + scrolls the row into view (block: 'nearest') so keyboard-driven scanning through a long queue keeps the highlight visible. - submitRowAction() builds + submits a hidden
for a/r so the SvelteKit action pipeline (invalidations, form result propagation) runs the same way a button click would. Decisions baked in - 'e' (AI enrich) and 's' (Similar) open the drawer rather than firing the LLM call directly. LLM calls cost money; keeping the UI explicit avoids hidden side effects from a misclick. - Persistent '?' button bottom-right for discoverability — operators shouldn't have to read docs to find the help. - Modal uses click-outside-to-dismiss + Esc + ✕ button, all three. No backend changes. Frontend-only. --- web/src/routes/admin/discovery/+page.svelte | 208 +++++++++++++++++++- 1 file changed, 204 insertions(+), 4 deletions(-) diff --git a/web/src/routes/admin/discovery/+page.svelte b/web/src/routes/admin/discovery/+page.svelte index 2de3059..1654c38 100644 --- a/web/src/routes/admin/discovery/+page.svelte +++ b/web/src/routes/admin/discovery/+page.svelte @@ -85,6 +85,131 @@ goto(qs ? `?${qs}` : '?', { replaceState: false, noScroll: true }); } + // --- Keyboard navigation. Selection highlight tracks which row will + // receive j/k-driven actions (a/r/e/s). Enter opens the drawer. --- + let selectedId = $state(null); + let showHelp = $state(false); + + // When the drawer opens, selection follows — so closing the drawer + // returns focus to the same row. + $effect(() => { + if (drawerId) selectedId = drawerId; + }); + // Keep selection inside the current page; if the row scrolled away + // (page change / refresh), reset to the first row so j/k still work. + $effect(() => { + if (selectedId && !queue.find((r) => r.id === selectedId)) { + selectedId = queue[0]?.id ?? null; + } + }); + + function selectRelative(delta: number) { + if (queue.length === 0) return; + const idx = selectedId ? queue.findIndex((r) => r.id === selectedId) : -1; + const next = Math.max(0, Math.min(queue.length - 1, idx + delta)); + selectedId = queue[next === -1 ? 0 : next].id; + // Scroll into view so the highlighted row stays visible during + // keyboard-driven scanning through a long list. + requestAnimationFrame(() => { + document + .querySelector(`[data-row-id="${selectedId}"]`) + ?.scrollIntoView({ block: 'nearest' }); + }); + } + + // Programmatically submit a form action for the selected row. Used by the + // a/r keyboard shortcuts — mirrors the Accept/Reject buttons' behaviour + // so use:enhance invalidations run. + function submitRowAction(action: string, id: string) { + const f = document.createElement('form'); + f.method = 'POST'; + f.action = `?/${action}`; + const idInput = document.createElement('input'); + idInput.type = 'hidden'; + idInput.name = 'id'; + idInput.value = id; + f.appendChild(idInput); + document.body.appendChild(f); + f.submit(); + } + + // Typeable-target guard: don't intercept keys while the user is typing + // in an input/textarea (drawer edit form, in particular). + function isTypingTarget(el: EventTarget | null): boolean { + if (!(el instanceof HTMLElement)) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + return el.isContentEditable; + } + + function handleKeydown(event: KeyboardEvent) { + if (isTypingTarget(event.target)) return; + // Don't fight browser shortcuts (Cmd-R, Ctrl-F, etc.). + if (event.metaKey || event.ctrlKey || event.altKey) return; + + switch (event.key) { + case 'j': + case 'ArrowDown': + event.preventDefault(); + selectRelative(1); + break; + case 'k': + case 'ArrowUp': + event.preventDefault(); + selectRelative(-1); + break; + case 'Enter': + if (selectedId && !drawerId) { + event.preventDefault(); + openDrawer(selectedId); + } + break; + case 'Escape': + if (showHelp) { + event.preventDefault(); + showHelp = false; + } else if (drawerId) { + event.preventDefault(); + closeDrawer(); + } + break; + case 'a': + if (selectedId) { + event.preventDefault(); + submitRowAction('accept', selectedId); + } + break; + case 'r': + if (selectedId) { + event.preventDefault(); + submitRowAction('reject', selectedId); + } + break; + case 'e': + // Open the drawer so the AI button is visible + clickable — + // avoids hidden side-effects. Operator then hits the button + // (or we could wire the action directly; keeping it explicit + // while the feature is young). + if (selectedId && !drawerId) { + event.preventDefault(); + openDrawer(selectedId); + } + break; + case 's': + // Same as 'e' — drawer exposes the Similar section at the + // bottom, already auto-loaded on open. + if (selectedId && !drawerId) { + event.preventDefault(); + openDrawer(selectedId); + } + break; + case '?': + event.preventDefault(); + showHelp = !showHelp; + break; + } + } + // Pagination helpers. const totalPages = $derived(Math.ceil((data.total ?? 0) / data.limit)); @@ -315,6 +440,8 @@ ); + + Admin · Discovery @@ -692,16 +819,23 @@ {#each queue as row (row.id)} { // Ignore clicks on interactive descendants (Accept/Reject - // forms, sort header buttons, links). Everything else opens - // the drawer. + // forms, sort header buttons, links). Everything else + // selects + opens the drawer. const target = e.target as HTMLElement; - if (target.closest('button, a, input, form, label, select')) return; + if (target.closest('button, a, input, form, label, select')) { + selectedId = row.id; + return; + } + selectedId = row.id; openDrawer(row.id); }} role="button" @@ -849,3 +983,69 @@ llmEnriching={openRow ? llmEnrichingIds.has(openRow.id) : false} llmEnrichError={openRow && form?.llmEnrichErrorId === openRow.id ? (form.llmEnrichError ?? '') : ''} /> + + +{#if showHelp} + +
(showHelp = false)} + role="button" + tabindex="-1" + aria-label="Hilfe schließen" + > + + +
+{/if} + + +