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:
1 parent
5b3658640e
commit
5ea66ea884
9 files changed
+145
-20
No files matched your search
+28
@@ -1472,6 +1472,34 @@ für einen einmaligen Bedarf).
|
|||||||
- [ ] Auf der produktiven Instanz mit echten Handy-Fotos gegenprüfen –
|
- [ ] Auf der produktiven Instanz mit echten Handy-Fotos gegenprüfen –
|
||||||
**neu hochgeladene**, nicht die bestehenden
|
**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=<Fassung>`.
|
||||||
|
|
||||||
|
**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)
|
## Automatisiert geprüft (22.08.2026)
|
||||||
|
|
||||||
Diese Punkte hat Claude per API/`curl` gegen Wegwerf-Konten durchgespielt; sie
|
Diese Punkte hat Claude per API/`curl` gegen Wegwerf-Konten durchgespielt; sie
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// HeadBytes ist die Menge, die zum Finden der Drehung gelesen werden muss.
|
// 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.
|
// 256 KB, nicht 64: Ein APP1-Segment darf selbst schon 65 535 Byte groß sein –
|
||||||
const HeadBytes = 64 << 10
|
// 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.
|
Orientation liest die EXIF-Drehung (1–8) aus dem Anfang einer JPEG-Datei.
|
||||||
@@ -43,15 +46,27 @@ func Orientation(head []byte) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
size := int(binary.BigEndian.Uint16(head[i+2 : i+4]))
|
size := int(binary.BigEndian.Uint16(head[i+2 : i+4]))
|
||||||
if size < 2 || i+2+size > len(head) {
|
if size < 2 {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
if marker == 0xE1 {
|
end := i + 2 + size
|
||||||
if o, ok := orientationInAPP1(head[i+4 : i+2+size]); ok {
|
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
|
return o
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
i += 2 + size
|
if truncated {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
i = end
|
||||||
}
|
}
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,3 +146,19 @@ func TestApplyLaesstEinsUnberuehrt(t *testing.T) {
|
|||||||
t.Error("unbekannte Drehung hat kopiert")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -589,7 +589,20 @@ export const filesApi = {
|
|||||||
* Server liefert das nur für eine feste Liste harmloser Typen inline aus. */
|
* Server liefert das nur für eine feste Liste harmloser Typen inline aus. */
|
||||||
inline: (id: string) => `/api/v1/files/${id}/download?inline=1`,
|
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.
|
* Der erkannte Text einer Datei, in Seiten zerlegt.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { filesApi, type FileItem, type FolderItem } from '../api/client'
|
import { filesApi, type FileItem, type FolderItem } from '../api/client'
|
||||||
import { FileTypeIcon, FolderGlyph } from './icons'
|
import { FileTypeIcon, FolderGlyph } from './icons'
|
||||||
import { fileKind, kindColors, extensionOf } from './filetypes'
|
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
|
* passendes Symbol und die Endung als Aufkleber: Man erkennt den Typ, bevor man
|
||||||
* den Namen gelesen hat.
|
* 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 }) {
|
export default function FileCover({ file }: { file: FileItem }) {
|
||||||
const [failed, setFailed] = useState(false)
|
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 kind = fileKind(file.mime_type, file.name)
|
||||||
const color = kindColors[kind]
|
const color = kindColors[kind]
|
||||||
const ext = extensionOf(file.name).toUpperCase()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
@@ -27,7 +75,7 @@ export default function FileCover({ file }: { file: FileItem }) {
|
|||||||
Ablagefläche, lädt die Cloud ihr eigenes Bild erneut hoch.
|
Ablagefläche, lädt die Cloud ihr eigenes Bild erneut hoch.
|
||||||
Die Kachel selbst bleibt ziehbar; sie verschiebt die Datei. */
|
Die Kachel selbst bleibt ziehbar; sie verschiebt die Datei. */
|
||||||
draggable={false}
|
draggable={false}
|
||||||
src={filesApi.thumbnail(file.id)}
|
src={thumbSrc}
|
||||||
alt=""
|
alt=""
|
||||||
onError={() => setFailed(true)}
|
onError={() => setFailed(true)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -156,15 +156,15 @@ export default function GridView({ folders, files, onFolderOpen, onFolderMenu, o
|
|||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
border: `1px solid ${isOver ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
border: `1px solid ${isOver ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||||
borderRadius: 12, overflow: 'hidden', cursor: 'grab',
|
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',
|
display: 'flex', flexDirection: 'column',
|
||||||
transform: isOver ? 'scale(1.03)' : 'none',
|
transform: isOver ? 'scale(1.03)' : 'none',
|
||||||
boxShadow: isOver ? '0 0 0 3px rgba(var(--color-primary-rgb), 0.2)' : 'none',
|
boxShadow: isOver ? '0 0 0 3px rgba(var(--color-primary-rgb), 0.2)' : 'none',
|
||||||
}}
|
}}
|
||||||
{...openGesture(coarse, () => onFolderOpen(folder))}
|
{...openGesture(coarse, () => onFolderOpen(folder))}
|
||||||
onContextMenu={e => onFolderMenu(e, folder)}
|
onContextMenu={e => onFolderMenu(e, folder)}
|
||||||
onMouseEnter={e => { if (!isOver) { e.currentTarget.style.borderColor = 'var(--color-primary)'; e.currentTarget.style.transform = 'translateY(-1px)' } }}
|
onMouseEnter={e => { if (!isOver) e.currentTarget.style.borderColor = 'var(--color-primary)' }}
|
||||||
onMouseLeave={e => { if (!isOver) { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.transform = 'none' } }}
|
onMouseLeave={e => { if (!isOver) e.currentTarget.style.borderColor = 'var(--color-border)' }}
|
||||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); onFolderDragOver(folder.id) }}
|
onDragOver={e => { e.preventDefault(); e.stopPropagation(); onFolderDragOver(folder.id) }}
|
||||||
onDragLeave={e => { e.stopPropagation(); onFolderDragOver(null) }}
|
onDragLeave={e => { e.stopPropagation(); onFolderDragOver(null) }}
|
||||||
onDrop={e => onFolderDrop(e, folder)}
|
onDrop={e => onFolderDrop(e, folder)}
|
||||||
@@ -242,19 +242,24 @@ export default function GridView({ folders, files, onFolderOpen, onFolderMenu, o
|
|||||||
boxShadow: selected?.has(file.id)
|
boxShadow: selected?.has(file.id)
|
||||||
? '0 0 0 2px var(--color-primary)'
|
? '0 0 0 2px var(--color-primary)'
|
||||||
: file.id === highlightId ? highlightRing : undefined,
|
: 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',
|
display: 'flex', flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
onContextMenu={e => onFileMenu(e, file)}
|
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 => {
|
onMouseEnter={e => {
|
||||||
e.currentTarget.style.borderColor = 'var(--color-primary)'
|
e.currentTarget.style.borderColor = 'var(--color-primary)'
|
||||||
e.currentTarget.style.transform = 'translateY(-1px)'
|
|
||||||
const pick = e.currentTarget.querySelector<HTMLElement>('.aether-pick')
|
const pick = e.currentTarget.querySelector<HTMLElement>('.aether-pick')
|
||||||
if (pick) pick.style.opacity = '1'
|
if (pick) pick.style.opacity = '1'
|
||||||
}}
|
}}
|
||||||
onMouseLeave={e => {
|
onMouseLeave={e => {
|
||||||
e.currentTarget.style.borderColor = 'var(--color-border)'
|
e.currentTarget.style.borderColor = 'var(--color-border)'
|
||||||
e.currentTarget.style.transform = 'none'
|
|
||||||
const pick = e.currentTarget.querySelector<HTMLElement>('.aether-pick')
|
const pick = e.currentTarget.querySelector<HTMLElement>('.aether-pick')
|
||||||
if (pick && !selected?.has(file.id) && (selected?.size ?? 0) === 0) pick.style.opacity = '0'
|
if (pick && !selected?.has(file.id) && (selected?.size ?? 0) === 0) pick.style.opacity = '0'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function SearchBox({ onOpenFolder, onOpenFile }: Props) {
|
|||||||
{hit.kind === 'folder' ? (
|
{hit.kind === 'folder' ? (
|
||||||
<FolderGlyph size={24} />
|
<FolderGlyph size={24} />
|
||||||
) : hit.has_thumbnail ? (
|
) : 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',
|
width: 18, height: 18, borderRadius: 3, objectFit: 'cover',
|
||||||
}} />
|
}} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export default function TagsPage({ tagId, correspondentId, onOpenFolder }: Props
|
|||||||
einer Liste reicht die kleine Fassung; die großen Cover
|
einer Liste reicht die kleine Fassung; die großen Cover
|
||||||
gehören zu den Kacheln. */}
|
gehören zu den Kacheln. */}
|
||||||
{f.has_thumbnail ? (
|
{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,
|
width: 22, height: 22, borderRadius: 4, objectFit: 'cover', flexShrink: 0,
|
||||||
}} />
|
}} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function TrashPage({ onUserChanged }: Props) {
|
|||||||
{file.has_thumbnail ? (
|
{file.has_thumbnail ? (
|
||||||
<img
|
<img
|
||||||
draggable={false}
|
draggable={false}
|
||||||
src={filesApi.thumbnail(file.id)}
|
src={filesApi.thumbnail(file.id, file.version)}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
onError={e => { e.currentTarget.style.display = 'none' }}
|
onError={e => { e.currentTarget.style.display = 'none' }}
|
||||||
|
|||||||
Reference in new issue
Block a user