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 + + )} + + +