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:
@@ -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 { photosApi, PhotoGallery, Photo } from '@/lib/api';
|
||||
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 LoginModal from '@/components/LoginModal';
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
CalendarIcon,
|
||||
CameraIcon,
|
||||
LinkIcon,
|
||||
@@ -42,6 +48,17 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
||||
const [denied, setDenied] = useState<DeniedState>(null);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
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
|
||||
// (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]);
|
||||
|
||||
// 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)) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<ImageGridSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <GallerySkeleton count={gallery?.photoCount || undefined} />;
|
||||
}
|
||||
|
||||
// Gate pages for restricted galleries. After a successful login in the
|
||||
@@ -254,47 +267,48 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<div className="relative bg-brand-navy overflow-hidden">
|
||||
{heroUrl && (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={heroUrl}
|
||||
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" />
|
||||
</>
|
||||
{/* Hero — same frame the skeleton renders (GalleryLayout). */}
|
||||
<GalleryHeroFrame
|
||||
backdrop={
|
||||
heroUrl ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={heroUrl}
|
||||
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" />
|
||||
</>
|
||||
) : 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">
|
||||
<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="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 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>
|
||||
</GalleryHeroFrame>
|
||||
|
||||
{/* Masonry grid */}
|
||||
<div className="container-page px-2 sm:px-4 py-4 md:py-8">
|
||||
<GalleryContainer>
|
||||
{readyPhotos.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
|
||||
<MasonryGrid>
|
||||
{readyPhotos.map((photo, i) => (
|
||||
<div
|
||||
<PhotoTile
|
||||
key={photo.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setLightboxIndex(i)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
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>
|
||||
photo={photo}
|
||||
eager={i < 8}
|
||||
onOpen={() => setLightboxIndex(i)}
|
||||
onDownload={() => downloadPhoto(photo)}
|
||||
downloading={downloads.isPending(photo.id)}
|
||||
labels={downloadLabels}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</MasonryGrid>
|
||||
)}
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
@@ -351,9 +338,16 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
||||
index={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
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
|
||||
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 />;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChevronRightIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Spinner from '@/components/ui/Spinner';
|
||||
|
||||
export interface LightboxItem {
|
||||
id: string;
|
||||
@@ -24,17 +25,35 @@ interface LightboxProps {
|
||||
onNavigate: (index: number) => void;
|
||||
/** Extra per-item action buttons rendered in the top bar (admin use). */
|
||||
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
|
||||
* 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 activeThumbRef = useRef<HTMLButtonElement | null>(null);
|
||||
const item = items[index];
|
||||
const hasMultiple = items.length > 1;
|
||||
const downloading = !!downloadingId && item?.id === downloadingId;
|
||||
|
||||
const prev = useCallback(() => {
|
||||
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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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>
|
||||
{onDownload ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownload(item)}
|
||||
disabled={downloading}
|
||||
aria-busy={downloading}
|
||||
aria-label={
|
||||
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)}
|
||||
</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 };
|
||||
}
|
||||
@@ -9,11 +9,21 @@ interface SkeletonProps {
|
||||
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 (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user