From dd68bc594d9a27645ffb38dde044a662f082eb89 Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 29 Sep 2026 20:13:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(drawing):=20Vorschaubilder=20und=20ein=20Z?= =?UTF-8?q?eichenbrett,=20das=20zur=20Oberfl=C3=A4che=20passt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorschaubilder entstehen sonst serverseitig aus den hochgeladenen Bytes – bei einer Zeichnung steht dort JSON, daraus wird ohne Browser kein Bild. Wer sie gerade bearbeitet, hat aber genau den Browser, der sie ohnehin zeichnet. Neu: POST /api/v1/files/{id}/thumbnail. Der Editor schickt nach dem Speichern ein PNG hinterher. Den Bytes wird dabei nicht geglaubt: Sie werden nicht abgelegt, sondern durch denselben Weg geschickt wie jedes andere Vorschaubild – dekodieren, verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die übliche Grösse; nichts anderes kann im thumbs-Bereich landen. Dazu Schreibrecht auf die Datei: Wer sie nicht ändern darf, bestimmt auch nicht, wie sie in der Liste aussieht. Das Hochladen läuft nach dem Speichern und ohne darauf zu warten. Ein fehlgeschlagenes Vorschaubild ist ein Schönheitsfehler, kein Grund, das Speichern scheitern zu lassen – und bleibt deshalb stumm. Politur am Zeichenbrett: dunkles Thema (ein weisses Brett in dunkler Oberfläche war der auffälligste Bruch), „Unsaved" als Marke statt eines Punktes hinter dem Namen, Strg+S, Dateisymbol und Name ohne Endung. „Open" und „Save to…" sind aus. Beide gehen am Browser vorbei: „Open" ersetzte den Inhalt dieser Zeichnung durch eine fremde Datei, „Save to…" schriebe eine zweite Fassung in den Download-Ordner, die niemand wiederfindet. Selbsttest 12 → 18 Prüfungen, darunter die beiden Ablehnungen (kein Bild → 400, fremder Typ → 415). Geprüft: go build/vet sauber, alle Go-Pakete grün, 261 Prüfungen der acht Selbsttest-Suiten grün, 53 Vitest-Tests, tsc sauber. Co-Authored-By: Claude Opus 5 --- TESTING.md | 51 ++++++++++++ backend/cmd/aether/main.go | 1 + backend/internal/files/handler_thumbnail.go | 72 ++++++++++++++++ frontend/src/api/client.ts | 21 +++++ frontend/src/components/DrawingEditor.tsx | 91 ++++++++++++++++++++- scripts/zeichnung-selbsttest.py | 44 ++++++++++ 6 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 backend/internal/files/handler_thumbnail.go diff --git a/TESTING.md b/TESTING.md index 66f1ee0..37de9d1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1634,6 +1634,57 @@ ist, muss sie koennen, was Dateien koennen. - [x] Zeichnung im Gruppenordner - [x] Excalifont wird vom eigenen Server ausgeliefert +### Vorschaubild und Politur (29.09.2026) + +**Vorschaubilder entstehen sonst serverseitig** aus den hochgeladenen Bytes – +bei einer Zeichnung steht dort JSON, daraus wird ohne Browser kein Bild. Wer +sie gerade bearbeitet, hat aber den Browser, der sie ohnehin zeichnet. + +Neu: `POST /api/v1/files/{id}/thumbnail`. Nach dem Speichern schickt der Editor +ein PNG hinterher. + +> **Den Bytes wird nicht geglaubt.** Sie werden nicht abgelegt, sondern durch +> denselben Weg geschickt wie jedes andere Vorschaubild – dekodieren, +> verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die +> uebliche Groesse; nichts anderes kann im `thumbs`-Bereich landen. Dazu +> Schreibrecht auf die Datei: Wer sie nicht aendern darf, bestimmt auch nicht, +> wie sie in der Liste aussieht. + +Das Hochladen laeuft **nach** dem Speichern und ohne darauf zu warten. Ein +fehlgeschlagenes Vorschaubild ist ein Schoenheitsfehler, kein Grund, das +Speichern scheitern zu lassen – und bleibt deshalb auch stumm. + +### Was am Zeichenbrett hueebscher wurde + +* **Dunkles Thema.** Ein weisses Zeichenbrett in einer dunklen Oberflaeche war + der auffaelligste Bruch. Umschalten geht weiterhin im Menue – fuer einen + Ausdruck ist Hell die bessere Wahl. +* **„Unsaved" als Marke** statt eines Punktes hinter dem Namen. Das Uebersehene + war hier die ungespeicherte Arbeit. +* **Strg+S / Cmd+S** speichert. +* Dateisymbol und Name ohne die Endung in der Kopfzeile. +* **„Open" und „Save to…" sind aus.** Beide gehen am Browser vorbei: „Open" + ersetzte den Inhalt dieser Zeichnung durch eine fremde Datei, „Save to…" + schriebe eine zweite Fassung in den Download-Ordner, die niemand + wiederfindet. In Aether liegt die Datei da, wo sie liegt. + +- [x] Vorschaubild setzen (204), Liste weiss davon, laesst sich abrufen +- [x] Was kein Bild ist -> 400. Fremder Typ -> 415. +- [ ] Zeichnung speichern -> nach kurzer Zeit zeigt die Kachel das Bild 🔎 +- [ ] Zeichnung aendern und neu speichern -> das Bild zieht nach +- [ ] Strg+S speichert, „Unsaved" verschwindet + +### Warum Excalidraw und nicht tldraw + +tldraw sieht besser aus und ist angenehmer zu bedienen – aber es ist **nicht +quelloffen**: proprietaere Lizenz, Wasserzeichen, und Produktivbetrieb nur mit +bezahlter Lizenz. Fuer eine selbst betriebene Cloud, die laufen soll, ohne dass +jemand jaehrlich etwas verlaengert, faellt es damit aus. + +Excalidraw ist MIT. draw.io waere die Alternative (Apache-2.0), zielt aber auf +Flussdiagramme und UML statt auf freies Zeichnen – und bringt eine deutlich +schwerere Oberflaeche mit. + ### Beim zweiten Oeffnen blieb es leer (29.09.2026) Eine Zeichnung liess sich anlegen und bearbeiten – **beim zweiten Oeffnen** diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index d38bcf6..7dc6e04 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -504,6 +504,7 @@ func main() { r.Get("/search", fileHandler.Search) r.Get("/files/{id}/download", fileHandler.Download) r.Get("/files/{id}/thumbnail", fileHandler.GetThumbnail) + r.Post("/files/{id}/thumbnail", fileHandler.SetThumbnail) // Erkannter Text, in Seiten zerlegt – fuer die Suche im PDF-Betrachter. r.Get("/files/{id}/text", fileHandler.TextPages) // Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im diff --git a/backend/internal/files/handler_thumbnail.go b/backend/internal/files/handler_thumbnail.go new file mode 100644 index 0000000..d1c1367 --- /dev/null +++ b/backend/internal/files/handler_thumbnail.go @@ -0,0 +1,72 @@ +package files + +import ( + "net/http" + + "github.com/rs/zerolog/log" + + "aether/internal/thumbnails" + "aether/pkg/response" +) + +// maxThumbUpload begrenzt, was als Vorschaubild hereinkommen darf. Ein +// Vorschaubild ist ein paar Kilobyte gross; zwei Megabyte sind grosszuegig und +// halten trotzdem jeden Unfug vom Dekodierer fern. +const maxThumbUpload = 2 << 20 + +// SetThumbnail nimmt ein Vorschaubild entgegen, das nur der Browser erzeugen +// kann. +// +// POST /api/v1/files/{id}/thumbnail +// +// **Warum es das ueberhaupt gibt.** Vorschaubilder entstehen sonst +// serverseitig aus den hochgeladenen Bytes. Bei einer Zeichnung steht dort +// JSON – daraus laesst sich ohne Browser kein Bild rechnen. Wer die Zeichnung +// gerade bearbeitet, hat aber genau den Browser, der sie zeichnen kann. +// +// **Und warum man den Bytes trotzdem nicht glauben darf.** Was hier ankommt, +// kommt von einem Klienten. Es wird deshalb nicht abgelegt, sondern durch +// denselben Weg geschickt wie jedes andere Vorschaubild: dekodieren, +// verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die +// uebliche Groesse – und nichts anderes kann im thumbs-Bereich landen. +func (h *Handler) SetThumbnail(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r, "id") + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid file id") + return + } + // Schreibrecht: Wer die Datei nicht aendern darf, bestimmt auch nicht, wie + // sie in der Liste aussieht. + owner, ok := h.resolveFile(w, r, id, true) + if !ok { + return + } + f, err := h.store.GetFile(r.Context(), id, owner.OwnerID) + if err != nil { + response.Error(w, http.StatusNotFound, "file not found") + return + } + + typ := r.Header.Get("Content-Type") + if !thumbnails.SupportedMIME(typ) { + response.Error(w, http.StatusUnsupportedMediaType, "thumbnail must be a PNG, JPEG, GIF or WebP image") + return + } + + // MaxBytesReader statt einer eigenen Zaehlung: Er bricht die Verbindung + // ab, statt erst alles zu lesen und dann abzulehnen. + rc := http.MaxBytesReader(w, r.Body, maxThumbUpload) + defer rc.Close() + + if err := thumbnails.Generate(r.Context(), h.storage, f.ID.String(), typ, rc); err != nil { + log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()). + Msg("Vorschaubild vom Klienten liess sich nicht verarbeiten") + response.Error(w, http.StatusBadRequest, "could not read that image") + return + } + if err := h.store.UpdateThumbnail(r.Context(), f.ID, true); err != nil { + response.Error(w, http.StatusInternalServerError, "database error") + return + } + response.NoContent(w) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 63f091d..bd5a9b4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -671,6 +671,27 @@ export const filesApi = { * Gemessen: dreimal dieselbe Datei hochgeladen ergibt dreimal dieselbe ID * mit den Fassungen 1, 2, 3. */ + /** + * Ein Vorschaubild setzen, das nur der Browser erzeugen kann. + * + * Der Server rechnet Vorschaubilder sonst selbst aus den hochgeladenen + * Bytes. Bei einer Zeichnung steht dort JSON – daraus wird ohne Browser kein + * Bild. Wer sie gerade bearbeitet, hat den Browser aber. + * + * Der Server legt die Bytes **nicht** ab, sondern schickt sie durch + * denselben Weg wie jedes andere Vorschaubild (dekodieren, verkleinern, neu + * kodieren). Was hier hochgeht, ist also ein Vorschlag, kein Befehl. + */ + setThumbnail: async (id: string, bild: Blob) => { + const res = await fetch(`/api/v1/files/${id}/thumbnail`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': bild.type || 'image/png', 'X-CSRF-Token': csrfToken }, + body: bild, + }) + if (!res.ok) throw new APIError(res.status, 'thumbnail upload failed') + }, + thumbnail: (id: string, version?: number | string) => `/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`, diff --git a/frontend/src/components/DrawingEditor.tsx b/frontend/src/components/DrawingEditor.tsx index d46f1ae..f58d19e 100644 --- a/frontend/src/components/DrawingEditor.tsx +++ b/frontend/src/components/DrawingEditor.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { Excalidraw, restore, serializeAsJSON } from '@excalidraw/excalidraw' +import { Excalidraw, exportToBlob, restore, serializeAsJSON } from '@excalidraw/excalidraw' import '@excalidraw/excalidraw/index.css' import { filesApi, type FileItem } from '../api/client' import { useBackClose } from '../hooks/useBackClose' +import { FileTypeIcon } from './icons' import { toast, fehlertext } from './Toasts' /** @@ -146,6 +147,7 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) { setGeaendert(false) onSaved?.(neu) toast.info('Drawing saved') + void vorschaubild(neu.id) } catch (err) { toast.error(fehlertext(err, 'Could not save the drawing')) } finally { @@ -153,6 +155,52 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) { } } + /** + * Ein Vorschaubild erzeugen und hochladen. + * + * Der Server kann das nicht: In der Datei steht JSON, und daraus wird ohne + * Browser kein Bild. Hier ist der Browser, der die Zeichnung ohnehin gerade + * gezeichnet hat. + * + * **Nach** dem Speichern und ohne darauf zu warten: Ein fehlgeschlagenes + * Vorschaubild ist ein Schönheitsfehler, kein Grund, das Speichern + * scheitern zu lassen. Es bleibt deshalb auch stumm – die Zeichnung selbst + * ist heil, und eine rote Meldung dafür wäre verwirrend. + */ + async function vorschaubild(id: string) { + if (!aktuell.current) return + try { + const bild = await exportToBlob({ + elements: aktuell.current.elements as never, + appState: { ...(aktuell.current.appState as object), exportBackground: true } as never, + files: aktuell.current.files as never, + mimeType: 'image/png', + exportPadding: 16, + // Der Server verkleinert ohnehin auf Kachelgröße. Grösser hochzuladen + // kostet nur Bandbreite. + getDimensions: (w: number, h: number) => { + const faktor = Math.min(1, 512 / Math.max(w, h)) + return { width: Math.round(w * faktor), height: Math.round(h * faktor), scale: faktor } + }, + }) + await filesApi.setThumbnail(id, bild) + } catch { + /* Schönheitsfehler – die Zeichnung ist gespeichert. */ + } + } + + // Strg+S / Cmd+S, wie überall sonst auch. + useEffect(() => { + function onKey(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { + e.preventDefault() + if (geaendert && !speichert) void speichern() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }) + // Die Zurück-Geste schließt das Zeichenbrett. Bei ungespeicherten Änderungen // erst fragen – `false` hält die Ansicht offen (siehe useBackClose). useBackClose({ @@ -174,22 +222,42 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) { background: 'var(--color-surface)', borderBottom: '1px solid var(--color-border)', }}> + + + + - {file.name}{geaendert ? ' •' : ''} + {file.name.replace(/\.excalidraw$/i, '')} + {/* Ein Punkt hinter dem Namen ist leicht zu übersehen – und das + Übersehene ist hier die ungespeicherte Arbeit. */} + {geaendert && ( + + Unsaved + + )} + + +