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>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+258
View File
@@ -0,0 +1,258 @@
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>
)
}