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:
1 parent
84ae570305
commit
9c5cda75d7
22 files changed
+959
-12
No files matched your search
@@ -601,6 +601,17 @@ export const filesApi = {
|
||||
* Gemessen: dreimal dieselbe Datei hochgeladen ergibt dreimal dieselbe ID
|
||||
* mit den Fassungen 1, 2, 3.
|
||||
*/
|
||||
/**
|
||||
* Ein Office-Dokument als PDF – zum Ansehen, ohne den Editor.
|
||||
*
|
||||
* Die Fassung steht in der Adresse: Nach einer Bearbeitung ändert sie sich,
|
||||
* und der Browser holt das PDF von selbst neu, statt ein Dokument von
|
||||
* gestern aus seinem Zwischenspeicher zu zeigen. Derselbe Kniff wie beim
|
||||
* Vorschaubild.
|
||||
*/
|
||||
officePreview: (id: string, version?: number) =>
|
||||
`/api/v1/files/${id}/preview.pdf${version ? `?v=${version}` : ''}`,
|
||||
|
||||
thumbnail: (id: string, version?: number | string) =>
|
||||
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
|
||||
|
||||
@@ -838,6 +849,10 @@ export const adminApi = {
|
||||
return request<{ entries: AuditEntry[]; total: number }>('GET', `/admin/audit${suffix}`)
|
||||
},
|
||||
listAuditActions: () => request<string[]>('GET', '/admin/audit/actions'),
|
||||
/** Platzbedarf der abgeleiteten Daten – zählt gegen kein Kontingent. */
|
||||
derivedStorage: () =>
|
||||
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
|
||||
|
||||
runCleanup: () => request<{
|
||||
scanned: number; orphans: number; deleted: number
|
||||
freed_bytes: number; skipped_young: number
|
||||
|
||||
@@ -19,6 +19,17 @@ interface Props {
|
||||
onClose: () => void
|
||||
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
|
||||
onSaved?: () => void
|
||||
/**
|
||||
* Ein Office-Dokument als PDF zeigen statt im Editor.
|
||||
*
|
||||
* Am Telefon der Normalfall: Der Editor ist dort eine vollständige
|
||||
* Textverarbeitung, und vom Dokument bleibt zwischen seinen Leisten ein
|
||||
* Rechteck übrig. Zum Nachsehen – und das ist unterwegs fast immer der
|
||||
* Anlass – ist ein PDF im eigenen Betrachter das bessere Werkzeug.
|
||||
*/
|
||||
asOfficePdf?: boolean
|
||||
/** „Trotzdem bearbeiten" – nur gesetzt, wenn es einen Editor gibt. */
|
||||
onEdit?: () => 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
|
||||
@@ -90,10 +101,12 @@ 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)
|
||||
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm, asOfficePdf, onEdit }: Props) {
|
||||
// Ein Office-Dokument ist für den Browser sonst „nichts anzeigbares" – als
|
||||
// gewandeltes PDF ist es eins.
|
||||
const kind = asOfficePdf ? 'pdf' : kindOf(file.mime_type, file.name)
|
||||
const narrow = useNarrow()
|
||||
const src = filesApi.inline(file.id)
|
||||
const src = asOfficePdf ? filesApi.officePreview(file.id, file.version) : filesApi.inline(file.id)
|
||||
const [text, setText] = useState<string | null>(null)
|
||||
const [tooBig, setTooBig] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -205,6 +218,23 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se
|
||||
{file.name}
|
||||
</span>
|
||||
|
||||
{/* Der Weg in den Editor bleibt offen – eine Ansicht ohne Ausweg wäre
|
||||
eine Bevormundung. Er steht bewusst *neben* dem Namen und nicht
|
||||
als Hauptknopf: Am Telefon ist Nachsehen der Anlass, Bearbeiten
|
||||
die Ausnahme. */}
|
||||
{asOfficePdf && onEdit && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12, flexShrink: 0,
|
||||
border: '1px solid var(--color-border)', background: 'transparent',
|
||||
color: 'var(--color-text)', cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
|
||||
{kind === 'text' && !tooBig && text !== null && (
|
||||
editing ? (
|
||||
<>
|
||||
|
||||
@@ -34,7 +34,7 @@ function FolderIcon({ size = 24, group = false }: { size?: number; group?: boole
|
||||
* nicht zweimal auf denselben Pixel tippt. Der Doppelklick ist eine
|
||||
* Mausgeste; am Finger ist er nie angekommen.
|
||||
*/
|
||||
function useCoarsePointer(): boolean {
|
||||
export function useCoarsePointer(): boolean {
|
||||
const [coarse, setCoarse] = useState(
|
||||
() => typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches,
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user