Share gallery layout with loading skeletons and add download progress.

Keep the public gallery hero/masonry geometry stable across route and client loading, and show busy state while photos download.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-08-05 22:05:33 +00:00
co-authored by Cursor
parent 498d7d8a7d
commit fa8686276d
10 changed files with 524 additions and 94 deletions
@@ -0,0 +1,6 @@
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
// Shown while the server component fetches the event gallery.
export default function Loading() {
return <GallerySkeleton />;
}
@@ -7,11 +7,17 @@ import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { photosApi, PhotoGallery, Photo } from '@/lib/api'; import { photosApi, PhotoGallery, Photo } from '@/lib/api';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { ImageGridSkeleton } from '@/components/ui/Skeleton'; import GallerySkeleton from '@/components/gallery/GallerySkeleton';
import PhotoTile from '@/components/gallery/PhotoTile';
import {
GalleryContainer,
GalleryHeroFrame,
MasonryGrid,
} from '@/components/gallery/GalleryLayout';
import { useDownloads } from '@/components/gallery/useDownloads';
import Lightbox from '@/components/Lightbox'; import Lightbox from '@/components/Lightbox';
import LoginModal from '@/components/LoginModal'; import LoginModal from '@/components/LoginModal';
import { import {
ArrowDownTrayIcon,
CalendarIcon, CalendarIcon,
CameraIcon, CameraIcon,
LinkIcon, LinkIcon,
@@ -42,6 +48,17 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
const [denied, setDenied] = useState<DeniedState>(null); const [denied, setDenied] = useState<DeniedState>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [loginOpen, setLoginOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false);
const downloads = useDownloads(es);
const downloadLabels = {
download: es ? 'Descargar' : 'Download',
downloading: es ? 'Descargando…' : 'Downloading…',
};
const downloadPhoto = (photo: Photo) =>
downloads.start({
id: photo.id,
url: photo.urls.original,
filename: photo.originalFilename,
});
// Server-rendered public galleries need no client fetch. Everything else // Server-rendered public galleries need no client fetch. Everything else
// (link/ticket/private) is fetched here with the share token and/or the // (link/ticket/private) is fetched here with the share token and/or the
@@ -78,14 +95,10 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
}; };
}, [slug, eventSlug, shareToken, initial, authLoading, user?.id]); }, [slug, eventSlug, shareToken, initial, authLoading, user?.id]);
// Mirrors the hero + masonry layout below, so the real page drops straight
// into the placeholder's geometry instead of replacing it.
if (loading || (authLoading && !initial)) { if (loading || (authLoading && !initial)) {
return ( return <GallerySkeleton count={gallery?.photoCount || undefined} />;
<div className="section-padding">
<div className="container-page">
<ImageGridSkeleton />
</div>
</div>
);
} }
// Gate pages for restricted galleries. After a successful login in the // Gate pages for restricted galleries. After a successful login in the
@@ -254,47 +267,48 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
return ( return (
<div> <div>
{/* Hero */} {/* Hero — same frame the skeleton renders (GalleryLayout). */}
<div className="relative bg-brand-navy overflow-hidden"> <GalleryHeroFrame
{heroUrl && ( backdrop={
<> heroUrl ? (
{/* eslint-disable-next-line @next/next/no-img-element */} <>
<img {/* eslint-disable-next-line @next/next/no-img-element */}
src={heroUrl} <img
alt="" src={heroUrl}
className="absolute inset-0 w-full h-full object-cover opacity-60" alt=""
/> className="absolute inset-0 w-full h-full object-cover opacity-60"
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" /> />
</> <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
</>
) : null
}
>
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
{title}
</h1>
{description && (
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
)} )}
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12"> <div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm"> <span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
{title} <CameraIcon className="w-4 h-4" />
</h1> {readyPhotos.length} {es ? 'fotos' : 'photos'}
{description && ( </span>
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p> {gallery.event && (
<Link
href={`/events/${gallery.event.slug}`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
>
<CalendarIcon className="w-4 h-4" />
{eventTitle}
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
</Link>
)} )}
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
<CameraIcon className="w-4 h-4" />
{readyPhotos.length} {es ? 'fotos' : 'photos'}
</span>
{gallery.event && (
<Link
href={`/events/${gallery.event.slug}`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
>
<CalendarIcon className="w-4 h-4" />
{eventTitle}
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
</Link>
)}
</div>
</div> </div>
</div> </GalleryHeroFrame>
{/* Masonry grid */} {/* Masonry grid */}
<div className="container-page px-2 sm:px-4 py-4 md:py-8"> <GalleryContainer>
{readyPhotos.length === 0 ? ( {readyPhotos.length === 0 ? (
<div className="text-center py-16"> <div className="text-center py-16">
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" /> <CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
@@ -303,46 +317,19 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
</p> </p>
</div> </div>
) : ( ) : (
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]"> <MasonryGrid>
{readyPhotos.map((photo, i) => ( {readyPhotos.map((photo, i) => (
<div <PhotoTile
key={photo.id} key={photo.id}
role="button" photo={photo}
tabIndex={0} eager={i < 8}
onClick={() => setLightboxIndex(i)} onOpen={() => setLightboxIndex(i)}
onKeyDown={(e) => { onDownload={() => downloadPhoto(photo)}
if (e.key === 'Enter' || e.key === ' ') { downloading={downloads.isPending(photo.id)}
e.preventDefault(); labels={downloadLabels}
setLightboxIndex(i); />
}
}}
className="group relative mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow"
style={
photo.width && photo.height
? { aspectRatio: `${photo.width} / ${photo.height}` }
: undefined
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.urls.thumb}
alt=""
loading={i < 8 ? 'eager' : 'lazy'}
className="w-full h-auto group-hover:scale-[1.03] transition-transform duration-300"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<a
href={photo.urls.original}
download={photo.originalFilename || true}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-2 right-2 hidden md:flex p-2 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80"
aria-label={es ? 'Descargar' : 'Download'}
>
<ArrowDownTrayIcon className="w-4 h-4" />
</a>
</div>
))} ))}
</div> </MasonryGrid>
)} )}
{lightboxIndex !== null && ( {lightboxIndex !== null && (
@@ -351,9 +338,16 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
index={lightboxIndex} index={lightboxIndex}
onClose={() => setLightboxIndex(null)} onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex} onNavigate={setLightboxIndex}
onDownload={(it) =>
downloads.start({ id: it.id, url: it.downloadUrl, filename: it.filename })
}
downloadingId={
lightboxItems.find((it) => downloads.isPending(it.id))?.id ?? null
}
downloadLabels={downloadLabels}
/> />
)} )}
</div> </GalleryContainer>
{/* Call to action: send attendees to their dashboard, everyone else to {/* Call to action: send attendees to their dashboard, everyone else to
the next event. Auth state comes from the same useAuth() the gate the next event. Auth state comes from the same useAuth() the gate
@@ -0,0 +1,7 @@
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
// Shown while the server component fetches the gallery, so the first paint is
// already the gallery's layout rather than an empty page.
export default function Loading() {
return <GallerySkeleton />;
}
+51 -9
View File
@@ -7,6 +7,7 @@ import {
ChevronRightIcon, ChevronRightIcon,
ArrowDownTrayIcon, ArrowDownTrayIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import Spinner from '@/components/ui/Spinner';
export interface LightboxItem { export interface LightboxItem {
id: string; id: string;
@@ -24,17 +25,35 @@ interface LightboxProps {
onNavigate: (index: number) => void; onNavigate: (index: number) => void;
/** Extra per-item action buttons rendered in the top bar (admin use). */ /** Extra per-item action buttons rendered in the top bar (admin use). */
renderActions?: (item: LightboxItem, index: number) => React.ReactNode; renderActions?: (item: LightboxItem, index: number) => React.ReactNode;
/**
* Handles the download in JS instead of navigating, so the button can show
* progress. Without it the button stays a plain <a download>.
*/
onDownload?: (item: LightboxItem) => void;
/** Id of the item currently downloading (pairs with onDownload). */
downloadingId?: string | null;
downloadLabels?: { download: string; downloading: string };
} }
/** /**
* Full-screen photo lightbox with keyboard and swipe navigation, in the * Full-screen photo lightbox with keyboard and swipe navigation, in the
* style of the admin gallery preview modal (fixed inset-0 bg-black/90). * style of the admin gallery preview modal (fixed inset-0 bg-black/90).
*/ */
export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) { export default function Lightbox({
items,
index,
onClose,
onNavigate,
renderActions,
onDownload,
downloadingId,
downloadLabels,
}: LightboxProps) {
const touchStart = useRef<{ x: number; y: number } | null>(null); const touchStart = useRef<{ x: number; y: number } | null>(null);
const activeThumbRef = useRef<HTMLButtonElement | null>(null); const activeThumbRef = useRef<HTMLButtonElement | null>(null);
const item = items[index]; const item = items[index];
const hasMultiple = items.length > 1; const hasMultiple = items.length > 1;
const downloading = !!downloadingId && item?.id === downloadingId;
const prev = useCallback(() => { const prev = useCallback(() => {
onNavigate(index > 0 ? index - 1 : items.length - 1); onNavigate(index > 0 ? index - 1 : items.length - 1);
@@ -98,14 +117,37 @@ export default function Lightbox({ items, index, onClose, onNavigate, renderActi
className="absolute top-4 left-4 z-10 flex items-center gap-4" className="absolute top-4 left-4 z-10 flex items-center gap-4"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<a {onDownload ? (
href={item.downloadUrl} <button
download={item.filename || true} type="button"
className="text-white hover:text-gray-300" onClick={() => onDownload(item)}
aria-label="Download" disabled={downloading}
> aria-busy={downloading}
<ArrowDownTrayIcon className="w-7 h-7" /> aria-label={
</a> downloading
? downloadLabels?.downloading || 'Downloading…'
: downloadLabels?.download || 'Download'
}
className={`text-white hover:text-gray-300 flex items-center ${
downloading ? 'cursor-wait' : ''
}`}
>
{downloading ? (
<Spinner className="w-6 h-6" />
) : (
<ArrowDownTrayIcon className="w-7 h-7" />
)}
</button>
) : (
<a
href={item.downloadUrl}
download={item.filename || true}
className="text-white hover:text-gray-300"
aria-label="Download"
>
<ArrowDownTrayIcon className="w-7 h-7" />
</a>
)}
{renderActions?.(item, index)} {renderActions?.(item, index)}
</div> </div>
@@ -0,0 +1,46 @@
// Layout shell shared by the public gallery page (GalleryClient) and its
// loading placeholder (GallerySkeleton). Column count, gaps, radii and
// container padding are defined once here, so the skeleton's geometry can
// never drift from the grid it stands in for.
export function GalleryHeroFrame({
children,
backdrop,
}: {
children: React.ReactNode;
/** Cover image + scrim, absolutely positioned behind the text. */
backdrop?: React.ReactNode;
}) {
return (
<div className="relative bg-brand-navy overflow-hidden">
{backdrop}
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12">{children}</div>
</div>
);
}
export function GalleryContainer({ children }: { children: React.ReactNode }) {
return <div className="container-page px-2 sm:px-4 py-4 md:py-8">{children}</div>;
}
export function MasonryGrid({ children }: { children: React.ReactNode }) {
return (
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
{children}
</div>
);
}
// Per-tile geometry. Deliberately carries no background so callers can pick
// one (photo tiles: bg-gray-100, skeleton tiles: the skeleton surface) without
// two `bg-*` utilities fighting over CSS order.
export const masonryTileClass = 'mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl';
/**
* Reserves the tile's aspect ratio up front so the image can load into a box
* that is already the right size — late arrivals never reflow the columns.
* Returns undefined when the photo has no stored dimensions.
*/
export function aspectStyle(width?: number, height?: number): React.CSSProperties | undefined {
return width && height ? { aspectRatio: `${width} / ${height}` } : undefined;
}
@@ -0,0 +1,59 @@
import clsx from 'clsx';
import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton';
import { GalleryContainer, GalleryHeroFrame, MasonryGrid, masonryTileClass } from './GalleryLayout';
// Loading placeholder for the public gallery page. It renders through the same
// hero frame, container and masonry grid as the real page (see GalleryLayout),
// so replacing it with photos changes only the pixels inside the tiles.
// Portrait/landscape/square mix standing in for a real event set. Fixed order
// on purpose — a random shuffle would differ between the server and client
// render and blow up hydration.
const FALLBACK_RATIOS = [3 / 4, 4 / 3, 1, 2 / 3, 3 / 2, 4 / 5, 1, 3 / 4, 16 / 9, 4 / 5, 3 / 4, 4 / 3];
interface GallerySkeletonProps {
/** Number of tiles to draw; ignored when `ratios` is given. */
count?: number;
/**
* Real width/height ratios when the caller already knows them (e.g. a
* cached gallery payload). Produces a grid with exactly the right geometry
* instead of the guessed mix above.
*/
ratios?: number[];
}
export default function GallerySkeleton({ count = 12, ratios }: GallerySkeletonProps) {
const tiles =
ratios && ratios.length > 0
? ratios
: Array.from({ length: count }, (_, i) => FALLBACK_RATIOS[i % FALLBACK_RATIOS.length]);
return (
<SkeletonGroup>
<GalleryHeroFrame>
{/* Matches the h1 (text-3xl / md:text-5xl) and the pill row below it. */}
<Skeleton tone="on-dark" className="h-9 md:h-12 w-2/3 max-w-md" />
<div className="mt-4 flex flex-wrap items-center gap-2">
<Skeleton tone="on-dark" className="h-7 w-28 rounded-full" />
<Skeleton tone="on-dark" className="h-7 w-56 max-w-[60%] rounded-full" />
</div>
</GalleryHeroFrame>
<GalleryContainer>
<MasonryGrid>
{tiles.map((ratio, i) => (
<div
key={i}
aria-hidden="true"
className={clsx(
masonryTileClass,
'bg-secondary-light-gray/70 animate-pulse motion-reduce:animate-none'
)}
style={{ aspectRatio: `${ratio}` }}
/>
))}
</MasonryGrid>
</GalleryContainer>
</SkeletonGroup>
);
}
@@ -0,0 +1,100 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import type { Photo } from '@/lib/api';
import Spinner from '@/components/ui/Spinner';
import { aspectStyle, masonryTileClass } from './GalleryLayout';
interface PhotoTileProps {
photo: Photo;
/** Above-the-fold tiles load eagerly, the rest lazily. */
eager?: boolean;
onOpen: () => void;
onDownload: () => void;
downloading: boolean;
labels: { download: string; downloading: string };
}
export default function PhotoTile({
photo,
eager,
onOpen,
onDownload,
downloading,
labels,
}: PhotoTileProps) {
const imgRef = useRef<HTMLImageElement>(null);
// 'initial' is what the server renders: fully visible, so a public gallery
// still paints its photos if hydration is slow or JS never arrives. The
// fade only takes over once we're mounted and know the image is still in
// flight — a cached image reports `complete` here and skips it entirely
// (its onLoad already fired before React attached the handler).
const [state, setState] = useState<'initial' | 'pending' | 'loaded'>('initial');
const pending = state === 'pending';
useEffect(() => {
setState(imgRef.current?.complete ? 'loaded' : 'pending');
}, []);
return (
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen();
}
}}
className={clsx(
masonryTileClass,
'group relative bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow'
)}
/* Sized from the stored dimensions before the bytes arrive, so a slow
image never pushes its column around. */
style={aspectStyle(photo.width, photo.height)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imgRef}
src={photo.urls.thumb}
alt=""
loading={eager ? 'eager' : 'lazy'}
onLoad={() => setState('loaded')}
onError={() => setState('loaded')}
className={clsx(
'w-full h-auto transition-[opacity,transform] duration-300 motion-reduce:transition-none group-hover:scale-[1.03]',
pending ? 'opacity-0' : 'opacity-100'
)}
/>
{pending && (
<span
aria-hidden="true"
className="absolute inset-0 bg-secondary-light-gray/70 animate-pulse motion-reduce:animate-none"
/>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDownload();
}}
disabled={downloading}
aria-busy={downloading}
aria-label={downloading ? labels.downloading : labels.download}
className={clsx(
'absolute bottom-2 right-2 hidden md:flex p-2 rounded-full bg-black/50 text-white transition-opacity hover:bg-black/80 focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary-yellow',
downloading
? 'opacity-100 cursor-wait hover:bg-black/50'
: 'opacity-0 group-hover:opacity-100'
)}
>
{downloading ? <Spinner /> : <ArrowDownTrayIcon className="w-4 h-4" />}
</button>
</div>
);
}
@@ -0,0 +1,148 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast';
// Per-photo download state for the public gallery. The photo-api serves full
// quality originals, which takes a few seconds, so downloads run through
// fetch() instead of a bare <a download> — that gives us a spinner, a real
// success/failure signal, a timeout and abort-on-unmount.
// Originals run to ~20 MB, so a single flat timeout would either be too short
// for a phone on mobile data or useless as a stall detector. Time out on the
// response headers instead, then give the body a generous ceiling.
const HEADERS_TIMEOUT_MS = 30_000;
const BODY_TIMEOUT_MS = 10 * 60_000;
export interface DownloadRequest {
/** Photo id; keys the in-flight state so tiles stay independent. */
id: string;
url: string;
filename?: string;
}
/** Saves fetched bytes without navigating away from the gallery. */
function saveBlob(blob: Blob, filename: string) {
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Give the browser time to start the save before the URL is invalidated.
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
}
/**
* Plain-anchor download, i.e. what the page did before this hook existed.
* Used as a fallback when fetch() itself fails: with S3 storage the file
* endpoint 302s to a presigned URL on another origin, which a cross-origin
* fetch cannot read but a navigation downloads fine (the presigned URL
* carries its own Content-Disposition).
*/
function navigateToDownload({ url, filename }: DownloadRequest) {
const a = document.createElement('a');
a.href = url;
if (filename) a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
export function useDownloads(es: boolean) {
const [pending, setPending] = useState<ReadonlySet<string>>(() => new Set());
const controllers = useRef(new Map<string, AbortController>());
const unmounted = useRef(false);
// Lets the retry button in the error toast call the latest `start`.
const startRef = useRef<(req: DownloadRequest) => void>(() => {});
useEffect(() => {
unmounted.current = false;
return () => {
unmounted.current = true;
controllers.current.forEach((c) => c.abort());
controllers.current.clear();
};
}, []);
const start = useCallback(
async (req: DownloadRequest) => {
// Repeat clicks while the same photo is in flight are no-ops; other
// photos are unaffected because state is keyed by id.
if (controllers.current.has(req.id)) return;
const controller = new AbortController();
controllers.current.set(req.id, controller);
setPending((prev) => new Set(prev).add(req.id));
let timer = setTimeout(() => controller.abort(), HEADERS_TIMEOUT_MS);
const timedOut = () => controller.signal.aborted && !unmounted.current;
try {
const res = await fetch(req.url, {
credentials: 'same-origin',
signal: controller.signal,
});
clearTimeout(timer);
timer = setTimeout(() => controller.abort(), BODY_TIMEOUT_MS);
if (!res.ok) throw new Error(`Download failed (${res.status})`);
const blob = await res.blob();
saveBlob(blob, req.filename || `${req.id}.jpg`);
} catch (err) {
if (unmounted.current) return; // page gone, nothing to report
if (err instanceof TypeError) {
// Network-level failure — most likely a cross-origin presigned
// redirect. Hand it to the browser, which can follow it.
navigateToDownload(req);
} else {
const message = timedOut()
? es
? 'La descarga tardó demasiado.'
: 'The download timed out.'
: es
? 'No se pudo descargar la foto.'
: 'Could not download the photo.';
toast.error(
(t) => (
<span className="flex items-center gap-3">
{message}
<button
type="button"
onClick={() => {
toast.dismiss(t.id);
startRef.current(req);
}}
className="font-medium underline underline-offset-2 whitespace-nowrap"
>
{es ? 'Reintentar' : 'Retry'}
</button>
</span>
),
{ duration: 6000 }
);
}
} finally {
clearTimeout(timer);
controllers.current.delete(req.id);
if (!unmounted.current) {
setPending((prev) => {
const next = new Set(prev);
next.delete(req.id);
return next;
});
}
}
},
[es]
);
useEffect(() => {
startRef.current = start;
}, [start]);
const isPending = useCallback((id: string) => pending.has(id), [pending]);
return { start, isPending };
}
+12 -2
View File
@@ -9,11 +9,21 @@ interface SkeletonProps {
className?: string; className?: string;
} }
export function Skeleton({ className }: SkeletonProps) { export function Skeleton({
className,
tone = 'default',
}: SkeletonProps & {
/** `on-dark` lightens the surface for placeholders over a dark hero. */
tone?: 'default' | 'on-dark';
}) {
return ( return (
<div <div
aria-hidden="true" aria-hidden="true"
className={clsx('animate-pulse rounded-lg bg-secondary-light-gray/70', className)} className={clsx(
'animate-pulse motion-reduce:animate-none rounded-lg',
tone === 'on-dark' ? 'bg-white/20' : 'bg-secondary-light-gray/70',
className
)}
/> />
); );
} }
+18
View File
@@ -0,0 +1,18 @@
import clsx from 'clsx';
// Inline busy indicator in the same style as the full-page loaders
// (animate-spin ring with one contrasting edge). Purely decorative: callers
// own the accessible state via aria-busy / aria-label. Defaults to the
// white-on-dark ring used by the photo overlay controls; pass `border-*`
// classes to restyle it elsewhere.
export default function Spinner({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={clsx(
'inline-block rounded-full border-2 animate-spin border-white/40 border-t-white',
className || 'w-4 h-4'
)}
/>
);
}