Die letzte offene Zeile aus TASKS.md. Sie blieb offen, weil eine Spur über
jeden Aktenaufruf schnell etwas anderes wird als eine Bequemlichkeit.
Vier Festlegungen halten sie auf der richtigen Seite:
- Kein Verlauf. Der Primärschlüssel (userId, targetType, targetId) lässt je
Nutzer und Datensatz genau eine Zeile zu, die überschrieben wird. Die
Tabelle kann nicht sagen, wie oft oder in welcher Reihenfolge gearbeitet
wurde. Zwanzig Einträge, älteres fällt hinten heraus.
- Nur die eigene. Keine Ansicht, kein Adminbereich, keine Funktion mit
fremder Kennung. Jede Abfrage nimmt p.userId.
- Jederzeit löschbar, ohne Rückfrage und ohne Grabstein.
- Vorabrufe zählen nicht. Ohne die Prüfung auf next-router-prefetch stünde
dort, worüber jemand mit der Maus gefahren ist.
Gelesen wird über dieselben Sichtbarkeitsfragmente wie überall: eine
vorgemerkte Kennung ist keine Berechtigung. Steigt eine Akte über die
Freigabestufe, verschwindet sie ohne Lücke und ohne Platzhalter; Personalakten
brauchen zusätzlich personnel.read. Das Zugriffsprotokoll bleibt unberührt und
bleibt die einzige Auskunft darüber, wer wann was gelesen hat.
Sie steht auf der Übersicht und im leeren Feld der Kommandopalette. Der zweite
Ort ist der eigentliche.
Nebenbei: NEXT_DIST_DIR, damit ein Build neben einem laufenden
Entwicklungsserver nicht dasselbe .next-Verzeichnis abräumt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
219 lines
7.4 KiB
TypeScript
219 lines
7.4 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { paletteText } from '@/lib/i18n/shell'
|
|
import { stampColor } from '@/components/StampMark'
|
|
|
|
type Treffer = { href: string; primary: string; secondary?: string; classification?: number }
|
|
|
|
const SEITEN: Treffer[] = paletteText.pages.map((seite) => ({
|
|
href: seite.href,
|
|
primary: seite.label,
|
|
}))
|
|
|
|
/**
|
|
* Die Kommandopalette.
|
|
*
|
|
* Aktenzeichen eintippen und hinspringen, mehr will sie nicht. Die Treffer
|
|
* kommen serverseitig gefiltert; was hier ankommt, darf der Nutzer sehen.
|
|
*
|
|
* Eine laufende Anfrage wird bei neuer Eingabe abgebrochen — sonst überholt
|
|
* eine langsame Antwort eine schnellere und zeigt Treffer zu einem Begriff,
|
|
* der nicht mehr im Feld steht. Siehe docs/09-performance-und-ux.md.
|
|
*/
|
|
export function CommandPalette({ onClose }: { onClose: () => void }) {
|
|
const router = useRouter()
|
|
const [text, setText] = useState('')
|
|
const [treffer, setTreffer] = useState<{ label: string; eintraege: Treffer[] }[]>([])
|
|
const [zuletzt, setZuletzt] = useState<Treffer[]>([])
|
|
const [auswahl, setAuswahl] = useState(0)
|
|
const [laeuft, setLaeuft] = useState(false)
|
|
const feld = useRef<HTMLInputElement>(null)
|
|
|
|
useEffect(() => {
|
|
feld.current?.focus()
|
|
}, [])
|
|
|
|
// Die eigene Sprungliste, einmal beim Öffnen. Sie steht im leeren Feld über
|
|
// den Seiten: wer die Palette aufmacht, will meistens dorthin zurück, wo er
|
|
// eben war.
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
void (async () => {
|
|
try {
|
|
const antwort = await fetch('/api/search/quick?recent=1', { signal: controller.signal })
|
|
const daten = (await antwort.json()) as { recent?: Treffer[] }
|
|
setZuletzt(daten.recent ?? [])
|
|
} catch {
|
|
// Ohne Sprungliste tut die Palette dasselbe wie vorher.
|
|
}
|
|
})()
|
|
return () => controller.abort()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const begriff = text.trim()
|
|
const seiten = SEITEN.filter((seite) =>
|
|
seite.primary.toLowerCase().includes(begriff.toLowerCase()),
|
|
)
|
|
|
|
if (begriff.length < 2) {
|
|
setTreffer([
|
|
...(zuletzt.length > 0
|
|
? [{ label: paletteText.sections.recent!, eintraege: zuletzt }]
|
|
: []),
|
|
{ label: paletteText.sections.pages!, eintraege: SEITEN },
|
|
])
|
|
setAuswahl(0)
|
|
return
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(async () => {
|
|
setLaeuft(true)
|
|
try {
|
|
const antwort = await fetch(`/api/search/quick?q=${encodeURIComponent(begriff)}`, {
|
|
signal: controller.signal,
|
|
})
|
|
const daten = (await antwort.json()) as {
|
|
cases: { id: string; caseNumber: string; title: string; classification: number }[]
|
|
personnel: { id: string; displayName: string; rank: string | null; classification: number }[]
|
|
}
|
|
|
|
const gruppen: { label: string; eintraege: Treffer[] }[] = []
|
|
if (daten.cases.length > 0) {
|
|
gruppen.push({
|
|
label: paletteText.sections.cases!,
|
|
eintraege: daten.cases.map((akte) => ({
|
|
href: `/faelle/${akte.caseNumber}`,
|
|
primary: akte.caseNumber,
|
|
secondary: akte.title,
|
|
classification: akte.classification,
|
|
})),
|
|
})
|
|
}
|
|
if (daten.personnel.length > 0) {
|
|
gruppen.push({
|
|
label: paletteText.sections.personnel!,
|
|
eintraege: daten.personnel.map((person) => ({
|
|
href: `/personalakten/${person.id}`,
|
|
primary: person.displayName,
|
|
secondary: person.rank ?? undefined,
|
|
classification: person.classification,
|
|
})),
|
|
})
|
|
}
|
|
if (seiten.length > 0) {
|
|
gruppen.push({ label: paletteText.sections.pages!, eintraege: seiten })
|
|
}
|
|
|
|
setTreffer(gruppen)
|
|
setAuswahl(0)
|
|
} catch {
|
|
// Abgebrochen oder Netz weg: die alten Treffer bleiben stehen.
|
|
} finally {
|
|
setLaeuft(false)
|
|
}
|
|
}, 150)
|
|
|
|
return () => {
|
|
clearTimeout(timer)
|
|
controller.abort()
|
|
}
|
|
}, [text, zuletzt])
|
|
|
|
const flach = treffer.flatMap((gruppe) => gruppe.eintraege)
|
|
|
|
function springen(ziel: Treffer | undefined) {
|
|
if (!ziel) return
|
|
onClose()
|
|
router.push(ziel.href)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={paletteText.open}
|
|
className="fixed inset-0 z-50 flex items-start justify-center bg-ink-900/70 pt-[12vh]"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="w-full max-w-xl border border-line bg-raised"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<input
|
|
ref={feld}
|
|
value={text}
|
|
onChange={(event) => setText(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape') onClose()
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault()
|
|
setAuswahl((i) => Math.min(i + 1, flach.length - 1))
|
|
}
|
|
if (event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
setAuswahl((i) => Math.max(i - 1, 0))
|
|
}
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault()
|
|
springen(flach[auswahl])
|
|
}
|
|
}}
|
|
placeholder={paletteText.placeholder}
|
|
aria-label={paletteText.placeholder}
|
|
className="w-full border-b border-line bg-transparent px-4 py-3 text-sm outline-none"
|
|
/>
|
|
|
|
<div
|
|
className="max-h-[50vh] overflow-y-auto"
|
|
style={{ opacity: laeuft ? 0.5 : 1 }}
|
|
>
|
|
{flach.length === 0 ? (
|
|
<p className="px-4 py-3 text-2xs text-text-dim">{paletteText.empty}</p>
|
|
) : (
|
|
treffer.map((gruppe) => (
|
|
<div key={gruppe.label}>
|
|
<p className="px-4 pt-3 pb-1 font-condensed text-2xs text-text-dim">
|
|
{gruppe.label}
|
|
</p>
|
|
<ul>
|
|
{gruppe.eintraege.map((eintrag) => {
|
|
const index = flach.indexOf(eintrag)
|
|
return (
|
|
<li key={eintrag.href}>
|
|
<button
|
|
type="button"
|
|
onMouseEnter={() => setAuswahl(index)}
|
|
onClick={() => springen(eintrag)}
|
|
className={`flex w-full items-baseline gap-3 px-4 py-2 text-left text-sm ${
|
|
index === auswahl ? 'bg-ink-700' : ''
|
|
}`}
|
|
style={
|
|
eintrag.classification !== undefined
|
|
? {
|
|
borderLeft: `var(--spacing-edge) solid ${stampColor(eintrag.classification)}`,
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
<span className="font-mono text-xs">{eintrag.primary}</span>
|
|
{eintrag.secondary ? (
|
|
<span className="grow text-text-dim">{eintrag.secondary}</span>
|
|
) : null}
|
|
</button>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|