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:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,373 @@
|
||||
import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { filesApi, APIError, type FileItem } from '../api/client'
|
||||
import { fileKind } from './filetypes'
|
||||
|
||||
/**
|
||||
* Nachgeladen, nicht mitgeliefert: PDF.js wiegt rund eine halbe Megabyte im
|
||||
* Hauptbündel und noch einmal 1,3 MB im Arbeiter. Wer nur Bilder ansieht,
|
||||
* soll das nicht bei jedem Besuch herunterladen – geholt wird es erst beim
|
||||
* ersten PDF und liegt danach im Cache des Browsers.
|
||||
*/
|
||||
const PdfViewer = lazy(() => import('./PdfViewer'))
|
||||
|
||||
interface Props {
|
||||
file: FileItem
|
||||
/** Blättern innerhalb des Ordners – null, wenn es nichts zu blättern gibt. */
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
onClose: () => void
|
||||
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
|
||||
onSaved?: () => void
|
||||
/**
|
||||
* Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt
|
||||
* damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem
|
||||
* es überhaupt ein eigener Betrachter geworden ist.
|
||||
*/
|
||||
searchTerm?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Was der Browser selbst anzeigen kann, zeigt er: Bilder, PDF, Text, Video,
|
||||
* Ton. Der Server liefert diese Typen mit `inline`, alles andere bleibt ein
|
||||
* Download – die Liste steht in internal/files/handler.go und ist eine
|
||||
* Positivliste, damit eine hochgeladene HTML-Datei nie im Ursprung der
|
||||
* Anwendung läuft.
|
||||
*/
|
||||
type Kind = 'image' | 'pdf' | 'text' | 'video' | 'audio' | 'none'
|
||||
|
||||
/**
|
||||
* Der Name zählt mit, nicht nur der MIME-Typ: Ältere Dateien liegen als
|
||||
* application/octet-stream in der Datenbank, weil der Server bis zum
|
||||
* 23.08.2026 die Endungen .mp3 und .mp4 nicht kannte. Ohne den Namen ließe
|
||||
* sich eine vorhandene MP3 also nie abspielen.
|
||||
*/
|
||||
function kindOf(mime: string, name = ''): Kind {
|
||||
const m = (mime || '').toLowerCase()
|
||||
// SVG bleibt draußen: Der Server liefert sie nie inline aus, weil eine
|
||||
// SVG-Datei Skripte enthalten darf.
|
||||
if (m.includes('svg')) return 'none'
|
||||
|
||||
switch (fileKind(mime, name)) {
|
||||
case 'image': return 'image'
|
||||
case 'pdf': return 'pdf'
|
||||
case 'video': return 'video'
|
||||
case 'audio': return 'audio'
|
||||
case 'text':
|
||||
case 'code': return 'text'
|
||||
case 'doc':
|
||||
// Nur echte Textdateien, kein .docx – das ist ein ZIP-Archiv.
|
||||
return m.startsWith('text/') || /\.(txt|md|log)$/i.test(name) ? 'text' : 'none'
|
||||
default: return 'none'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unterhalb dieser Breite ist der Bildschirm ein Telefon.
|
||||
*
|
||||
* Dort zählt jeder Pixel: Ein PDF, das nach Rand und Blätterpfeilen nur noch
|
||||
* die halbe Breite hat, wird bei 51 % dargestellt – lesbar ist das nicht mehr.
|
||||
*/
|
||||
const narrowQuery = '(max-width: 640px)'
|
||||
|
||||
function useNarrow(): boolean {
|
||||
return useSyncExternalStore(
|
||||
cb => {
|
||||
const m = window.matchMedia(narrowQuery)
|
||||
m.addEventListener('change', cb)
|
||||
return () => m.removeEventListener('change', cb)
|
||||
},
|
||||
() => window.matchMedia(narrowQuery).matches,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
|
||||
/** Grenze fürs Nachladen von Text. Darüber wird nicht angezeigt, sondern
|
||||
* heruntergeladen – ein 200-MB-Protokoll bringt jeden Browser ins Stocken. */
|
||||
const maxTextBytes = 2 * 1024 * 1024
|
||||
|
||||
export function canPreview(file: FileItem): boolean {
|
||||
return kindOf(file.mime_type, file.name) !== 'none'
|
||||
}
|
||||
|
||||
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm }: Props) {
|
||||
const kind = kindOf(file.mime_type, file.name)
|
||||
const narrow = useNarrow()
|
||||
const src = filesApi.inline(file.id)
|
||||
const [text, setText] = useState<string | null>(null)
|
||||
const [tooBig, setTooBig] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState('')
|
||||
const dirty = editing && draft !== (text ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
// Im Textfeld gehören die Pfeiltasten dem Schreiben, nicht dem Blättern.
|
||||
if (editing) {
|
||||
if (e.key === 'Escape') tryClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') { onClose(); return }
|
||||
// Die Pfeiltasten blättern zwischen Dateien – aber nicht, während
|
||||
// jemand in einem Feld schreibt. Der PDF-Betrachter hat eine eigene
|
||||
// Suche; dort setzte ein Pfeil sonst mitten im Wort die nächste Datei
|
||||
// vor.
|
||||
const el = document.activeElement
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return
|
||||
if (e.key === 'ArrowLeft') onPrev?.()
|
||||
else if (e.key === 'ArrowRight') onNext?.()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onClose, onPrev, onNext, editing, dirty])
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== 'text') return
|
||||
setText(null); setTooBig(false)
|
||||
if (file.size > maxTextBytes) { setTooBig(true); return }
|
||||
setEditing(false); setSaveError('')
|
||||
let alive = true
|
||||
fetch(src, { credentials: 'include' })
|
||||
.then(r => r.text())
|
||||
.then(t => { if (alive) { setText(t); setDraft(t) } })
|
||||
.catch(() => { if (alive) setText('(could not load this file)') })
|
||||
return () => { alive = false }
|
||||
}, [src, kind, file.size])
|
||||
|
||||
/**
|
||||
* Gespeichert wird über denselben Weg wie ein Upload: gleicher Name, gleicher
|
||||
* Ordner. Der Server ersetzt die Datei damit und legt – wenn Versionierung
|
||||
* für sie eingeschaltet ist – die alte Fassung in die Historie. Ein eigener
|
||||
* Endpunkt zum Schreiben bräuchte dieselben Prüfungen noch einmal.
|
||||
*/
|
||||
async function save() {
|
||||
setSaving(true); setSaveError('')
|
||||
try {
|
||||
const blob = new File([draft], file.name, { type: file.mime_type || 'text/plain' })
|
||||
await filesApi.upload(blob, file.folder_id ?? undefined)
|
||||
setText(draft)
|
||||
setEditing(false)
|
||||
onSaved?.()
|
||||
} catch (err) {
|
||||
// Nur-Lese-Zugriff auf eine geliehene Datei ist der häufigste Fall –
|
||||
// deshalb hier ausgeschrieben statt "403".
|
||||
setSaveError(
|
||||
err instanceof APIError && err.status === 403
|
||||
? 'You only have read access to this file.'
|
||||
: err instanceof Error ? err.message : 'Could not save',
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function tryClose() {
|
||||
if (dirty && !confirm('Discard your changes?')) return
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={tryClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 100,
|
||||
background: 'rgba(0,0,0,0.82)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Kopfzeile */}
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 14px', flexShrink: 0,
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
flex: 1, minWidth: 0, fontSize: 14, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{file.name}
|
||||
</span>
|
||||
|
||||
{kind === 'text' && !tooBig && text !== null && (
|
||||
editing ? (
|
||||
<>
|
||||
{saveError && (
|
||||
<span style={{ fontSize: 12, color: 'var(--color-danger)' }}>{saveError}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setDraft(text ?? ''); setEditing(false); setSaveError('') }}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12,
|
||||
border: '1px solid var(--color-border)', background: 'transparent',
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving || !dirty}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
|
||||
border: 'none', background: 'var(--color-primary)', color: '#fff',
|
||||
cursor: saving || !dirty ? 'default' : 'pointer',
|
||||
opacity: saving || !dirty ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12,
|
||||
border: '1px solid var(--color-border)', background: 'transparent',
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<a
|
||||
href={filesApi.download(file.id)}
|
||||
download={file.name}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
|
||||
background: 'var(--color-primary)', color: '#fff', textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
onClick={tryClose}
|
||||
title="Close (Esc)"
|
||||
style={{
|
||||
padding: 6, borderRadius: 8, border: 'none', background: 'none',
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex',
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inhalt */}
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1, minHeight: 0, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
// Ein PDF bekommt fast den ganzen Platz: Es bringt seinen eigenen
|
||||
// weißen Rand mit, ein zweiter darum herum verschenkt nur Breite.
|
||||
padding: kind === 'pdf' ? (narrow ? 2 : 8) : 16,
|
||||
}}
|
||||
>
|
||||
{/* Am Telefon blättert man nicht per Pfeil zur nächsten Datei, während
|
||||
man ein Dokument liest – dort lägen die Knöpfe mitten auf dem Text
|
||||
und würden beim Wischen getroffen. */}
|
||||
{onPrev && !(kind === 'pdf' && narrow) && <PagerButton dir="prev" onClick={onPrev} />}
|
||||
|
||||
{kind === 'image' && (
|
||||
<img src={src} alt={file.name}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
||||
)}
|
||||
|
||||
{kind === 'pdf' && (
|
||||
// Eigener Betrachter statt des eingebauten im <iframe>: Chrome auf
|
||||
// Android zeigte darin gar nichts an, und ein Suchtreffer ließ sich
|
||||
// ihm nicht mitteilen. Siehe PdfViewer.tsx.
|
||||
<Suspense fallback={
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</span>
|
||||
}>
|
||||
<PdfViewer url={src} fileId={file.id} searchTerm={searchTerm} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{kind === 'video' && (
|
||||
<video src={src} controls autoPlay
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: 8 }} />
|
||||
)}
|
||||
|
||||
{kind === 'audio' && (
|
||||
<audio src={src} controls style={{ width: 'min(520px, 90%)' }} />
|
||||
)}
|
||||
|
||||
{/* Text bekommt die Form eines Blattes statt der ganzen Fläche: über
|
||||
die volle Breite gelesen springt das Auge beim Zeilenwechsel, und
|
||||
eine kurze Notiz auf einem riesigen Feld wirkt erschlagend. */}
|
||||
{kind === 'text' && (
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 820, height: '100%', maxHeight: 680,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
borderRadius: 10, overflow: 'hidden',
|
||||
border: `1px solid ${editing ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
background: 'var(--color-surface)',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.35)',
|
||||
}}>
|
||||
{editing ? (
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1, resize: 'none', width: '100%',
|
||||
padding: '18px 20px', border: 'none',
|
||||
background: 'transparent', color: 'var(--color-text)',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
fontSize: 13, lineHeight: 1.65, outline: 'none',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<pre style={{
|
||||
flex: 1, margin: 0, overflow: 'auto',
|
||||
padding: '18px 20px',
|
||||
color: 'var(--color-text)',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
fontSize: 13, lineHeight: 1.65,
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
}}>
|
||||
{tooBig
|
||||
? 'This file is too large to show here – use Download.'
|
||||
: text ?? 'Loading…'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onNext && !(kind === 'pdf' && narrow) && <PagerButton dir="next" onClick={onNext} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PagerButton({ dir, onClick }: { dir: 'prev' | 'next'; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={dir === 'prev' ? 'Previous (←)' : 'Next (→)'}
|
||||
style={{
|
||||
position: 'absolute', [dir === 'prev' ? 'left' : 'right']: 12,
|
||||
top: '50%', transform: 'translateY(-50%)',
|
||||
width: 40, height: 40, borderRadius: '50%',
|
||||
border: 'none', background: 'rgba(0,0,0,0.5)', color: '#fff',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points={dir === 'prev' ? '15 18 9 12 15 6' : '9 18 15 12 9 6'} />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user