Files
Aether/frontend/src/components/SearchBox.tsx
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:36 +02:00

259 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import { searchApi, filesApi, type SearchHit } from '../api/client'
import { FileTypeIcon, FolderGlyph } from './icons'
/**
* Zerlegt die Fundstelle an den Marken aus ts_headline.
*
* Bewusst kein dangerouslySetInnerHTML: Der Text kommt aus einem hochgeladenen
* Dokument, das jeder freigegebene Nutzer beisteuern kann. Als HTML eingesetzt
* wäre ein <script> darin eine Lücke; als Textknoten ist es einfach Text.
* Deshalb markiert die Datenbank mit [[hl]] statt mit <b>.
*/
function highlight(snippet: string) {
return snippet.split(/\[\[\/?hl\]\]/).map((part, i) =>
// Jedes zweite Stück lag zwischen den Marken – das ist der Treffer.
i % 2 === 1
? <mark key={i} style={{
background: 'transparent', color: 'var(--color-primary)', fontWeight: 600,
}}>{part}</mark>
: <span key={i}>{part}</span>
)
}
interface Props {
/** Treffer geöffnet: Ordner betreten bzw. zum Ordner der Datei springen. */
onOpenFolder: (folderId: string | null, folderName: string) => void
/** Zweiter Wert ist das Suchwort – der PDF-Betrachter springt damit zur
* Fundstelle im Dokument. */
onOpenFile: (hit: SearchHit, term: string) => void
}
/** Erst ab zwei Zeichen suchen – ein einzelnes trifft fast alles. Der Server
* hält dieselbe Grenze ein, das hier spart nur die Anfrage. */
const minChars = 2
/** Wartezeit nach dem letzten Tastendruck. Kurz genug, dass es beim Tippen
* mitläuft; lang genug, dass nicht jeder Buchstabe eine Anfrage auslöst. */
const debounceMs = 250
export default function SearchBox({ onOpenFolder, onOpenFile }: Props) {
const [query, setQuery] = useState('')
const [hits, setHits] = useState<SearchHit[]>([])
const [truncated, setTruncated] = useState(false)
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [active, setActive] = useState(0)
const boxRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Jede Anfrage bricht die vorige ab. Ohne das kann eine langsame frühere
// Antwort eine neuere überschreiben – man tippt weiter und sieht plötzlich
// wieder die Treffer von vor zwei Buchstaben.
useEffect(() => {
const q = query.trim()
if (q.length < minChars) {
setHits([]); setTruncated(false); setBusy(false)
return
}
const ctrl = new AbortController()
setBusy(true)
const timer = setTimeout(async () => {
try {
const res = await searchApi.query(q, ctrl.signal)
setHits(res.hits ?? [])
setTruncated(res.truncated ?? false)
setActive(0)
setOpen(true)
} catch {
// Abgebrochen oder fehlgeschlagen – ein leeres Feld ist hier die
// ehrlichste Anzeige, eine Fehlermeldung beim Tippen nur im Weg.
} finally {
setBusy(false)
}
}, debounceMs)
return () => { clearTimeout(timer); ctrl.abort() }
}, [query])
// Klick daneben schließt die Liste.
useEffect(() => {
function onDown(e: MouseEvent) {
if (!boxRef.current?.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [])
function choose(hit: SearchHit) {
const term = query.trim()
setOpen(false)
setQuery('')
if (hit.kind === 'folder') onOpenFolder(hit.id, hit.name)
else onOpenFile(hit, term)
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') { setOpen(false); inputRef.current?.blur(); return }
if (!open || hits.length === 0) return
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(i => (i + 1) % hits.length) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(i => (i - 1 + hits.length) % hits.length) }
else if (e.key === 'Enter') { e.preventDefault(); choose(hits[active]) }
}
const showPanel = open && query.trim().length >= minChars
return (
<div ref={boxRef} style={{ position: 'relative', flex: '1 1 180px', minWidth: 0, maxWidth: 220 }}>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
style={{ position: 'absolute', left: 9, color: 'var(--color-text-muted)', pointerEvents: 'none' }}>
<circle cx="11" cy="11" r="7" /><line x1="16.5" y1="16.5" x2="21" y2="21" />
</svg>
<input
ref={inputRef}
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => { if (hits.length) setOpen(true) }}
onKeyDown={onKeyDown}
placeholder="Search files and folders"
style={{
// Auf breiten Schirmen 220, auf schmalen so breit wie der Platz:
// Die feste Breite war der Grund, warum daneben nichts mehr passte.
width: 220, maxWidth: '100%', minWidth: 0,
padding: '6px 10px 6px 28px',
borderRadius: 8,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)',
color: 'var(--color-text)',
fontSize: 13, outline: 'none',
}}
/>
{busy && (
<div style={{
position: 'absolute', right: 8, width: 12, height: 12,
border: '2px solid var(--color-primary)', borderTopColor: 'transparent',
borderRadius: '50%', animation: 'spin 0.7s linear infinite',
}} />
)}
</div>
{showPanel && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', left: 0,
// Breiter als die 380 von vorher: Eine Fundstelle braucht Platz,
// sonst bringen auch zwei Zeilen kaum mehr Wörter unter.
// Auf dem Handy passen keine 460px – dort so breit wie der Schirm
// hergibt, damit die Fundstelle nicht auf drei Wörter schrumpft.
width: 460, maxWidth: 'min(85vw, calc(100vw - 32px))', maxHeight: 420, overflowY: 'auto',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 10, boxShadow: '0 12px 32px rgba(0,0,0,0.45)',
zIndex: 60, padding: 4,
}}>
{hits.length === 0 && !busy && (
<div style={{ padding: '14px 12px', fontSize: 13, color: 'var(--color-text-muted)' }}>
Nothing found for “{query.trim()}”
</div>
)}
{hits.map((hit, i) => (
<button
key={hit.kind + hit.id}
onClick={() => choose(hit)}
onMouseEnter={() => setActive(i)}
style={{
// Oben ausrichten statt mittig: Ein Treffer mit Fundstelle ist
// drei Zeilen hoch, einer ohne zwei – mittig zentriert wandert
// das Symbol dann von Zeile zu Zeile.
display: 'flex', alignItems: 'flex-start', gap: 10, width: '100%',
padding: '8px 9px', borderRadius: 7, border: 'none', textAlign: 'left',
background: i === active ? 'var(--color-surface-2)' : 'transparent',
cursor: 'pointer',
}}
>
{/* Fester Platz fürs Symbol, egal wie breit es ist. Der Ordner
ist 24 Pixel breit, die Dateisymbole 18 – ohne diese Spalte
begännen Ordnernamen weiter rechts als Dateinamen, und in
einer gemischten Trefferliste steht der Text dann in zwei
Spalten. Gemessen: 107 gegen 95. */}
<span style={{
width: 24, flexShrink: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
// Auf Höhe der ersten Zeile, nicht der Mitte des Blocks.
height: 20, marginTop: 1,
}}>
{hit.kind === 'folder' ? (
<FolderGlyph size={24} />
) : hit.has_thumbnail ? (
<img src={filesApi.thumbnail(hit.id)} alt="" style={{
width: 18, height: 18, borderRadius: 3, objectFit: 'cover',
}} />
) : (
<FileTypeIcon mime={hit.mime_type ?? ''} name={hit.name} size={18} />
)}
</span>
<span style={{ minWidth: 0, flex: 1 }}>
<span style={{
display: 'block', fontSize: 13, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{hit.name}
</span>
{/* Ohne den Ordner darunter sind drei Dateien namens
„Rechnung.pdf" nicht auseinanderzuhalten. */}
<span style={{
display: 'block', fontSize: 11, color: 'var(--color-text-muted)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
in {hit.folder_name || 'Home'}
</span>
{/* Bei einem Inhaltstreffer passt der Name gerade *nicht* –
ohne die Fundstelle sähe „scan_0042.pdf" wie ein Fehler der
Suche aus.
Zwei Zeilen statt einer abgeschnittenen: Eine Fundstelle,
die nach zehn Wörtern in „…" endet, zeigt oft gerade noch
nicht, worum es geht – und das Suchwort selbst kann darin
fehlen. Gekappt wird jetzt am Ende der zweiten Zeile
(line-clamp), nicht am Ende der ersten.
Die Linie links trennt die Fundstelle vom Ordnernamen
darüber: Beides ist kleine, gedämpfte Schrift, und ohne
Trennung liest es sich als ein Block. */}
{hit.matched_content && hit.snippet && (
<span style={{
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
lineClamp: 2,
overflow: 'hidden',
fontSize: 11, lineHeight: 1.45,
color: 'var(--color-text-muted)',
marginTop: 3, paddingLeft: 8,
// In der Farbe des Treffers, nur blass: --color-border war
// gegen die hervorgehobene Zeile praktisch unsichtbar
// (gemessen rgb(51,65,85) auf fast demselben Grund). So ist
// die Linie auf beiden Untergründen zu sehen und gehört
// sichtbar zum hervorgehobenen Wort.
borderLeft: '2px solid rgba(var(--color-primary-rgb), 0.4)',
}}>
{highlight(hit.snippet)}
</span>
)}
</span>
</button>
))}
{truncated && (
<div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--color-text-muted)' }}>
Only the first {hits.length} matches are shown – narrow the search.
</div>
)}
</div>
)}
</div>
)
}