Show fallback UI when art fails to load

This commit is contained in:
Kalle
2026-09-20 08:56:38 +03:00
parent bf8b7292d1
commit 61e8a8313c
18 changed files with 100 additions and 33 deletions

View File

@@ -56,6 +56,34 @@
);
}
/* nothing to look at, so the details take over the figure instead of floating over the image */
.lightboxFigureFailed {
display: flex;
flex-direction: column;
width: min(var(--lightbox-max-width), 22rem);
background-color: rgba(0, 0, 0, 0.6);
.lightboxInfo {
position: static;
opacity: 1;
padding-block-start: var(--s-3);
background: none;
}
}
.lightboxFailed {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s-2);
min-height: 10rem;
padding: var(--s-4);
color: rgba(255, 255, 255, 0.7);
font-size: var(--font-sm);
text-align: center;
}
.lightboxPlaceholder {
display: block;
width: 100%;

View File

@@ -1,5 +1,5 @@
import clsx from "clsx";
import { SquarePen, Trash, Unlink } from "lucide-react";
import { ImageOff, SquarePen, Trash, Unlink } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -90,12 +90,15 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
const [infoVisible, setInfoVisible] = React.useState(true);
const [imageSettled, imageRef] = useImageSettled();
const [aspectRatio, placeholderRef] = useImageAspectRatio();
const { t } = useTranslation(["art"]);
const { formatter } = useDateTimeFormat({
year: "numeric",
month: "numeric",
day: "numeric",
});
const imageFailed = aspectRatio === "FAILED";
const dateText = formatter.format(databaseTimestampToDate(art.createdAt));
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -120,10 +123,11 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
<div className={styles.lightboxBody} onClick={handleClick}>
<figure
className={clsx(styles.lightboxFigure, {
[styles.lightboxFigureSized]: aspectRatio,
[styles.lightboxFigureSized]: typeof aspectRatio === "number",
[styles.lightboxFigureFailed]: imageFailed,
})}
style={
aspectRatio
typeof aspectRatio === "number"
? ({ "--aspect-ratio": aspectRatio } as React.CSSProperties)
: undefined
}
@@ -134,14 +138,21 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
className={styles.lightboxPlaceholder}
ref={placeholderRef}
/>
<img
alt=""
src={art.url}
className={clsx(styles.lightboxImg, {
[styles.lightboxImgSettled]: imageSettled,
})}
ref={imageRef}
/>
{imageFailed ? (
<div className={styles.lightboxFailed}>
<ImageOff />
{t("art:imageFailed")}
</div>
) : (
<img
alt=""
src={art.url}
className={clsx(styles.lightboxImg, {
[styles.lightboxImgSettled]: imageSettled,
})}
ref={imageRef}
/>
)}
<figcaption
className={clsx(styles.lightboxInfo, {
[styles.lightboxInfoHidden]: !infoVisible,
@@ -364,17 +375,24 @@ function useImageSettled() {
return [imageSettled, imageRef] as const;
}
/** Aspect ratio of the image once it has loaded, and the ref to give it. */
/**
* Aspect ratio of the image once it has loaded, "FAILED" if it never will,
* and the ref to give it.
*/
function useImageAspectRatio() {
const [aspectRatio, setAspectRatio] = React.useState<number | null>(null);
const [aspectRatio, setAspectRatio] = React.useState<
number | "FAILED" | null
>(null);
const imageRef = (image: HTMLImageElement | null) => {
if (!image) return;
const measure = () => {
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
setAspectRatio(image.naturalWidth / image.naturalHeight);
}
setAspectRatio(
image.naturalWidth > 0 && image.naturalHeight > 0
? image.naturalWidth / image.naturalHeight
: "FAILED",
);
};
if (image.complete) {
measure();
@@ -382,7 +400,12 @@ function useImageAspectRatio() {
}
image.addEventListener("load", measure);
return () => image.removeEventListener("load", measure);
image.addEventListener("error", measure);
return () => {
image.removeEventListener("load", measure);
image.removeEventListener("error", measure);
};
};
return [aspectRatio, imageRef] as const;

View File

@@ -22,5 +22,6 @@
"forms.tags.maxReached": "Maks antal etiketter opnået",
"delete.title": "",
"unlink.title": "",
"noArtForTag": ""
"noArtForTag": "",
"imageFailed": ""
}

View File

@@ -22,5 +22,6 @@
"forms.tags.maxReached": "",
"delete.title": "",
"unlink.title": "",
"noArtForTag": ""
"noArtForTag": "",
"imageFailed": ""
}

View File

@@ -22,5 +22,6 @@
"forms.tags.maxReached": "Max tags reached",
"delete.title": "Are you sure you want to delete the art?",
"unlink.title": "Are you sure you want to remove this art from your profile (only {{username}} can add it back)?",
"noArtForTag": "No uploaded art found for #{{tag}}"
"noArtForTag": "No uploaded art found for #{{tag}}",
"imageFailed": "Image could not be loaded"
}

View File

@@ -23,5 +23,6 @@
"forms.tags.maxReached": "Límite máximo de etiquetas",
"delete.title": "¿Seguro que quieres eliminar este arte?",
"unlink.title": "¿Seguro que quieres desvincular este arte de tu perfil? (solo {{username}} puede volver a añadirlo)",
"noArtForTag": "No se ha encontrado arte subido para #{{tag}}"
"noArtForTag": "No se ha encontrado arte subido para #{{tag}}",
"imageFailed": ""
}

View File

@@ -23,5 +23,6 @@
"forms.tags.maxReached": "Límite máximo de etiquetas",
"delete.title": "¿Seguro que quieres eliminar este arte?",
"unlink.title": "¿Seguro que quieres desvincular este arte de tu perfil? (solo {{username}} puede volver a añadirlo)",
"noArtForTag": "No se ha encontrado arte subido para #{{tag}}"
"noArtForTag": "No se ha encontrado arte subido para #{{tag}}",
"imageFailed": ""
}

