Files
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

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>
)
}