···
const primaryName = authorDisplayName || authorHandle || "…";
44
+
// Memoize sorted photos to prevent re-sorting on every render
45
+
const sortedPhotos = React.useMemo(
46
+
() => [...photos].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)),
const openLightbox = React.useCallback((photoIndex: number) => {
setLightboxPhotoIndex(photoIndex);
···
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [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)),
const isSinglePhoto = sortedPhotos.length === 1;
···
<div style={styles.singlePhotoContainer}>
200
-
<GalleryPhotoItem key={`${sortedPhotos[0].did}-${sortedPhotos[0].rkey}`} photo={sortedPhotos[0]} isSingle={true} />
201
+
key={`${sortedPhotos[0].did}-${sortedPhotos[0].rkey}`}
202
+
photo={sortedPhotos[0]}
204
+
onClick={() => openLightbox(0)}
<div style={styles.carouselContainer}>
···
<div style={styles.photosGrid}>
222
-
{layoutPhotos.map((item) => (
224
-
key={`${item.did}-${item.rkey}`}
227
+
{layoutPhotos.map((item) => {
228
+
const photoIndex = sortedPhotos.findIndex(p => p.did === item.did && p.rkey === item.rkey);
231
+
key={`${item.did}-${item.rkey}`}
235
+
onClick={() => openLightbox(photoIndex)}
{hasMultiplePages && currentPage < totalPages - 1 && (
···
return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } }));
496
+
// Lightbox component for fullscreen image viewing
497
+
const Lightbox: React.FC<{
498
+
photo: GrainGalleryPhoto;
499
+
photoIndex: number;
500
+
totalPhotos: number;
501
+
onClose: () => void;
502
+
onNext: () => void;
503
+
onPrev: () => void;
504
+
}> = ({ photo, photoIndex, totalPhotos, onClose, onNext, onPrev }) => {
505
+
const photoBlob = photo.record.photo;
506
+
const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined;
507
+
const cid = cdnUrl ? undefined : extractCidFromBlob(photoBlob);
508
+
const { url: urlFromBlob, loading: photoLoading, error: photoError } = useBlob(photo.did, cid);
509
+
const url = cdnUrl || urlFromBlob;
510
+
const alt = photo.record.alt?.trim() || "grain.social photo";
520
+
background: "rgba(0, 0, 0, 0.95)",
523
+
alignItems: "center",
524
+
justifyContent: "center",
529
+
{/* Close button */}
533
+
position: "absolute",
539
+
borderRadius: "50%",
540
+
background: "rgba(255, 255, 255, 0.1)",
545
+
alignItems: "center",
546
+
justifyContent: "center",
547
+
transition: "background 200ms ease",
549
+
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")}
550
+
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")}
551
+
aria-label="Close lightbox"
556
+
{/* Previous button */}
557
+
{totalPhotos > 1 && (
560
+
e.stopPropagation();
564
+
position: "absolute",
567
+
transform: "translateY(-50%)",
571
+
borderRadius: "50%",
572
+
background: "rgba(255, 255, 255, 0.1)",
577
+
alignItems: "center",
578
+
justifyContent: "center",
579
+
transition: "background 200ms ease",
581
+
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")}
582
+
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")}
583
+
aria-label="Previous photo"
589
+
{/* Next button */}
590
+
{totalPhotos > 1 && (
593
+
e.stopPropagation();
597
+
position: "absolute",
600
+
transform: "translateY(-50%)",
604
+
borderRadius: "50%",
605
+
background: "rgba(255, 255, 255, 0.1)",
610
+
alignItems: "center",
611
+
justifyContent: "center",
612
+
transition: "background 200ms ease",
614
+
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")}
615
+
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")}
616
+
aria-label="Next photo"
628
+
alignItems: "center",
629
+
justifyContent: "center",
631
+
onClick={(e) => e.stopPropagation()}
640
+
objectFit: "contain",
649
+
textAlign: "center",
652
+
{photoLoading ? "Loading…" : photoError ? "Failed to load" : "Unavailable"}
657
+
{/* Photo counter */}
658
+
{totalPhotos > 1 && (
661
+
position: "absolute",
664
+
transform: "translateX(-50%)",
667
+
background: "rgba(0, 0, 0, 0.5)",
668
+
padding: "8px 16px",
672
+
{photoIndex + 1} / {totalPhotos}
const GalleryPhotoItem: React.FC<{
photo: GrainGalleryPhoto;
span?: { row: number; col: number };
491
-
}> = ({ photo, isSingle, span }) => {
683
+
onClick?: () => void;
684
+
}> = ({ photo, isSingle, span, onClick }) => {
const [showAltText, setShowAltText] = React.useState(false);
const photoBlob = photo.record.photo;
const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined;
···
background: `var(--atproto-color-image-bg)`,
// Only apply aspect ratio for single photos; grid photos fill their cells
...(isSingle && aspect ? { aspectRatio: aspect } : {}),
714
+
cursor: onClick ? "pointer" : "default",
<img src={url} alt={alt} style={isSingle ? styles.photo : styles.photoGrid} />