View File

@@ -23,5 +23,6 @@
"forms.tags.maxReached": "Limite de tags atteinte",
"delete.title": "",
"unlink.title": "",
"noArtForTag": ""
"noArtForTag": "",
"imageFailed": ""
}

View File

@@ -23,5 +23,6 @@
"forms.tags.maxReached": "Limite de tags atteinte",
"delete.title": "Êtes vous sûre de vouloir supprimer cette œuvre?",
"unlink.title": "Êtes vous sûre de vouloir supprimer cette œuvre de votre profil? (Seulement {{username}} pourra le remettre)",
"noArtForTag": ""
"noArtForTag": "",
"imageFailed": ""
}

View File

@@ -23,5 +23,6 @@
"forms.tags.maxReached": "הגעת לכמות תגים מקסימלית",
"delete.title": "האם אתה בטוח שאתה רוצה למחוק את הציור?",
"unlink.title": "האם אתה בטוח שאתה רוצה להסיר את הציור הזה מהפרופיל שלך (רק {{username}} יוכל להוסיף אותו חזרה)?",
"noArtForTag": ""
"noArtForTag": "",
"imageFailed": ""
}

View File

@@ -23,5 +23,6 @@
"forms.tags.maxReached": "Numero massimo di tag raggiunto.",
"delete.title": "",
"unlink.title": "",
"noArtForTag": ""
"noArtForTag": "",
"imageFailed": ""
}

View File

@@ -20,5 +20,6 @@
"forms.tags.maxReached": "タグの最大数に到達しました",
"delete.title": "本当にこの作品を削除しますか?",
"unlink.title": "本当にこの作品をプロフィールから削除しますか?{{username}}しか再び追加することはできません。",
"noArtForTag": "#{{tag}}の作品が見つかりませんでした。"
"noArtForTag": "#{{tag}}の作品が見つかりませんでした。",
"imageFailed": ""
}

View File

@@ -20,5 +20,6 @@
"forms.tags.maxReached": "",
"delete.title": "",
"unlink.title": "",
"noArtForTag": ""
"noArtForTag": "",
"imageFailed": ""
}

View File

@@ -22,5 +22,6 @@
"forms.tags.maxReached": "",
"delete.title": "",
"unlink.title": "",
"noArtForTag": ""
"noArtForTag": "",
"imageFailed": ""
}

View File

@@ -24,5 +24,6 @@
"forms.tags.maxReached": "",
"delete.title": "",
"unlink.title": "",
"noArtForTag": ""
"noArtForTag": "",
"imageFailed": ""
}

View File

@@ -23,5 +23,6 @@
"forms.tags.maxReached": "Quantidade máxima de marcações atingida",
"delete.title": "",
"unlink.title": "",
"noArtForTag": ""
"noArtForTag": "",
"imageFailed": ""
}

View File

@@ -24,5 +24,6 @@
"forms.tags.maxReached": "Максимальное кол-во тегов достигнуто",
"delete.title": "Вы точно хотите удалить этот арт?",
"unlink.title": "Вы точно хотите удалить этот арт с вашего профиля (только {{username}} может добавить его обратно)?",
"noArtForTag": ""
"noArtForTag": "",
"imageFailed": ""
}

View File

@@ -21,5 +21,6 @@
"forms.tags.maxReached": "已达到最大标签数",
"delete.title": "您确定要删除这件作品吗?",
"unlink.title": "您确定要将这件作品从个人资料中移除吗(只有 {{username}} 可以重新添加)?",
"noArtForTag": "未能找到包含 #{{tag}} 的作品。"
"noArtForTag": "未能找到包含 #{{tag}} 的作品。",
"imageFailed": ""
}