tangled
alpha
login
or
join now
nekomimi.pet
/
atproto-ui
41
fork
atom
A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
41
fork
atom
overview
issues
2
pulls
pipelines
finish lightbox
@nekomimi.pet
4 months ago
534d313a
ed4600ac
+211
-16
1 changed file
expand all
collapse all
unified
split
lib
renderers
GrainGalleryRenderer.tsx
+211
-16
lib/renderers/GrainGalleryRenderer.tsx
···
41
41
42
42
const primaryName = authorDisplayName || authorHandle || "…";
43
43
44
44
+
// Memoize sorted photos to prevent re-sorting on every render
45
45
+
const sortedPhotos = React.useMemo(
46
46
+
() => [...photos].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)),
47
47
+
[photos]
48
48
+
);
49
49
+
44
50
// Open lightbox
45
51
const openLightbox = React.useCallback((photoIndex: number) => {
46
52
setLightboxPhotoIndex(photoIndex);
···
74
80
window.addEventListener("keydown", handleKeyDown);
75
81
return () => window.removeEventListener("keydown", handleKeyDown);
76
82
}, [lightboxOpen, closeLightbox, goToPrevPhoto, goToNextPhoto]);
77
77
-
78
78
-
// Memoize sorted photos to prevent re-sorting on every render
79
79
-
const sortedPhotos = React.useMemo(
80
80
-
() => [...photos].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)),
81
81
-
[photos]
82
82
-
);
83
83
84
84
const isSinglePhoto = sortedPhotos.length === 1;
85
85
···
197
197
198
198
{isSinglePhoto ? (
199
199
<div style={styles.singlePhotoContainer}>
200
200
-
<GalleryPhotoItem key={`${sortedPhotos[0].did}-${sortedPhotos[0].rkey}`} photo={sortedPhotos[0]} isSingle={true} />
200
200
+
<GalleryPhotoItem
201
201
+
key={`${sortedPhotos[0].did}-${sortedPhotos[0].rkey}`}
202
202
+
photo={sortedPhotos[0]}
203
203
+
isSingle={true}
204
204
+
onClick={() => openLightbox(0)}
205
205
+
/>
201
206
</div>
202
207
) : (
203
208
<div style={styles.carouselContainer}>
···
219
224
</button>
220
225
)}
221
226
<div style={styles.photosGrid}>
222
222
-
{layoutPhotos.map((item) => (
223
223
-
<GalleryPhotoItem
224
224
-
key={`${item.did}-${item.rkey}`}
225
225
-
photo={item}
226
226
-
isSingle={false}
227
227
-
span={item.span}
228
228
-
/>
229
229
-
))}
227
227
+
{layoutPhotos.map((item) => {
228
228
+
const photoIndex = sortedPhotos.findIndex(p => p.did === item.did && p.rkey === item.rkey);
229
229
+
return (
230
230
+
<GalleryPhotoItem
231
231
+
key={`${item.did}-${item.rkey}`}
232
232
+
photo={item}
233
233
+
isSingle={false}
234
234
+
span={item.span}
235
235
+
onClick={() => openLightbox(photoIndex)}
236
236
+
/>
237
237
+
);
238
238
+
})}
230
239
</div>
231
240
{hasMultiplePages && currentPage < totalPages - 1 && (
232
241
<button
···
484
493
return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } }));
485
494
};
486
495
496
496
+
// Lightbox component for fullscreen image viewing
497
497
+
const Lightbox: React.FC<{
498
498
+
photo: GrainGalleryPhoto;
499
499
+
photoIndex: number;
500
500
+
totalPhotos: number;
501
501
+
onClose: () => void;
502
502
+
onNext: () => void;
503
503
+
onPrev: () => void;
504
504
+
}> = ({ photo, photoIndex, totalPhotos, onClose, onNext, onPrev }) => {
505
505
+
const photoBlob = photo.record.photo;
506
506
+
const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined;
507
507
+
const cid = cdnUrl ? undefined : extractCidFromBlob(photoBlob);
508
508
+
const { url: urlFromBlob, loading: photoLoading, error: photoError } = useBlob(photo.did, cid);
509
509
+
const url = cdnUrl || urlFromBlob;
510
510
+
const alt = photo.record.alt?.trim() || "grain.social photo";
511
511
+
512
512
+
return (
513
513
+
<div
514
514
+
style={{
515
515
+
position: "fixed",
516
516
+
top: 0,
517
517
+
left: 0,
518
518
+
right: 0,
519
519
+
bottom: 0,
520
520
+
background: "rgba(0, 0, 0, 0.95)",
521
521
+
zIndex: 9999,
522
522
+
display: "flex",
523
523
+
alignItems: "center",
524
524
+
justifyContent: "center",
525
525
+
padding: 20,
526
526
+
}}
527
527
+
onClick={onClose}
528
528
+
>
529
529
+
{/* Close button */}
530
530
+
<button
531
531
+
onClick={onClose}
532
532
+
style={{
533
533
+
position: "absolute",
534
534
+
top: 20,
535
535
+
right: 20,
536
536
+
width: 40,
537
537
+
height: 40,
538
538
+
border: "none",
539
539
+
borderRadius: "50%",
540
540
+
background: "rgba(255, 255, 255, 0.1)",
541
541
+
color: "white",
542
542
+
fontSize: 24,
543
543
+
cursor: "pointer",
544
544
+
display: "flex",
545
545
+
alignItems: "center",
546
546
+
justifyContent: "center",
547
547
+
transition: "background 200ms ease",
548
548
+
}}
549
549
+
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")}
550
550
+
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")}
551
551
+
aria-label="Close lightbox"
552
552
+
>
553
553
+
×
554
554
+
</button>
555
555
+
556
556
+
{/* Previous button */}
557
557
+
{totalPhotos > 1 && (
558
558
+
<button
559
559
+
onClick={(e) => {
560
560
+
e.stopPropagation();
561
561
+
onPrev();
562
562
+
}}
563
563
+
style={{
564
564
+
position: "absolute",
565
565
+
left: 20,
566
566
+
top: "50%",
567
567
+
transform: "translateY(-50%)",
568
568
+
width: 50,
569
569
+
height: 50,
570
570
+
border: "none",
571
571
+
borderRadius: "50%",
572
572
+
background: "rgba(255, 255, 255, 0.1)",
573
573
+
color: "white",
574
574
+
fontSize: 24,
575
575
+
cursor: "pointer",
576
576
+
display: "flex",
577
577
+
alignItems: "center",
578
578
+
justifyContent: "center",
579
579
+
transition: "background 200ms ease",
580
580
+
}}
581
581
+
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")}
582
582
+
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")}
583
583
+
aria-label="Previous photo"
584
584
+
>
585
585
+
‹
586
586
+
</button>
587
587
+
)}
588
588
+
589
589
+
{/* Next button */}
590
590
+
{totalPhotos > 1 && (
591
591
+
<button
592
592
+
onClick={(e) => {
593
593
+
e.stopPropagation();
594
594
+
onNext();
595
595
+
}}
596
596
+
style={{
597
597
+
position: "absolute",
598
598
+
right: 20,
599
599
+
top: "50%",
600
600
+
transform: "translateY(-50%)",
601
601
+
width: 50,
602
602
+
height: 50,
603
603
+
border: "none",
604
604
+
borderRadius: "50%",
605
605
+
background: "rgba(255, 255, 255, 0.1)",
606
606
+
color: "white",
607
607
+
fontSize: 24,
608
608
+
cursor: "pointer",
609
609
+
display: "flex",
610
610
+
alignItems: "center",
611
611
+
justifyContent: "center",
612
612
+
transition: "background 200ms ease",
613
613
+
}}
614
614
+
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")}
615
615
+
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")}
616
616
+
aria-label="Next photo"
617
617
+
>
618
618
+
›
619
619
+
</button>
620
620
+
)}
621
621
+
622
622
+
{/* Image */}
623
623
+
<div
624
624
+
style={{
625
625
+
maxWidth: "90vw",
626
626
+
maxHeight: "90vh",
627
627
+
display: "flex",
628
628
+
alignItems: "center",
629
629
+
justifyContent: "center",
630
630
+
}}
631
631
+
onClick={(e) => e.stopPropagation()}
632
632
+
>
633
633
+
{url ? (
634
634
+
<img
635
635
+
src={url}
636
636
+
alt={alt}
637
637
+
style={{
638
638
+
maxWidth: "100%",
639
639
+
maxHeight: "100%",
640
640
+
objectFit: "contain",
641
641
+
borderRadius: 8,
642
642
+
}}
643
643
+
/>
644
644
+
) : (
645
645
+
<div
646
646
+
style={{
647
647
+
color: "white",
648
648
+
fontSize: 16,
649
649
+
textAlign: "center",
650
650
+
}}
651
651
+
>
652
652
+
{photoLoading ? "Loading…" : photoError ? "Failed to load" : "Unavailable"}
653
653
+
</div>
654
654
+
)}
655
655
+
</div>
656
656
+
657
657
+
{/* Photo counter */}
658
658
+
{totalPhotos > 1 && (
659
659
+
<div
660
660
+
style={{
661
661
+
position: "absolute",
662
662
+
bottom: 20,
663
663
+
left: "50%",
664
664
+
transform: "translateX(-50%)",
665
665
+
color: "white",
666
666
+
fontSize: 14,
667
667
+
background: "rgba(0, 0, 0, 0.5)",
668
668
+
padding: "8px 16px",
669
669
+
borderRadius: 20,
670
670
+
}}
671
671
+
>
672
672
+
{photoIndex + 1} / {totalPhotos}
673
673
+
</div>
674
674
+
)}
675
675
+
</div>
676
676
+
);
677
677
+
};
678
678
+
487
679
const GalleryPhotoItem: React.FC<{
488
680
photo: GrainGalleryPhoto;
489
681
isSingle: boolean;
490
682
span?: { row: number; col: number };
491
491
-
}> = ({ photo, isSingle, span }) => {
683
683
+
onClick?: () => void;
684
684
+
}> = ({ photo, isSingle, span, onClick }) => {
492
685
const [showAltText, setShowAltText] = React.useState(false);
493
686
const photoBlob = photo.record.photo;
494
687
const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined;
···
518
711
background: `var(--atproto-color-image-bg)`,
519
712
// Only apply aspect ratio for single photos; grid photos fill their cells
520
713
...(isSingle && aspect ? { aspectRatio: aspect } : {}),
714
714
+
cursor: onClick ? "pointer" : "default",
521
715
}}
716
716
+
onClick={onClick}
522
717
>
523
718
{url ? (
524
719
<img src={url} alt={alt} style={isSingle ? styles.photo : styles.photoGrid} />