···
+
import React from "react";
+
import type { GrainGalleryRecord, GrainPhotoRecord } from "../types/grain";
+
import { useBlob } from "../hooks/useBlob";
+
import { isBlobWithCdn, extractCidFromBlob } from "../utils/blob";
+
export interface GrainGalleryPhoto {
+
record: GrainPhotoRecord;
+
export interface GrainGalleryRendererProps {
+
gallery: GrainGalleryRecord;
+
photos: GrainGalleryPhoto[];
+
authorDisplayName?: string;
+
export const GrainGalleryRenderer: React.FC<GrainGalleryRendererProps> = ({
+
const [currentPage, setCurrentPage] = React.useState(0);
+
const [lightboxOpen, setLightboxOpen] = React.useState(false);
+
const [lightboxPhotoIndex, setLightboxPhotoIndex] = React.useState(0);
+
const createdDate = new Date(gallery.createdAt);
+
const created = createdDate.toLocaleString(undefined, {
+
const primaryName = authorDisplayName || authorHandle || "…";
+
const openLightbox = React.useCallback((photoIndex: number) => {
+
setLightboxPhotoIndex(photoIndex);
+
const closeLightbox = React.useCallback(() => {
+
setLightboxOpen(false);
+
const goToNextPhoto = React.useCallback(() => {
+
setLightboxPhotoIndex((prev) => (prev + 1) % sortedPhotos.length);
+
}, [sortedPhotos.length]);
+
const goToPrevPhoto = React.useCallback(() => {
+
setLightboxPhotoIndex((prev) => (prev - 1 + sortedPhotos.length) % sortedPhotos.length);
+
}, [sortedPhotos.length]);
+
React.useEffect(() => {
+
if (!lightboxOpen) return;
+
const handleKeyDown = (e: KeyboardEvent) => {
+
if (e.key === "Escape") closeLightbox();
+
if (e.key === "ArrowLeft") goToPrevPhoto();
+
if (e.key === "ArrowRight") goToNextPhoto();
+
window.addEventListener("keydown", handleKeyDown);
+
return () => window.removeEventListener("keydown", handleKeyDown);
+
}, [lightboxOpen, closeLightbox, goToPrevPhoto, goToNextPhoto]);
+
// Memoize sorted photos to prevent re-sorting on every render
+
const sortedPhotos = React.useMemo(
+
() => [...photos].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)),
+
const isSinglePhoto = sortedPhotos.length === 1;
+
// Preload all photos to avoid loading states when paginating
+
usePreloadAllPhotos(sortedPhotos);
+
// Reset to first page when photos change
+
React.useEffect(() => {
+
}, [sortedPhotos.length]);
+
// Memoize pagination calculations with intelligent photo count per page
+
const paginationData = React.useMemo(() => {
+
const pages = calculatePages(sortedPhotos);
+
const totalPages = pages.length;
+
const visiblePhotos = pages[currentPage] || [];
+
const hasMultiplePages = totalPages > 1;
+
const layoutPhotos = calculateLayout(visiblePhotos);
+
}, [sortedPhotos, currentPage]);
+
const { totalPages, hasMultiplePages, layoutPhotos } = paginationData;
+
// Memoize navigation handlers to prevent re-creation
+
const goToNextPage = React.useCallback(() => {
+
setCurrentPage((prev) => (prev + 1) % totalPages);
+
const goToPrevPage = React.useCallback(() => {
+
setCurrentPage((prev) => (prev - 1 + totalPages) % totalPages);
+
<div style={{ padding: 8, color: "crimson" }}>
+
Failed to load gallery.
+
if (loading && photos.length === 0) {
+
return <div style={{ padding: 8 }}>Loading gallery…</div>;
+
{/* Hidden preload elements for all photos */}
+
<div style={{ display: "none" }} aria-hidden>
+
{sortedPhotos.map((photo) => (
+
<PreloadPhoto key={`${photo.did}-${photo.rkey}-preload`} photo={photo} />
+
photo={sortedPhotos[lightboxPhotoIndex]}
+
photoIndex={lightboxPhotoIndex}
+
totalPhotos={sortedPhotos.length}
+
onClose={closeLightbox}
+
<article style={styles.card}>
+
<header style={styles.header}>
+
<img src={avatarUrl} alt="avatar" style={styles.avatarImg} />
+
<div style={styles.avatarPlaceholder} aria-hidden />
+
<div style={styles.authorInfo}>
+
<strong style={styles.displayName}>{primaryName}</strong>
+
color: `var(--atproto-color-text-secondary)`,
+
<div style={styles.galleryInfo}>
+
color: `var(--atproto-color-text)`,
+
{gallery.description && (
+
color: `var(--atproto-color-text-secondary)`,
+
<div style={styles.singlePhotoContainer}>
+
<GalleryPhotoItem key={`${sortedPhotos[0].did}-${sortedPhotos[0].rkey}`} photo={sortedPhotos[0]} isSingle={true} />
+
<div style={styles.carouselContainer}>
+
{hasMultiplePages && currentPage > 0 && (
+
onMouseEnter={(e) => (e.currentTarget.style.opacity = "1")}
+
onMouseLeave={(e) => (e.currentTarget.style.opacity = "0.7")}
+
...styles.navButtonLeft,
+
background: "rgba(0, 0, 0, 0.5)",
+
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
+
aria-label="Previous photos"
+
<div style={styles.photosGrid}>
+
{layoutPhotos.map((item) => (
+
key={`${item.did}-${item.rkey}`}
+
{hasMultiplePages && currentPage < totalPages - 1 && (
+
onMouseEnter={(e) => (e.currentTarget.style.opacity = "1")}
+
onMouseLeave={(e) => (e.currentTarget.style.opacity = "0.7")}
+
...styles.navButtonRight,
+
background: "rgba(0, 0, 0, 0.5)",
+
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
+
aria-label="Next photos"
+
<footer style={styles.footer}>
+
color: `var(--atproto-color-text-muted)`,
+
dateTime={gallery.createdAt}
+
{hasMultiplePages && !isSinglePhoto && (
+
<div style={styles.paginationDots}>
+
{Array.from({ length: totalPages }, (_, i) => (
+
onClick={() => setCurrentPage(i)}
+
...styles.paginationDot,
+
background: i === currentPage
+
? `var(--atproto-color-text)`
+
: `var(--atproto-color-border)`,
+
aria-label={`Go to page ${i + 1}`}
+
aria-current={i === currentPage ? "page" : undefined}
+
// Component to preload a single photo's blob
+
const PreloadPhoto: React.FC<{ photo: GrainGalleryPhoto }> = ({ photo }) => {
+
const photoBlob = photo.record.photo;
+
const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined;
+
const cid = cdnUrl ? undefined : extractCidFromBlob(photoBlob);
+
// Trigger blob loading via the hook
+
useBlob(photo.did, cid);
+
// Preload CDN images via Image element
+
React.useEffect(() => {
+
const img = new Image();
+
// Hook to preload all photos (CDN-based)
+
const usePreloadAllPhotos = (photos: GrainGalleryPhoto[]) => {
+
React.useEffect(() => {
+
photos.forEach((photo) => {
+
const photoBlob = photo.record.photo;
+
const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined;
+
const img = new Image();
+
// Calculate pages with intelligent photo count (1, 2, or 3)
+
// Only includes multiple photos when they fit well together
+
const calculatePages = (photos: GrainGalleryPhoto[]): GrainGalleryPhoto[][] => {
+
if (photos.length === 0) return [];
+
if (photos.length === 1) return [[photos[0]]];
+
const pages: GrainGalleryPhoto[][] = [];
+
while (i < photos.length) {
+
const remaining = photos.length - i;
+
// Only one photo left - use it
+
pages.push([photos[i]]);
+
// Check if next 3 photos can fit well together
+
const nextThree = photos.slice(i, i + 3);
+
if (canFitThreePhotos(nextThree)) {
+
// Check if next 2 photos can fit well together
+
const nextTwo = photos.slice(i, i + 2);
+
if (canFitTwoPhotos(nextTwo)) {
+
// Photos don't fit well together, use 1 per page
+
pages.push([photos[i]]);
+
// Helper functions for aspect ratio classification
+
const isPortrait = (ratio: number) => ratio < 0.8;
+
const isLandscape = (ratio: number) => ratio > 1.2;
+
const isSquarish = (ratio: number) => ratio >= 0.8 && ratio <= 1.2;
+
// Determine if 2 photos can fit well together side by side
+
const canFitTwoPhotos = (photos: GrainGalleryPhoto[]): boolean => {
+
if (photos.length !== 2) return false;
+
const ratios = photos.map((p) => {
+
const ar = p.record.aspectRatio;
+
return ar ? ar.width / ar.height : 1;
+
const [r1, r2] = ratios;
+
// Two portraits side by side don't work well (too narrow)
+
if (isPortrait(r1) && isPortrait(r2)) return false;
+
// Portrait + landscape/square creates awkward layout
+
if (isPortrait(r1) && !isPortrait(r2)) return false;
+
if (!isPortrait(r1) && isPortrait(r2)) return false;
+
// Two landscape or two squarish photos work well
+
if ((isLandscape(r1) || isSquarish(r1)) && (isLandscape(r2) || isSquarish(r2))) {
+
// Default to not fitting
+
// Determine if 3 photos can fit well together in a layout
+
const canFitThreePhotos = (photos: GrainGalleryPhoto[]): boolean => {
+
if (photos.length !== 3) return false;
+
const ratios = photos.map((p) => {
+
const ar = p.record.aspectRatio;
+
return ar ? ar.width / ar.height : 1;
+
const [r1, r2, r3] = ratios;
+
// Good pattern: one portrait, two landscape/square
+
if (isPortrait(r1) && !isPortrait(r2) && !isPortrait(r3)) return true;
+
if (isPortrait(r3) && !isPortrait(r1) && !isPortrait(r2)) return true;
+
// Good pattern: all similar aspect ratios (all landscape or all squarish)
+
const allLandscape = ratios.every(isLandscape);
+
const allSquarish = ratios.every(isSquarish);
+
if (allLandscape || allSquarish) return true;
+
// Three portraits in a row can work
+
const allPortrait = ratios.every(isPortrait);
+
if (allPortrait) return true;
+
// Otherwise don't fit 3 together
+
// Layout calculator for intelligent photo grid arrangement
+
const calculateLayout = (photos: GrainGalleryPhoto[]) => {
+
if (photos.length === 0) return [];
+
if (photos.length === 1) {
+
return [{ ...photos[0], span: { row: 2, col: 2 } }];
+
const photosWithRatios = photos.map((photo) => {
+
const ratio = photo.record.aspectRatio
+
? photo.record.aspectRatio.width / photo.record.aspectRatio.height
+
isPortrait: isPortrait(ratio),
+
isLandscape: isLandscape(ratio)
+
// For 2 photos: side by side
+
if (photos.length === 2) {
+
return photosWithRatios.map((p) => ({ ...p, span: { row: 2, col: 1 } }));
+
// For 3 photos: try to create a balanced layout
+
if (photos.length === 3) {
+
const [p1, p2, p3] = photosWithRatios;
+
// Pattern 1: One tall on left, two stacked on right
+
if (p1.isPortrait && !p2.isPortrait && !p3.isPortrait) {
+
{ ...p1, span: { row: 2, col: 1 } },
+
{ ...p2, span: { row: 1, col: 1 } },
+
{ ...p3, span: { row: 1, col: 1 } },
+
// Pattern 2: Two stacked on left, one tall on right
+
if (!p1.isPortrait && !p2.isPortrait && p3.isPortrait) {
+
{ ...p1, span: { row: 1, col: 1 } },
+
{ ...p2, span: { row: 1, col: 1 } },
+
{ ...p3, span: { row: 2, col: 1 } },
+
// Pattern 3: All in a row
+
const allPortrait = photosWithRatios.every((p) => p.isPortrait);
+
// All portraits: display in a row with smaller cells
+
return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } }));
+
// Default: All three in a row
+
return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } }));
+
return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } }));
+
const GalleryPhotoItem: React.FC<{
+
photo: GrainGalleryPhoto;
+
span?: { row: number; col: number };
+
}> = ({ photo, isSingle, span }) => {
+
const [showAltText, setShowAltText] = React.useState(false);
+
const photoBlob = photo.record.photo;
+
const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined;
+
const cid = cdnUrl ? undefined : extractCidFromBlob(photoBlob);
+
const { url: urlFromBlob, loading: photoLoading, error: photoError } = useBlob(photo.did, cid);
+
const url = cdnUrl || urlFromBlob;
+
const alt = photo.record.alt?.trim() || "grain.social photo";
+
const hasAlt = photo.record.alt && photo.record.alt.trim().length > 0;
+
photo.record.aspectRatio && photo.record.aspectRatio.height > 0
+
? `${photo.record.aspectRatio.width} / ${photo.record.aspectRatio.height}`
+
const gridItemStyle = span
+
gridRow: `span ${span.row}`,
+
gridColumn: `span ${span.col}`,
+
<figure style={{ ...(isSingle ? styles.singlePhotoItem : styles.photoItem), ...gridItemStyle }}>
+
...(isSingle ? styles.singlePhotoMedia : styles.photoContainer),
+
background: `var(--atproto-color-image-bg)`,
+
// Only apply aspect ratio for single photos; grid photos fill their cells
+
...(isSingle && aspect ? { aspectRatio: aspect } : {}),
+
<img src={url} alt={alt} style={isSingle ? styles.photo : styles.photoGrid} />
+
color: `var(--atproto-color-text-muted)`,
+
onClick={() => setShowAltText(!showAltText)}
+
background: showAltText
+
? `var(--atproto-color-text)`
+
: `var(--atproto-color-bg-secondary)`,
+
? `var(--atproto-color-bg)`
+
: `var(--atproto-color-text)`,
+
title="Toggle alt text"
+
aria-label="Toggle alt text"
+
{hasAlt && showAltText && (
+
color: `var(--atproto-color-text-secondary)`,
+
const styles: Record<string, React.CSSProperties> = {
+
border: `1px solid var(--atproto-color-border)`,
+
background: `var(--atproto-color-bg)`,
+
color: `var(--atproto-color-text)`,
+
fontFamily: "system-ui, sans-serif",
+
flexDirection: "column",
+
"background-color 180ms ease, border-color 180ms ease, color 180ms ease",
+
background: `var(--atproto-color-border)`,
+
flexDirection: "column",
+
whiteSpace: "pre-wrap",
+
singlePhotoContainer: {
+
gridTemplateColumns: "repeat(2, 1fr)",
+
gridTemplateRows: "repeat(2, 1fr)",
+
transform: "translateY(-50%)",
+
justifyContent: "center",
+
transition: "opacity 150ms ease",
+
flexDirection: "column",
+
flexDirection: "column",
+
justifyContent: "center",
+
letterSpacing: "0.5px",
+
transition: "background 150ms ease, color 150ms ease",
+
fontFamily: "system-ui, sans-serif",
+
justifyContent: "space-between",
+
transition: "background 200ms ease, transform 150ms ease",
+
export default GrainGalleryRenderer;