refactor(frontend): PdfViewer und FileBrowser aufteilen, Tests dazu

FileBrowser 1177 -> 712 Zeilen, PdfViewer 1193 -> 848. Herausgeloest
wurde, was fuer sich zusammenhaengt:

  hooks/useUploads            Warteschlange, Ordnerbaeume, Ablage
  components/files/           Werkzeugleiste, Dialoge, Menues, Reihenfolge
  components/pdf/             Werkzeugleiste, Zoomstufen, Seitenmasse, Suche
  src/format.ts               formatBytes -- lag in einer *Seite*

Dabei gefundene und behobene Fehler:

* Ordner-Upload haengte Fortschritt und Fehler an die falsche Zeile,
  sobald in einem Ordner Dateien *nach* einem Unterordner lagen: Die
  Zeilen standen in Fundreihenfolge, hochgeladen wurde nach Ordnern
  gruppiert, und ein mitlaufender Zaehler verband beides falsch.
* formatBytes gab es zweimal, zeichengleich (Dateibrowser + Admin).
* Beide Sortierungen liefen bei *jedem* Bild -- der Rahmen beim Ziehen
  ist eines je Mausbewegung. Jetzt gemerkt.
* Die Suche im PDF las bei jedem Tastendruck den Text aller Seiten neu.
  Jetzt 160 ms Wartezeit; "Searching..." bleibt auch bei einem Fehler
  nicht mehr stehen.
* Seitenmasse werden in Buendeln statt streng nacheinander geholt.

Pruefinfrastruktur: vitest stand auf 'jsdom', ohne dass jsdom je
installiert war -- "npm test" brach ab, bevor es etwas ausfuehrte. Jetzt
'node' und 32 Tests fuer die herausgeloesten reinen Funktionen. Das
Abbild baut nur noch mit gruenen Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-08 22:23:21 +02:00
1 parent 8e27c703d2
commit ea90f43e77
28 files changed
+1904 -1260

No files matched your search

