···
1
+
import React from "react";
2
+
import type { GrainGalleryRecord, GrainPhotoRecord } from "../types/grain";
3
+
import { useBlob } from "../hooks/useBlob";
4
+
import { isBlobWithCdn, extractCidFromBlob } from "../utils/blob";
6
+
export interface GrainGalleryPhoto {
7
+
record: GrainPhotoRecord;
13
+
export interface GrainGalleryRendererProps {
14
+
gallery: GrainGalleryRecord;
15
+
photos: GrainGalleryPhoto[];
18
+
authorHandle?: string;
19
+
authorDisplayName?: string;
23
+
export const GrainGalleryRenderer: React.FC<GrainGalleryRendererProps> = ({
32
+
const [currentPage, setCurrentPage] = React.useState(0);
33
+
const [lightboxOpen, setLightboxOpen] = React.useState(false);
34
+
const [lightboxPhotoIndex, setLightboxPhotoIndex] = React.useState(0);
36
+
const createdDate = new Date(gallery.createdAt);
37
+
const created = createdDate.toLocaleString(undefined, {
38
+
dateStyle: "medium",
42
+
const primaryName = authorDisplayName || authorHandle || "…";
45
+
const openLightbox = React.useCallback((photoIndex: number) => {
46
+
setLightboxPhotoIndex(photoIndex);
47
+
setLightboxOpen(true);
51
+
const closeLightbox = React.useCallback(() => {
52
+
setLightboxOpen(false);
55
+
// Navigate lightbox
56
+
const goToNextPhoto = React.useCallback(() => {
57
+
setLightboxPhotoIndex((prev) => (prev + 1) % sortedPhotos.length);
58
+
}, [sortedPhotos.length]);
60
+
const goToPrevPhoto = React.useCallback(() => {
61
+
setLightboxPhotoIndex((prev) => (prev - 1 + sortedPhotos.length) % sortedPhotos.length);
62
+
}, [sortedPhotos.length]);
64
+
// Keyboard navigation
65
+
React.useEffect(() => {
66
+
if (!lightboxOpen) return;
68
+
const handleKeyDown = (e: KeyboardEvent) => {
69
+
if (e.key === "Escape") closeLightbox();
70
+
if (e.key === "ArrowLeft") goToPrevPhoto();
71
+
if (e.key === "ArrowRight") goToNextPhoto();
74
+
window.addEventListener("keydown", handleKeyDown);
75
+
return () => window.removeEventListener("keydown", handleKeyDown);
76
+
}, [lightboxOpen, closeLightbox, goToPrevPhoto, goToNextPhoto]);
78
+
// Memoize sorted photos to prevent re-sorting on every render
79
+
const sortedPhotos = React.useMemo(
80
+
() => [...photos].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)),
84
+
const isSinglePhoto = sortedPhotos.length === 1;
86
+
// Preload all photos to avoid loading states when paginating
87
+
usePreloadAllPhotos(sortedPhotos);
89
+
// Reset to first page when photos change
90
+
React.useEffect(() => {
92
+
}, [sortedPhotos.length]);
94
+
// Memoize pagination calculations with intelligent photo count per page
95
+
const paginationData = React.useMemo(() => {
96
+
const pages = calculatePages(sortedPhotos);
97
+
const totalPages = pages.length;
98
+
const visiblePhotos = pages[currentPage] || [];
99
+
const hasMultiplePages = totalPages > 1;
100
+
const layoutPhotos = calculateLayout(visiblePhotos);
109
+
}, [sortedPhotos, currentPage]);
111
+
const { totalPages, hasMultiplePages, layoutPhotos } = paginationData;
113
+
// Memoize navigation handlers to prevent re-creation
114
+
const goToNextPage = React.useCallback(() => {
115
+
setCurrentPage((prev) => (prev + 1) % totalPages);
118
+
const goToPrevPage = React.useCallback(() => {
119
+
setCurrentPage((prev) => (prev - 1 + totalPages) % totalPages);
124
+
<div style={{ padding: 8, color: "crimson" }}>
125
+
Failed to load gallery.
130
+
if (loading && photos.length === 0) {
131
+
return <div style={{ padding: 8 }}>Loading gallery…</div>;
136
+
{/* Hidden preload elements for all photos */}
137
+
<div style={{ display: "none" }} aria-hidden>
138
+
{sortedPhotos.map((photo) => (
139
+
<PreloadPhoto key={`${photo.did}-${photo.rkey}-preload`} photo={photo} />
146
+
photo={sortedPhotos[lightboxPhotoIndex]}
147
+
photoIndex={lightboxPhotoIndex}
148
+
totalPhotos={sortedPhotos.length}
149
+
onClose={closeLightbox}
150
+
onNext={goToNextPhoto}
151
+
onPrev={goToPrevPhoto}
155
+
<article style={styles.card}>
156
+
<header style={styles.header}>
158
+
<img src={avatarUrl} alt="avatar" style={styles.avatarImg} />
160
+
<div style={styles.avatarPlaceholder} aria-hidden />
162
+
<div style={styles.authorInfo}>
163
+
<strong style={styles.displayName}>{primaryName}</strong>
168
+
color: `var(--atproto-color-text-secondary)`,
177
+
<div style={styles.galleryInfo}>
181
+
color: `var(--atproto-color-text)`,
186
+
{gallery.description && (
189
+
...styles.description,
190
+
color: `var(--atproto-color-text-secondary)`,
193
+
{gallery.description}
199
+
<div style={styles.singlePhotoContainer}>
200
+
<GalleryPhotoItem key={`${sortedPhotos[0].did}-${sortedPhotos[0].rkey}`} photo={sortedPhotos[0]} isSingle={true} />
203
+
<div style={styles.carouselContainer}>
204
+
{hasMultiplePages && currentPage > 0 && (
206
+
onClick={goToPrevPage}
207
+
onMouseEnter={(e) => (e.currentTarget.style.opacity = "1")}
208
+
onMouseLeave={(e) => (e.currentTarget.style.opacity = "0.7")}
210
+
...styles.navButton,
211
+
...styles.navButtonLeft,
213
+
background: "rgba(0, 0, 0, 0.5)",
214
+
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
216
+
aria-label="Previous photos"
221
+
<div style={styles.photosGrid}>
222
+
{layoutPhotos.map((item) => (
224
+
key={`${item.did}-${item.rkey}`}
231
+
{hasMultiplePages && currentPage < totalPages - 1 && (
233
+
onClick={goToNextPage}
234
+
onMouseEnter={(e) => (e.currentTarget.style.opacity = "1")}
235
+
onMouseLeave={(e) => (e.currentTarget.style.opacity = "0.7")}
237
+
...styles.navButton,
238
+
...styles.navButtonRight,
240
+
background: "rgba(0, 0, 0, 0.5)",
241
+
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
243
+
aria-label="Next photos"
251
+
<footer style={styles.footer}>
255
+
color: `var(--atproto-color-text-muted)`,
257
+
dateTime={gallery.createdAt}
261
+
{hasMultiplePages && !isSinglePhoto && (
262
+
<div style={styles.paginationDots}>
263
+
{Array.from({ length: totalPages }, (_, i) => (
266
+
onClick={() => setCurrentPage(i)}
268
+
...styles.paginationDot,
269
+
background: i === currentPage
270
+
? `var(--atproto-color-text)`
271
+
: `var(--atproto-color-border)`,
273
+
aria-label={`Go to page ${i + 1}`}
274
+
aria-current={i === currentPage ? "page" : undefined}
285
+
// Component to preload a single photo's blob
286
+
const PreloadPhoto: React.FC<{ photo: GrainGalleryPhoto }> = ({ photo }) => {
287
+
const photoBlob = photo.record.photo;
288
+
const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined;
289
+
const cid = cdnUrl ? undefined : extractCidFromBlob(photoBlob);
291
+
// Trigger blob loading via the hook
292
+
useBlob(photo.did, cid);
294
+
// Preload CDN images via Image element
295
+
React.useEffect(() => {
297
+
const img = new Image();
305
+
// Hook to preload all photos (CDN-based)
306
+
const usePreloadAllPhotos = (photos: GrainGalleryPhoto[]) => {
307
+
React.useEffect(() => {
308
+
// Preload CDN images
309
+
photos.forEach((photo) => {
310
+
const photoBlob = photo.record.photo;
311
+
const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined;
314
+
const img = new Image();
321
+
// Calculate pages with intelligent photo count (1, 2, or 3)
322
+
// Only includes multiple photos when they fit well together
323
+
const calculatePages = (photos: GrainGalleryPhoto[]): GrainGalleryPhoto[][] => {
324
+
if (photos.length === 0) return [];
325
+
if (photos.length === 1) return [[photos[0]]];
327
+
const pages: GrainGalleryPhoto[][] = [];
330
+
while (i < photos.length) {
331
+
const remaining = photos.length - i;
333
+
// Only one photo left - use it
334
+
if (remaining === 1) {
335
+
pages.push([photos[i]]);
339
+
// Check if next 3 photos can fit well together
340
+
if (remaining >= 3) {
341
+
const nextThree = photos.slice(i, i + 3);
342
+
if (canFitThreePhotos(nextThree)) {
343
+
pages.push(nextThree);
349
+
// Check if next 2 photos can fit well together
350
+
if (remaining >= 2) {
351
+
const nextTwo = photos.slice(i, i + 2);
352
+
if (canFitTwoPhotos(nextTwo)) {
353
+
pages.push(nextTwo);
359
+
// Photos don't fit well together, use 1 per page
360
+
pages.push([photos[i]]);
367
+
// Helper functions for aspect ratio classification
368
+
const isPortrait = (ratio: number) => ratio < 0.8;
369
+
const isLandscape = (ratio: number) => ratio > 1.2;
370
+
const isSquarish = (ratio: number) => ratio >= 0.8 && ratio <= 1.2;
372
+
// Determine if 2 photos can fit well together side by side
373
+
const canFitTwoPhotos = (photos: GrainGalleryPhoto[]): boolean => {
374
+
if (photos.length !== 2) return false;
376
+
const ratios = photos.map((p) => {
377
+
const ar = p.record.aspectRatio;
378
+
return ar ? ar.width / ar.height : 1;
381
+
const [r1, r2] = ratios;
383
+
// Two portraits side by side don't work well (too narrow)
384
+
if (isPortrait(r1) && isPortrait(r2)) return false;
386
+
// Portrait + landscape/square creates awkward layout
387
+
if (isPortrait(r1) && !isPortrait(r2)) return false;
388
+
if (!isPortrait(r1) && isPortrait(r2)) return false;
390
+
// Two landscape or two squarish photos work well
391
+
if ((isLandscape(r1) || isSquarish(r1)) && (isLandscape(r2) || isSquarish(r2))) {
395
+
// Default to not fitting
399
+
// Determine if 3 photos can fit well together in a layout
400
+
const canFitThreePhotos = (photos: GrainGalleryPhoto[]): boolean => {
401
+
if (photos.length !== 3) return false;
403
+
const ratios = photos.map((p) => {
404
+
const ar = p.record.aspectRatio;
405
+
return ar ? ar.width / ar.height : 1;
408
+
const [r1, r2, r3] = ratios;
410
+
// Good pattern: one portrait, two landscape/square
411
+
if (isPortrait(r1) && !isPortrait(r2) && !isPortrait(r3)) return true;
412
+
if (isPortrait(r3) && !isPortrait(r1) && !isPortrait(r2)) return true;
414
+
// Good pattern: all similar aspect ratios (all landscape or all squarish)
415
+
const allLandscape = ratios.every(isLandscape);
416
+
const allSquarish = ratios.every(isSquarish);
417
+
if (allLandscape || allSquarish) return true;
419
+
// Three portraits in a row can work
420
+
const allPortrait = ratios.every(isPortrait);
421
+
if (allPortrait) return true;
423
+
// Otherwise don't fit 3 together
427
+
// Layout calculator for intelligent photo grid arrangement
428
+
const calculateLayout = (photos: GrainGalleryPhoto[]) => {
429
+
if (photos.length === 0) return [];
430
+
if (photos.length === 1) {
431
+
return [{ ...photos[0], span: { row: 2, col: 2 } }];
434
+
const photosWithRatios = photos.map((photo) => {
435
+
const ratio = photo.record.aspectRatio
436
+
? photo.record.aspectRatio.width / photo.record.aspectRatio.height
441
+
isPortrait: isPortrait(ratio),
442
+
isLandscape: isLandscape(ratio)
446
+
// For 2 photos: side by side
447
+
if (photos.length === 2) {
448
+
return photosWithRatios.map((p) => ({ ...p, span: { row: 2, col: 1 } }));
451
+
// For 3 photos: try to create a balanced layout
452
+
if (photos.length === 3) {
453
+
const [p1, p2, p3] = photosWithRatios;
455
+
// Pattern 1: One tall on left, two stacked on right
456
+
if (p1.isPortrait && !p2.isPortrait && !p3.isPortrait) {
458
+
{ ...p1, span: { row: 2, col: 1 } },
459
+
{ ...p2, span: { row: 1, col: 1 } },
460
+
{ ...p3, span: { row: 1, col: 1 } },
464
+
// Pattern 2: Two stacked on left, one tall on right
465
+
if (!p1.isPortrait && !p2.isPortrait && p3.isPortrait) {
467
+
{ ...p1, span: { row: 1, col: 1 } },
468
+
{ ...p2, span: { row: 1, col: 1 } },
469
+
{ ...p3, span: { row: 2, col: 1 } },
473
+
// Pattern 3: All in a row
474
+
const allPortrait = photosWithRatios.every((p) => p.isPortrait);
476
+
// All portraits: display in a row with smaller cells
477
+
return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } }));
480
+
// Default: All three in a row
481
+
return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } }));
484
+
return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } }));
487
+
const GalleryPhotoItem: React.FC<{
488
+
photo: GrainGalleryPhoto;
490
+
span?: { row: number; col: number };
491
+
}> = ({ photo, isSingle, span }) => {
492
+
const [showAltText, setShowAltText] = React.useState(false);
493
+
const photoBlob = photo.record.photo;
494
+
const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined;
495
+
const cid = cdnUrl ? undefined : extractCidFromBlob(photoBlob);
496
+
const { url: urlFromBlob, loading: photoLoading, error: photoError } = useBlob(photo.did, cid);
497
+
const url = cdnUrl || urlFromBlob;
498
+
const alt = photo.record.alt?.trim() || "grain.social photo";
499
+
const hasAlt = photo.record.alt && photo.record.alt.trim().length > 0;
502
+
photo.record.aspectRatio && photo.record.aspectRatio.height > 0
503
+
? `${photo.record.aspectRatio.width} / ${photo.record.aspectRatio.height}`
506
+
const gridItemStyle = span
508
+
gridRow: `span ${span.row}`,
509
+
gridColumn: `span ${span.col}`,
514
+
<figure style={{ ...(isSingle ? styles.singlePhotoItem : styles.photoItem), ...gridItemStyle }}>
517
+
...(isSingle ? styles.singlePhotoMedia : styles.photoContainer),
518
+
background: `var(--atproto-color-image-bg)`,
519
+
// Only apply aspect ratio for single photos; grid photos fill their cells
520
+
...(isSingle && aspect ? { aspectRatio: aspect } : {}),
524
+
<img src={url} alt={alt} style={isSingle ? styles.photo : styles.photoGrid} />
528
+
...styles.placeholder,
529
+
color: `var(--atproto-color-text-muted)`,
541
+
onClick={() => setShowAltText(!showAltText)}
543
+
...styles.altBadge,
544
+
background: showAltText
545
+
? `var(--atproto-color-text)`
546
+
: `var(--atproto-color-bg-secondary)`,
548
+
? `var(--atproto-color-bg)`
549
+
: `var(--atproto-color-text)`,
551
+
title="Toggle alt text"
552
+
aria-label="Toggle alt text"
558
+
{hasAlt && showAltText && (
562
+
color: `var(--atproto-color-text-secondary)`,
572
+
const styles: Record<string, React.CSSProperties> = {
575
+
border: `1px solid var(--atproto-color-border)`,
576
+
background: `var(--atproto-color-bg)`,
577
+
color: `var(--atproto-color-text)`,
578
+
fontFamily: "system-ui, sans-serif",
580
+
flexDirection: "column",
583
+
"background-color 180ms ease, border-color 180ms ease, color 180ms ease",
584
+
overflow: "hidden",
588
+
alignItems: "center",
593
+
avatarPlaceholder: {
596
+
borderRadius: "50%",
597
+
background: `var(--atproto-color-border)`,
602
+
borderRadius: "50%",
603
+
objectFit: "cover",
607
+
flexDirection: "column",
631
+
whiteSpace: "pre-wrap",
633
+
singlePhotoContainer: {
636
+
carouselContainer: {
637
+
position: "relative",
642
+
gridTemplateColumns: "repeat(2, 1fr)",
643
+
gridTemplateRows: "repeat(2, 1fr)",
648
+
position: "absolute",
650
+
transform: "translateY(-50%)",
654
+
borderRadius: "50%",
659
+
alignItems: "center",
660
+
justifyContent: "center",
662
+
transition: "opacity 150ms ease",
663
+
userSelect: "none",
675
+
flexDirection: "column",
681
+
flexDirection: "column",
685
+
position: "relative",
688
+
overflow: "hidden",
691
+
singlePhotoMedia: {
692
+
position: "relative",
694
+
overflow: "hidden",
700
+
objectFit: "cover",
706
+
objectFit: "cover",
711
+
alignItems: "center",
712
+
justifyContent: "center",
721
+
padding: "0 12px 8px",
724
+
position: "absolute",
727
+
padding: "4px 8px",
730
+
letterSpacing: "0.5px",
734
+
transition: "background 150ms ease, color 150ms ease",
735
+
fontFamily: "system-ui, sans-serif",
741
+
justifyContent: "space-between",
742
+
alignItems: "center",
750
+
alignItems: "center",
755
+
borderRadius: "50%",
759
+
transition: "background 200ms ease, transform 150ms ease",
764
+
export default GrainGalleryRenderer;