feat(office): Dokumente am Telefon als PDF ansehen statt bearbeiten

Am Finger öffnet ein Office-Dokument jetzt das gewandelte PDF im eigenen
Betrachter; "Edit" daneben führt trotzdem in den Editor. Mit Maus ändert
sich nichts.

Der Web-Editor ist eine vollständige Textverarbeitung – zwischen seinen
Leisten bleibt auf 400 Pixeln vom Dokument ein Rechteck. Collabora und
OnlyOffice verhalten sich gleich; deshalb kein besserer Bedienmodus,
sondern ein anderes Werkzeug. Gewandelt wird von dem Server, der auch
bearbeitet: Sonst sähe man unterwegs ein LibreOffice-Layout eines
Dokuments, für das man OnlyOffice gewählt hat.

Je Dokument gibt es GENAU EIN PDF, und das ist keine Frage des
Aufräumens, sondern des Schlüssels: previews/<id>.pdf trägt die Fassung
nicht im Namen, eine neue Fassung überschreibt. files.preview_version
sagt, ob das Abgelegte noch passt.

Sicherheit: Die Adresse, die OnlyOffice zurückgibt, wird von uns
abgerufen – der Wähler des Transports lässt nur OFFICE_ALLOWED_HOSTS zu,
zusätzlich eine ausdrückliche Prüfung mit Test. JWT-Signatur (HS256) für
die eigene API des Dokumentenservers. Fremde Kennung 404, ohne Anmeldung
401. Das Ergebnis muss mit %PDF- beginnen, sonst landete eine
Fehlerseite im Zwischenspeicher.

Gemessen gegen einen echten OnlyOffice-Server im Docker-Netz:
  erster Abruf 1,05 s -> zweiter 0,009 s
  erneut hochgeladen -> Fassung 2, preview_version 2, weiterhin 1 Datei
  GC: herrenloses PDF von vorgestern weg, echtes bleibt
  endgültig gelöscht -> PDF weg; Papierkorb -> PDF bleibt
  ohne Anmeldung 401, fremde Kennung 404, Nicht-Office 415

Dazu im Admin-Bereich neu: Platzbedarf der abgeleiteten Daten
(Vorschaubilder, PDFs, Profilbilder). Sie zählen gegen kein Kontingent –
ein Ordner, der wächst, ohne dass ihn jemand sieht, ist eine stille
Überraschung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-31 23:30:49 +02:00
1 parent 84ae570305
commit 9c5cda75d7
22 files changed
+959 -12

No files matched your search

+29 -3
View File
@@ -7,7 +7,7 @@ import {
import Breadcrumb from '../components/Breadcrumb'
import SearchBox from '../components/SearchBox'
import PageHeader from '../components/PageHeader'
import GridView, { ListView } from '../components/FileViews'
import GridView, { ListView, useCoarsePointer } from '../components/FileViews'
import FilePreview, { canPreview } from '../components/FilePreview'
import ContextMenu, { type MenuItem } from '../components/ContextMenu'
import UploadQueue, { type UploadItem } from '../components/UploadQueue'
@@ -149,9 +149,14 @@ export default function FileBrowser({
const [viewMode, setViewMode] = useState<ViewMode>(() => loadPref('aether:view', 'grid'))
const [sort, setSort] = useState<Sort>(() => loadPref('aether:sort', 'name-asc'))
const [preview, setPreview] = useState<FileItem | null>(null)
/** Office-Dokument als PDF – am Telefon der übliche Weg hinein. */
const [officePreview, setOfficePreview] = useState<FileItem | null>(null)
/** Datei, die gerade im Office-Server bearbeitet wird. */
const [editing, setEditing] = useState<FileItem | null>(null)
const { officeEnabled } = useAppSettings()
/** Finger oder Maus? Entscheidet, ob ein Office-Dokument als PDF oder im
* Editor aufgeht. */
const touchDevice = useCoarsePointer()
/** Datei, zu der gerade aus der Suche gesprungen wurde – siehe openHitFile.
* Die Markierung verschwindet von selbst; ein Rahmen, der bleibt, sähe
@@ -310,8 +315,20 @@ export default function FileBrowser({
* ansieht; heruntergeladen wird, was der Browser nicht zeigen kann.
*/
function openFile(file: FileItem) {
if (officeEnabled && isOfficeFile(file.name)) setEditing(file)
else if (canPreview(file)) setPreview(file)
if (officeEnabled && isOfficeFile(file.name)) {
// Am Telefon zuerst ansehen, nicht bearbeiten. Der Editor ist dort eine
// vollständige Textverarbeitung; zwischen Menüband, Formatleisten und
// Eigenschaftenbereich bleibt vom Dokument ein Rechteck. Gilt für
// Collabora wie für OnlyOffice – beide ausprobiert, dasselbe Bild.
//
// Die Entscheidung fällt am **Zeigegerät**, nicht an der Fensterbreite:
// Ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus
// und eine Tastatur, und dort will man bearbeiten.
if (touchDevice) setOfficePreview(file)
else setEditing(file)
return
}
if (canPreview(file)) setPreview(file)
else downloadFile(file)
}
@@ -984,6 +1001,15 @@ export default function FileBrowser({
/>
)}
{officePreview && (
<FilePreview
file={officePreview}
asOfficePdf
onEdit={() => { const f = officePreview; setOfficePreview(null); setEditing(f) }}
onClose={() => setOfficePreview(null)}
/>
)}
{preview && (
<FilePreview
file={preview}
+48
View File
@@ -147,6 +147,14 @@ function IndexingStatus() {
function StorageCleanup() {
const [running, setRunning] = useState(false)
const [result, setResult] = useState<string | null>(null)
/** Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen.
* Steht hier, weil es sonst nirgends steht: Diese Dateien zählen gegen kein
* Kontingent, ihr Ordner könnte also unbemerkt wachsen. */
const [derived, setDerived] = useState<{ prefix: string; files: number; bytes: number }[]>([])
useEffect(() => {
adminApi.derivedStorage().then(setDerived).catch(() => {})
}, [])
async function run() {
setRunning(true)
@@ -166,6 +174,8 @@ function StorageCleanup() {
}
return (
<>
<DerivedStorage items={derived} />
<div style={{
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
padding: '12px 14px', borderRadius: 10,
@@ -192,6 +202,44 @@ function StorageCleanup() {
{running ? 'Running…' : 'Run now'}
</button>
</div>
</>
)
}
/** Namen, die etwas sagen – „thumbs" steht nirgends auf der Oberfläche. */
const derivedLabels: Record<string, string> = {
thumbs: 'Thumbnails',
previews: 'Office documents as PDF',
avatars: 'Profile pictures',
}
function DerivedStorage({ items }: { items: { prefix: string; files: number; bytes: number }[] }) {
const total = items.reduce((n, i) => n + i.bytes, 0)
if (items.length === 0) return null
return (
<div style={{
marginBottom: 16, padding: '12px 14px', borderRadius: 10,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
}}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Generated files — {formatBytes(total)}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', margin: '2px 0 8px' }}>
Created by the server, not uploaded by anyone. They count against no quota
and are rebuilt when needed — deleting them is safe.
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 20px' }}>
{items.map(i => (
<span key={i.prefix} style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{derivedLabels[i.prefix] ?? i.prefix}:{' '}
<span style={{ color: 'var(--color-text)' }}>
{formatBytes(i.bytes)}
</span>{' '}
({i.files})
</span>
))}
</div>
</div>
)
}