···
const primaryName = authorDisplayName || authorHandle || "…";
const openLightbox = React.useCallback((photoIndex: number) => {
setLightboxPhotoIndex(photoIndex);
···
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;
···
<div style={styles.singlePhotoContainer}>
-
<GalleryPhotoItem key={`${sortedPhotos[0].did}-${sortedPhotos[0].rkey}`} photo={sortedPhotos[0]} isSingle={true} />
<div style={styles.carouselContainer}>
···
<div style={styles.photosGrid}>
-
{layoutPhotos.map((item) => (
-
key={`${item.did}-${item.rkey}`}
{hasMultiplePages && currentPage < totalPages - 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;
···
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} />
···
const primaryName = authorDisplayName || authorHandle || "…";
+
// 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 openLightbox = React.useCallback((photoIndex: number) => {
setLightboxPhotoIndex(photoIndex);
···
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [lightboxOpen, closeLightbox, goToPrevPhoto, goToNextPhoto]);
const isSinglePhoto = sortedPhotos.length === 1;
···
<div style={styles.singlePhotoContainer}>
+
key={`${sortedPhotos[0].did}-${sortedPhotos[0].rkey}`}
+
photo={sortedPhotos[0]}
+
onClick={() => openLightbox(0)}
<div style={styles.carouselContainer}>
···
<div style={styles.photosGrid}>
+
{layoutPhotos.map((item) => {
+
const photoIndex = sortedPhotos.findIndex(p => p.did === item.did && p.rkey === item.rkey);
+
key={`${item.did}-${item.rkey}`}
+
onClick={() => openLightbox(photoIndex)}
{hasMultiplePages && currentPage < totalPages - 1 && (
···
return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } }));
+
// Lightbox component for fullscreen image viewing
+
const Lightbox: React.FC<{
+
photo: GrainGalleryPhoto;
+
}> = ({ photo, photoIndex, totalPhotos, onClose, onNext, onPrev }) => {
+
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";
+
background: "rgba(0, 0, 0, 0.95)",
+
justifyContent: "center",
+
background: "rgba(255, 255, 255, 0.1)",
+
justifyContent: "center",
+
transition: "background 200ms ease",
+
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")}
+
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")}
+
aria-label="Close lightbox"
+
{/* Previous button */}
+
transform: "translateY(-50%)",
+
background: "rgba(255, 255, 255, 0.1)",
+
justifyContent: "center",
+
transition: "background 200ms ease",
+
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")}
+
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")}
+
aria-label="Previous photo"
+
transform: "translateY(-50%)",
+
background: "rgba(255, 255, 255, 0.1)",
+
justifyContent: "center",
+
transition: "background 200ms ease",
+
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")}
+
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")}
+
aria-label="Next photo"
+
justifyContent: "center",
+
onClick={(e) => e.stopPropagation()}
+
{photoLoading ? "Loading…" : photoError ? "Failed to load" : "Unavailable"}
+
transform: "translateX(-50%)",
+
background: "rgba(0, 0, 0, 0.5)",
+
{photoIndex + 1} / {totalPhotos}
const GalleryPhotoItem: React.FC<{
photo: GrainGalleryPhoto;
span?: { row: number; col: number };
+
}> = ({ 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 } : {}),
+
cursor: onClick ? "pointer" : "default",
<img src={url} alt={alt} style={isSingle ? styles.photo : styles.photoGrid} />