import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from 'react' import { filesApi, APIError, type FileItem } from '../api/client' import { fileKind } from './filetypes' import { useBackClose } from '../hooks/useBackClose' /** * 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. */ export 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(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(): boolean { if (dirty && !confirm('Discard your changes?')) return false onClose() return true } // Zurück-Geste und Zurück-Taste schließen die Vorschau, statt die Anwendung // zu verlassen. Wichtig in der installierten App: Dort gibt es keinen // anderen Weg zurück. useBackClose({ active: true, onClose: tryClose }) return (
{/* Kopfzeile */}
e.stopPropagation()} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', flexShrink: 0, background: 'var(--color-surface)', borderBottom: '1px solid var(--color-border)', }} > {file.name} {kind === 'text' && !tooBig && text !== null && ( editing ? ( <> {saveError && ( {saveError} )} ) : ( ) )} Download
{/* Inhalt */}
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) && } {kind === 'image' && ( {file.name} )} {kind === 'pdf' && ( // Eigener Betrachter statt des eingebauten im