diff --git a/TESTING.md b/TESTING.md index f44a0a0..57723f3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1472,6 +1472,34 @@ für einen einmaligen Bedarf). - [ ] Auf der produktiven Instanz mit echten Handy-Fotos gegenprüfen – **neu hochgeladene**, nicht die bestehenden +## Vorschaubilder: Zwischenspeicher, Sofortanzeige, Flackern (25.08.2026) + +**Gleicher Dateiname = gleiche Datei.** Gemessen: dreimal dieselbe Datei +hochgeladen ergibt dreimal **dieselbe ID** mit den Fassungen 1, 2, 3. Die +Adresse des Vorschaubildes war damit unverändert, und es liegt einen Tag im +Browser (`max-age=86400`) – die Kachel zeigte bis zu 24 Stunden lang das alte +Bild. Die Adresse trägt jetzt `?v=`. + +**Vorschaubild ohne Neuladen.** Der Server erzeugt es im Hintergrund, die +Antwort auf den Upload ist längst da. Die Kachel fragt jetzt bis zu fünfmal +nach (0,4 s bis 5 s Abstand), aber nur bei Bildern, die jünger als zwei +Minuten sind – sonst fragte jede dauerhaft fehlgeschlagene Datei bei jedem +Öffnen des Ordners erneut. + +Gemessen: Bild über das Datei-Feld hochgeladen, Vorschaubild **608 ms** später +sichtbar, `performance` zählt genau **eine** Navigation – also kein Neuladen. + +**Flackern am Kachelrand.** Beim Überfahren wurde die Kachel um einen Pixel +angehoben. Steht der Zeiger genau auf der Unterkante, rutscht sie unter ihm +weg → `mouseleave` → sie fällt zurück → `mouseenter`, im Kreis. Das Anheben +ist raus, der Rahmen in der Primärfarbe zeigt dasselbe an, ohne die Kachel zu +bewegen. Nachher gemessen: Zeiger 1,5 s auf der Unterkante gehalten, **0** +Ereignisse, `transform: none`. + +- [ ] Das Flackern selbst konnte ich **nicht** nachstellen: Ein + programmgesteuerter Zeiger prüft die Überfahrung nicht bei jedem Bild neu, + eine echte Maus schon. Bitte mit der Maus gegenprüfen. + ## Automatisiert geprüft (22.08.2026) Diese Punkte hat Claude per API/`curl` gegen Wegwerf-Konten durchgespielt; sie diff --git a/backend/internal/imageutil/orientation.go b/backend/internal/imageutil/orientation.go index f73c7e3..1b1d390 100644 --- a/backend/internal/imageutil/orientation.go +++ b/backend/internal/imageutil/orientation.go @@ -8,9 +8,12 @@ import ( ) // HeadBytes ist die Menge, die zum Finden der Drehung gelesen werden muss. -// Der EXIF-Block steht unmittelbar hinter dem Dateianfang; 64 KB decken auch -// Kameras ab, die ein Vorschaubild mit hineinlegen. -const HeadBytes = 64 << 10 +// +// 256 KB, nicht 64: Ein APP1-Segment darf selbst schon 65 535 Byte groß sein – +// Telefone legen ihr eigenes Vorschaubild dort hinein –, und davor können +// weitere Segmente stehen. Bei 64 KB reichte der Puffer im Grenzfall nicht +// einmal für das eine Segment. +const HeadBytes = 256 << 10 /* Orientation liest die EXIF-Drehung (1–8) aus dem Anfang einer JPEG-Datei. @@ -43,15 +46,27 @@ func Orientation(head []byte) int { return 1 } size := int(binary.BigEndian.Uint16(head[i+2 : i+4])) - if size < 2 || i+2+size > len(head) { + if size < 2 { return 1 } - if marker == 0xE1 { - if o, ok := orientationInAPP1(head[i+4 : i+2+size]); ok { + end := i + 2 + size + truncated := end > len(head) + if truncated { + // Der Puffer endet mitten im Segment. Trotzdem nachsehen: Das + // Verzeichnis mit der Drehung steht ganz am Anfang des Blocks, + // das eingebettete Vorschaubild dahinter. Aufzugeben hieße, wegen + // eines Bildes im Bild die Drehung zu verlieren. + end = len(head) + } + if marker == 0xE1 && i+4 < end { + if o, ok := orientationInAPP1(head[i+4 : end]); ok { return o } } - i += 2 + size + if truncated { + return 1 + } + i = end } return 1 } diff --git a/backend/internal/imageutil/orientation_test.go b/backend/internal/imageutil/orientation_test.go index 24e26e5..545cc0a 100644 --- a/backend/internal/imageutil/orientation_test.go +++ b/backend/internal/imageutil/orientation_test.go @@ -146,3 +146,19 @@ func TestApplyLaesstEinsUnberuehrt(t *testing.T) { t.Error("unbekannte Drehung hat kopiert") } } + +// Telefone legen ihr eigenes Vorschaubild in den EXIF-Block; der wird dadurch +// zehntausende Byte groß. Reicht der gelesene Anfang nicht bis zum Ende des +// Blocks, muss die Drehung trotzdem gefunden werden – sie steht vorn. +func TestOrientationBeiAbgeschnittenemBlock(t *testing.T) { + voll := jpegMitDrehung(t, 6, false) + // Absichtlich hinter dem Verzeichnis abschneiden, aber vor dem Dateiende. + for _, laenge := range []int{40, 60, 80} { + if laenge > len(voll) { + continue + } + if got := Orientation(voll[:laenge]); got != 6 { + t.Errorf("bei %d Byte Kopf: Drehung %d statt 6", laenge, got) + } + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 34b64be..95d8f96 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -589,7 +589,20 @@ export const filesApi = { * Server liefert das nur für eine feste Liste harmloser Typen inline aus. */ inline: (id: string) => `/api/v1/files/${id}/download?inline=1`, - thumbnail: (id: string) => `/api/v1/files/${id}/thumbnail`, + /** + * Adresse des Vorschaubildes. + * + * `version` gehört mit hinein, auch wenn die Adresse ohne sie funktioniert: + * Lädt man eine Datei unter **gleichem Namen** erneut hoch, entsteht keine + * neue Datei, sondern eine neue Fassung – gleiche ID, gleiche Adresse. Das + * Vorschaubild darf einen Tag im Browser liegen (`max-age=86400`), also + * zeigte die Kachel danach bis zu 24 Stunden lang das **alte** Bild. + * + * Gemessen: dreimal dieselbe Datei hochgeladen ergibt dreimal dieselbe ID + * mit den Fassungen 1, 2, 3. + */ + thumbnail: (id: string, version?: number | string) => + `/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`, /** * Der erkannte Text einer Datei, in Seiten zerlegt. diff --git a/frontend/src/components/FileCover.tsx b/frontend/src/components/FileCover.tsx index ca3a50c..58b74f6 100644 --- a/frontend/src/components/FileCover.tsx +++ b/frontend/src/components/FileCover.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { filesApi, type FileItem, type FolderItem } from '../api/client' import { FileTypeIcon, FolderGlyph } from './icons' import { fileKind, kindColors, extensionOf } from './filetypes' @@ -12,13 +12,61 @@ import { fileKind, kindColors, extensionOf } from './filetypes' * passendes Symbol und die Endung als Aufkleber: Man erkennt den Typ, bevor man * den Namen gelesen hat. */ +/** + * Wie lange nach dem Hochladen auf ein Vorschaubild gewartet wird. + * + * Der Server erzeugt es im Hintergrund; die Antwort auf den Upload ist längst + * da, wenn das Bild fertig wird. Ohne dieses Nachfassen blieb die Kachel bis + * zum nächsten Neuladen der Seite bei ihrem Ersatzsymbol. + * + * Die Altersgrenze ist wichtig: Ohne sie fragte **jede** Kachel ohne + * Vorschaubild bei jedem Öffnen des Ordners mehrfach nach – auch die, deren + * Erzeugung vor Wochen endgültig fehlgeschlagen ist. Bei fünfzig solchen + * Dateien wären das zweihundertfünfzig Anfragen für nichts. + */ +const waitForThumbMs = 2 * 60 * 1000 +/** Abstände zwischen den Versuchen. Zusammen gut zehn Sekunden – länger + * braucht kein Vorschaubild, und wer so lange wartet, lädt ohnehin neu. */ +const thumbRetries = [400, 900, 1600, 3000, 5000] + export default function FileCover({ file }: { file: FileItem }) { const [failed, setFailed] = useState(false) + /** Adresse des Vorschaubildes, sobald es eines gibt. */ + const [lateSrc, setLateSrc] = useState(null) + + const fresh = Date.now() - new Date(file.created_at).getTime() < waitForThumbMs + const couldHaveThumb = file.mime_type.startsWith('image/') + + useEffect(() => { + if (file.has_thumbnail || lateSrc || !couldHaveThumb || !fresh) return + let alive = true + let attempt = 0 + let timer: ReturnType + + function probe() { + // Mit Zeitstempel, damit weder Browser noch Service Worker die vorherige + // 404-Antwort wiederverwenden. Genau diese Adresse wird danach auch + // angezeigt – die blanke hätte dasselbe Problem. + const url = `${filesApi.thumbnail(file.id, file.version)}&t=${Date.now()}` + const img = new Image() + img.onload = () => { if (alive) setLateSrc(url) } + img.onerror = () => { + if (!alive || attempt >= thumbRetries.length) return + timer = setTimeout(probe, thumbRetries[attempt++]) + } + img.src = url + } + timer = setTimeout(probe, thumbRetries[attempt++]) + return () => { alive = false; clearTimeout(timer) } + }, [file.id, file.has_thumbnail, couldHaveThumb, fresh, lateSrc]) + const kind = fileKind(file.mime_type, file.name) const color = kindColors[kind] const ext = extensionOf(file.name).toUpperCase() - if (file.has_thumbnail && !failed) { + const thumbSrc = file.has_thumbnail ? filesApi.thumbnail(file.id, file.version) : lateSrc + + if (thumbSrc && !failed) { return ( <> setFailed(true)} style={{ diff --git a/frontend/src/components/FileViews.tsx b/frontend/src/components/FileViews.tsx index 7a71101..e3fccf4 100644 --- a/frontend/src/components/FileViews.tsx +++ b/frontend/src/components/FileViews.tsx @@ -156,15 +156,15 @@ export default function GridView({ folders, files, onFolderOpen, onFolderMenu, o background: 'var(--color-surface)', border: `1px solid ${isOver ? 'var(--color-primary)' : 'var(--color-border)'}`, borderRadius: 12, overflow: 'hidden', cursor: 'grab', - transition: 'border-color 0.15s, transform 0.1s, background 0.1s', + transition: 'border-color 0.15s, transform 0.1s, background 0.1s', // transform: nur noch beim Ziehen (isOver) display: 'flex', flexDirection: 'column', transform: isOver ? 'scale(1.03)' : 'none', boxShadow: isOver ? '0 0 0 3px rgba(var(--color-primary-rgb), 0.2)' : 'none', }} {...openGesture(coarse, () => onFolderOpen(folder))} onContextMenu={e => onFolderMenu(e, folder)} - onMouseEnter={e => { if (!isOver) { e.currentTarget.style.borderColor = 'var(--color-primary)'; e.currentTarget.style.transform = 'translateY(-1px)' } }} - onMouseLeave={e => { if (!isOver) { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.transform = 'none' } }} + onMouseEnter={e => { if (!isOver) e.currentTarget.style.borderColor = 'var(--color-primary)' }} + onMouseLeave={e => { if (!isOver) e.currentTarget.style.borderColor = 'var(--color-border)' }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); onFolderDragOver(folder.id) }} onDragLeave={e => { e.stopPropagation(); onFolderDragOver(null) }} onDrop={e => onFolderDrop(e, folder)} @@ -242,19 +242,24 @@ export default function GridView({ folders, files, onFolderOpen, onFolderMenu, o boxShadow: selected?.has(file.id) ? '0 0 0 2px var(--color-primary)' : file.id === highlightId ? highlightRing : undefined, - transition: 'border-color 0.15s, transform 0.1s, box-shadow 0.3s', + transition: 'border-color 0.15s, box-shadow 0.3s', display: 'flex', flexDirection: 'column', }} onContextMenu={e => onFileMenu(e, file)} + /* **Kein Anheben beim Überfahren.** Ein `translateY(-1px)` verschiebt + die Kachel *unter* dem Zeiger weg: Steht er genau auf der unteren + Kante, rutscht sie hoch, der Zeiger liegt daneben, es feuert + `mouseleave`, sie fällt zurück – und das im Kreis, viele Male je + Sekunde. Sichtbar als Flackern von Kachel und Mauszeiger. + Der Rahmen in der Primärfarbe zeigt dasselbe an, ohne die Kachel + zu bewegen. */ onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--color-primary)' - e.currentTarget.style.transform = 'translateY(-1px)' const pick = e.currentTarget.querySelector('.aether-pick') if (pick) pick.style.opacity = '1' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)' - e.currentTarget.style.transform = 'none' const pick = e.currentTarget.querySelector('.aether-pick') if (pick && !selected?.has(file.id) && (selected?.size ?? 0) === 0) pick.style.opacity = '0' }} diff --git a/frontend/src/components/SearchBox.tsx b/frontend/src/components/SearchBox.tsx index e54953e..275160d 100644 --- a/frontend/src/components/SearchBox.tsx +++ b/frontend/src/components/SearchBox.tsx @@ -186,7 +186,7 @@ export default function SearchBox({ onOpenFolder, onOpenFile }: Props) { {hit.kind === 'folder' ? ( ) : hit.has_thumbnail ? ( - ) : ( diff --git a/frontend/src/pages/TagsPage.tsx b/frontend/src/pages/TagsPage.tsx index 47f1b12..09746d0 100644 --- a/frontend/src/pages/TagsPage.tsx +++ b/frontend/src/pages/TagsPage.tsx @@ -150,7 +150,7 @@ export default function TagsPage({ tagId, correspondentId, onOpenFolder }: Props einer Liste reicht die kleine Fassung; die großen Cover gehören zu den Kacheln. */} {f.has_thumbnail ? ( - ) : ( diff --git a/frontend/src/pages/TrashPage.tsx b/frontend/src/pages/TrashPage.tsx index f2d4b30..2fb81db 100644 --- a/frontend/src/pages/TrashPage.tsx +++ b/frontend/src/pages/TrashPage.tsx @@ -107,7 +107,7 @@ export default function TrashPage({ onUserChanged }: Props) { {file.has_thumbnail ? ( { e.currentTarget.style.display = 'none' }}