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 { 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 />;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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