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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
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>
|
|
)
|
|
}
|