fix(thumbnails): Fassung in der Adresse, Sofortanzeige, kein Flackern

Gleicher Dateiname erzeugt keine neue Datei, sondern eine neue Fassung -
gleiche ID, gleiche Vorschaubild-Adresse. Die darf einen Tag im Browser
liegen, also zeigte die Kachel nach dem erneuten Hochladen bis zu 24
Stunden das alte Bild. Das erklaert, warum eine Drehung erst nach
mehreren Versuchen "ankam". Die Adresse traegt jetzt ?v=<Fassung>.

Vorschaubilder entstehen im Hintergrund; die Kachel fragt jetzt bis zu
fuenfmal nach, statt bis zum naechsten Neuladen ihr Ersatzsymbol zu
zeigen. Nur bei Bildern juenger als zwei Minuten - sonst fragte jede
dauerhaft fehlgeschlagene Datei bei jedem Oeffnen erneut. Gemessen: 608
ms bis zur Anzeige, ohne Neuladen.

Das Anheben der Kachel beim Ueberfahren ist raus: Steht der Zeiger auf
der Unterkante, rutscht sie unter ihm weg, mouseleave, sie faellt
zurueck, mouseenter - im Kreis, sichtbar als Flackern.

Dazu liest die EXIF-Auswertung jetzt 256 statt 64 KB und gibt bei einem
abgeschnittenen Block nicht mehr auf: Die Drehung steht vorn, das
eingebettete Vorschaubild dahinter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-25 14:29:09 +02:00
1 parent 5b3658640e
commit 5ea66ea884
9 files changed
+145 -20

No files matched your search

+14 -1
View File
@@ -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.
+51 -3
View File
@@ -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<string | null>(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<typeof setTimeout>
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 (
<>
<img
@@ -27,7 +75,7 @@ export default function FileCover({ file }: { file: FileItem }) {
Ablagefläche, lädt die Cloud ihr eigenes Bild erneut hoch.
Die Kachel selbst bleibt ziehbar; sie verschiebt die Datei. */
draggable={false}
src={filesApi.thumbnail(file.id)}
src={thumbSrc}
alt=""
onError={() => setFailed(true)}
style={{
+11 -6
View File
@@ -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<HTMLElement>('.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<HTMLElement>('.aether-pick')
if (pick && !selected?.has(file.id) && (selected?.size ?? 0) === 0) pick.style.opacity = '0'
}}
+1 -1
View File
@@ -186,7 +186,7 @@ export default function SearchBox({ onOpenFolder, onOpenFile }: Props) {
{hit.kind === 'folder' ? (
<FolderGlyph size={24} />
) : hit.has_thumbnail ? (
<img src={filesApi.thumbnail(hit.id)} alt="" draggable={false} style={{
<img src={filesApi.thumbnail(hit.id, hit.updated_at)} alt="" draggable={false} style={{
width: 18, height: 18, borderRadius: 3, objectFit: 'cover',
}} />
) : (
+1 -1
View File
@@ -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 ? (
<img src={filesApi.thumbnail(f.id)} alt="" draggable={false} style={{
<img src={filesApi.thumbnail(f.id, f.updated_at)} alt="" draggable={false} style={{
width: 22, height: 22, borderRadius: 4, objectFit: 'cover', flexShrink: 0,
}} />
) : (
+1 -1
View File
@@ -107,7 +107,7 @@ export default function TrashPage({ onUserChanged }: Props) {
{file.has_thumbnail ? (
<img
draggable={false}
src={filesApi.thumbnail(file.id)}
src={filesApi.thumbnail(file.id, file.version)}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={e => { e.currentTarget.style.display = 'none' }}