Files
Aether/frontend/src/components/FilePreview.tsx
T
marcandClaude Opus 5 944b2c63e2 feat(office): mobileEdit am Telefon, PDF-Umweg entfällt
Ein Office-Dokument öffnete am Telefon als gewandeltes PDF, weil OnlyOffices
Oberfläche auf 400 Pixeln nichts vom Dokument übrig lässt. Die Begründung war
falsch: Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an, und
OnlyOffice/Euro-Office führen neben `edit` auch `mobileEdit` – dieselbe
Adresse mit `?mobile=1`, dahinter eine für kleine Schirme gebaute Oberfläche.
`EditURL` kannte nur `edit` und `view`; die Handy-Fassung lag die ganze Zeit
in der Tabelle und wurde nie abgerufen.

Entschieden wird am Zeigegerät, nicht an der Fensterbreite – ein schmales
Fenster auf dem Schreibtisch ist immer noch eine Maus.

Mit dem Anlass fällt die Notlösung: preview.go (229 Zeilen), convert.go (271)
samt Test, files.preview_version (Migration 000026), die Sonderabfrage im GC,
die Route und dreizehn Fundstellen im Frontend. convert.go hatte keinen
anderen Aufrufer – die gesamte Umwandlung nach PDF existierte allein für
diese Ansicht. Wer ein PDF braucht, exportiert es im Editor. Die Dateien
unter previews/ sammelt der GC ein.

Zwei Fehler, die dabei sichtbar wurden:

fix(history): „Edit" schloss die Datei, statt den Editor zu öffnen. Beim
Wechsel räumt React erst die alte Ansicht ab; der Aufräumer von useBackClose
ruft history.back(), das verzögert wirkt und deshalb den Eintrag verbrauchte,
den der Editor gerade angelegt hatte. Ein Zähler für selbst ausgelöste
Schritte verschluckt genau diese popstate.

fix(filetypes): `.txt` ging gar nicht auf. Es stand in officeExtensions und
lief damit in den Office-Zweig statt in den eingebauten Text-Editor – am
Telefon in die PDF-Umwandlung, die daran scheiterte. `md`, `log` und `rst`
standen nie drin; das war die Asymmetrie.

Dazu Euro-Office als Compose-Profil (Abbild 6,99 GB, nachgemessen – die
Ankündigung nennt 5) und die Android-App als Idee.

Geprüft: go build/vet sauber, tsc sauber, 37 Vitest-Tests, alle 234
Prüfungen der sieben Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:36:31 +02:00

388 lines
15 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 { 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<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(): 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 (
<div
onClick={tryClose}
/* Marke für die Wischgeste: Aus einer bildschirmfüllenden Ansicht heraus
soll der Wisch vom Rand nicht die Seitenleiste holen – die läge
dahinter, und hier wischt man durch Seiten. */
data-fullscreen=""
style={{
position: 'fixed', inset: 'var(--honeybar-h) 0 0 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}
/* Nicht ziehbar: Sonst nimmt Chrome das angezeigte Bild als Datei
und die darunterliegende Ablagefläche lädt es erneut hoch. */
draggable={false}
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>
)
}