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