+248
View File
@@ -0,0 +1,248 @@
import { useCallback, useRef, useState, type DragEvent } from 'react'
import { foldersApi, uploadWithProgress, APIError } from '../api/client'
import type { UploadItem } from '../components/UploadQueue'
import { useInternalDrag } from './useInternalDrag'
/**
* Alles, was mit dem Hineinlegen von Dateien zu tun hat: die Warteschlange,
* das Hochladen einzelner Dateien, das Nachbauen eines ganzen Ordnerbaums und
* die Ablage-Gesten am Fenster.
*
* Stand bis hierher mitten im Dateibrowser – rund 180 Zeilen zwischen
* Kontextmenüs und Dialogen, die mit dem Anzeigen eines Ordners nichts zu tun
* haben. Sie hängen untereinander eng zusammen (die Warteschlange gehört zum
* Fortschritt, der Fortschritt zum Hochladen) und mit dem Rest der Seite fast
* gar nicht: heraus kommen eine Kennung, ein Zustand und ein „fertig".
*
* **Verhalten unverändert.** Verschoben, nicht umgeschrieben – bis auf die
* Stellen, die unten eigens vermerkt sind.
*/
/**
* Eine Kennung für die Warteschlange, ohne den Server zu fragen.
*
* `crypto.randomUUID` gibt es nur in einem sicheren Kontext (https oder
* localhost). Hinter einer weitergeleiteten Portnummer – VS Code, ein Tunnel –
* fehlt es, und ohne den Rückfall bräche das Hochladen dort mit einem
* Tippfehler-artigen Fehler ab.
*/
function uid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
/** Wie viele Dateien gleichzeitig unterwegs sind. */
const concurrency = 3
interface Options {
/** Ordner, in den gelegt wird – undefined heißt Wurzelverzeichnis. */
folderId?: string
/** Läuft, wenn alles durch ist: Liste neu holen, Speicherbalken nachziehen. */
onFinished: () => Promise<void> | void
}
export function useUploads({ folderId, onFinished }: Options) {
const [items, setItems] = useState<UploadItem[]>([])
const [dragging, setDragging] = useState(false)
const isInternalDrag = useInternalDrag()
/**
* Der Zielordner in einem Ref – **nicht** in der Abhängigkeitsliste.
*
* Sonst zeigten die Ablage-Gesten bei jedem Ordnerwechsel auf neue
* Funktionen, und ein Zug, der über dem Fenster begonnen hat, liefe gegen
* einen abgemeldeten Zuhörer. Gelesen wird er erst, wenn wirklich abgelegt
* wird – dann ist er aktuell.
*/
const folderRef = useRef(folderId)
folderRef.current = folderId
const dismiss = useCallback((id: string) => {
setItems(u => u.filter(x => x.id !== id))
}, [])
/** Fortschritt, Erfolg und Fehler einer einzelnen Zeile. */
const patch = useCallback((id: string, change: Partial<UploadItem>) => {
setItems(u => u.map(x => (x.id === id ? { ...x, ...change } : x)))
}, [])
/** Eine Datei hochladen und ihre Zeile nachführen. Wirft nicht. */
const send = useCallback(async (id: string, file: File, target: string | undefined) => {
try {
await uploadWithProgress(file, target, ({ percent }) => patch(id, { progress: percent }))
patch(id, { progress: 100, status: 'done' })
} catch (err) {
patch(id, { status: 'error', error: err instanceof APIError ? err.message : 'Upload failed' })
}
}, [patch])
/** Einzelne Dateien – aus dem Auswahlfenster oder per Ablage. */
const uploadFiles = useCallback(async (fileList: File[]) => {
const target = folderRef.current
const queued = fileList.map(file => ({ id: uid(), file }))
setItems(u => [...u, ...queued.map(({ id, file }) => ({
id, name: file.name, progress: 0, status: 'uploading' as const,
}))])
// Drei gleichzeitig: Ein Dutzend Belege auf einmal geht so spürbar
// schneller, ohne die Verbindung zu verstopfen.
const queue = [...queued]
await Promise.all(
Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
while (queue.length > 0) {
const item = queue.shift()!
await send(item.id, item.file, target)
}
}),
)
await onFinished()
}, [send, onFinished])
/**
* Ein abgelegter Ordner: Der Baum wird unter dem aktuellen Ordner
* nachgebaut, dann werden die Dateien hineingelegt.
*/
const uploadFolder = useCallback(async (entries: { file: File; relativePath: string }[]) => {
if (!entries.length) return
const target = folderRef.current
// "a/b" → Ordner-ID. Ohne den Merkzettel legte jede Datei ihren
// Elternordner erneut an.
const known = new Map<string, string>()
async function ensureFolder(relPath: string): Promise<string | undefined> {
if (!relPath) return target
const cached = known.get(relPath)
if (cached) return cached
let parent = target
let acc = ''
for (const part of relPath.split('/')) {
acc = acc ? `${acc}/${part}` : part
const hit = known.get(acc)
if (hit) { parent = hit; continue }
const f = await foldersApi.create(part, parent)
known.set(acc, f.id)
parent = f.id
}
return parent
}
// Nach Ordner gruppieren: So wird jeder Zweig einmal angelegt, nicht
// einmal je Datei darin.
const grouped = new Map<string, { file: File; relativePath: string }[]>()
for (const entry of entries) {
const parts = entry.relativePath.split('/')
parts.pop()
const key = parts.join('/')
const list = grouped.get(key)
if (list) list.push(entry)
else grouped.set(key, [entry])
}
const queued = entries.map(e => ({ id: uid(), entry: e }))
setItems(u => [...u, ...queued.map(({ id, entry }) => ({
id, name: entry.relativePath, progress: 0, status: 'uploading' as const,
}))])
// Die Zeilen stehen in der Reihenfolge von `entries`, hochgeladen wird
// nach Ordnern gruppiert. Der Zeiger sucht deshalb die Zeile zur Datei,
// statt mitzuzählen – vorher lief ein Zähler mit, und sobald die
// Gruppierung die Reihenfolge änderte, hing der Fortschritt an der
// falschen Zeile.
const idOf = new Map(queued.map(q => [q.entry, q.id]))
for (const [relPath, files] of grouped) {
const into = await ensureFolder(relPath).catch(() => target)
for (const entry of files) {
const id = idOf.get(entry)
if (id) await send(id, entry.file, into)
}
}
await onFinished()
}, [send, onFinished])
/**
* Ablage am Fenster.
*
* Was in dieser Seite losgezogen ist, ist **kein** Upload: Ein vom Browser
* begonnener Bild-Zug bringt `Files` mit, ohne dass jemand eine Datei
* hineinlegen wollte. Siehe hooks/useInternalDrag.
*/
const onDrop = useCallback(async (e: DragEvent) => {
e.preventDefault()
setDragging(false)
const t = e.dataTransfer.types
if (t.includes('aether-file-id') || t.includes('aether-folder-id')) return
if (isInternalDrag()) return
// Synchron einsammeln: Die Liste gilt nur während des Ereignisses selbst,
// danach ist sie leer.
const entries: FileSystemEntry[] = []
const list = e.dataTransfer.items
for (let i = 0; i < (list?.length ?? 0); i++) {
const entry = list[i].webkitGetAsEntry?.()
if (entry) entries.push(entry)
}
const files = Array.from(e.dataTransfer.files)
if (entries.some(en => en.isDirectory)) {
const gathered = await gatherFromEntries(entries)
if (gathered.length) await uploadFolder(gathered)
} else if (files.length) {
await uploadFiles(files)
}
}, [isInternalDrag, uploadFiles, uploadFolder])
const onDragOver = useCallback((e: DragEvent) => {
e.preventDefault()
const t = e.dataTransfer.types
if (t.includes('Files') && !t.includes('aether-file-id') && !t.includes('aether-folder-id')
&& !isInternalDrag()) setDragging(true)
}, [isInternalDrag])
const onDragLeave = useCallback((e: DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false)
}, [])
return {
items, dismiss, dragging, uploadFiles,
/** Direkt an das äußerste Element der Seite hängen. */
dropProps: { onDragOver, onDragLeave, onDrop },
}
}
/**
* Einen abgelegten Ordnerbaum in Paare aus Datei und Pfad auflösen.
*
* `readEntries` liefert in Häppchen – solange nachfragen, bis nichts mehr
* kommt. Wer den ersten Aufruf für die ganze Liste hält, verliert bei einem
* Ordner mit mehr als hundert Einträgen den Rest, und zwar stumm.
*/
async function gatherFromEntries(entries: FileSystemEntry[]) {
const out: { file: File; relativePath: string }[] = []
async function walk(entry: FileSystemEntry, prefix: string): Promise<void> {
if (entry.isFile) {
const fe = entry as FileSystemFileEntry
out.push({
file: await new Promise<File>((res, rej) => fe.file(res, rej)),
relativePath: prefix + entry.name,
})
return
}
if (!entry.isDirectory) return
const reader = (entry as FileSystemDirectoryEntry).createReader()
const children: FileSystemEntry[] = []
for (;;) {
const batch: FileSystemEntry[] = await new Promise((res, rej) => reader.readEntries(res, rej))
if (batch.length === 0) break
children.push(...batch)
}
for (const child of children) await walk(child, prefix + entry.name + '/')
}
for (const entry of entries) await walk(entry, '')
return out
}