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>
259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
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>
|
||
)
|
||
}
|