A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
40
fork

Configure Feed

Select the types of activity you want to include in your feed.

finish lightbox

+211 -16
+211 -16
lib/renderers/GrainGalleryRenderer.tsx
··· 41 41 42 42 const primaryName = authorDisplayName || authorHandle || "…"; 43 43 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)), 47 + [photos] 48 + ); 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 - 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)), 81 - [photos] 82 - ); 83 83 84 84 const isSinglePhoto = sortedPhotos.length === 1; 85 85 ··· 197 197 198 198 {isSinglePhoto ? ( 199 199 <div style={styles.singlePhotoContainer}> 200 - <GalleryPhotoItem key={`${sortedPhotos[0].did}-${sortedPhotos[0].rkey}`} photo={sortedPhotos[0]} isSingle={true} /> 200 + <GalleryPhotoItem 201 + key={`${sortedPhotos[0].did}-${sortedPhotos[0].rkey}`} 202 + photo={sortedPhotos[0]} 203 + isSingle={true} 204 + onClick={() => openLightbox(0)} 205 + /> 201 206 </div> 202 207 ) : ( 203 208 <div style={styles.carouselContainer}> ··· 219 224 </button> 220 225 )} 221 226 <div style={styles.photosGrid}> 222 - {layoutPhotos.map((item) => ( 223 - <GalleryPhotoItem 224 - key={`${item.did}-${item.rkey}`} 225 - photo={item} 226 - isSingle={false} 227 - span={item.span} 228 - /> 229 - ))} 227 + {layoutPhotos.map((item) => { 228 + const photoIndex = sortedPhotos.findIndex(p => p.did === item.did && p.rkey === item.rkey); 229 + return ( 230 + <GalleryPhotoItem 231 + key={`${item.did}-${item.rkey}`} 232 + photo={item} 233 + isSingle={false} 234 + span={item.span} 235 + onClick={() => openLightbox(photoIndex)} 236 + /> 237 + ); 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 + // 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"; 511 + 512 + return ( 513 + <div 514 + style={{ 515 + position: "fixed", 516 + top: 0, 517 + left: 0, 518 + right: 0, 519 + bottom: 0, 520 + background: "rgba(0, 0, 0, 0.95)", 521 + zIndex: 9999, 522 + display: "flex", 523 + alignItems: "center", 524 + justifyContent: "center", 525 + padding: 20, 526 + }} 527 + onClick={onClose} 528 + > 529 + {/* Close button */} 530 + <button 531 + onClick={onClose} 532 + style={{ 533 + position: "absolute", 534 + top: 20, 535 + right: 20, 536 + width: 40, 537 + height: 40, 538 + border: "none", 539 + borderRadius: "50%", 540 + background: "rgba(255, 255, 255, 0.1)", 541 + color: "white", 542 + fontSize: 24, 543 + cursor: "pointer", 544 + display: "flex", 545 + alignItems: "center", 546 + justifyContent: "center", 547 + transition: "background 200ms ease", 548 + }} 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" 552 + > 553 + × 554 + </button> 555 + 556 + {/* Previous button */} 557 + {totalPhotos > 1 && ( 558 + <button 559 + onClick={(e) => { 560 + e.stopPropagation(); 561 + onPrev(); 562 + }} 563 + style={{ 564 + position: "absolute", 565 + left: 20, 566 + top: "50%", 567 + transform: "translateY(-50%)", 568 + width: 50, 569 + height: 50, 570 + border: "none", 571 + borderRadius: "50%", 572 + background: "rgba(255, 255, 255, 0.1)", 573 + color: "white", 574 + fontSize: 24, 575 + cursor: "pointer", 576 + display: "flex", 577 + alignItems: "center", 578 + justifyContent: "center", 579 + transition: "background 200ms ease", 580 + }} 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" 584 + > 585 + 586 + </button> 587 + )} 588 + 589 + {/* Next button */} 590 + {totalPhotos > 1 && ( 591 + <button 592 + onClick={(e) => { 593 + e.stopPropagation(); 594 + onNext(); 595 + }} 596 + style={{ 597 + position: "absolute", 598 + right: 20, 599 + top: "50%", 600 + transform: "translateY(-50%)", 601 + width: 50, 602 + height: 50, 603 + border: "none", 604 + borderRadius: "50%", 605 + background: "rgba(255, 255, 255, 0.1)", 606 + color: "white", 607 + fontSize: 24, 608 + cursor: "pointer", 609 + display: "flex", 610 + alignItems: "center", 611 + justifyContent: "center", 612 + transition: "background 200ms ease", 613 + }} 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" 617 + > 618 + 619 + </button> 620 + )} 621 + 622 + {/* Image */} 623 + <div 624 + style={{ 625 + maxWidth: "90vw", 626 + maxHeight: "90vh", 627 + display: "flex", 628 + alignItems: "center", 629 + justifyContent: "center", 630 + }} 631 + onClick={(e) => e.stopPropagation()} 632 + > 633 + {url ? ( 634 + <img 635 + src={url} 636 + alt={alt} 637 + style={{ 638 + maxWidth: "100%", 639 + maxHeight: "100%", 640 + objectFit: "contain", 641 + borderRadius: 8, 642 + }} 643 + /> 644 + ) : ( 645 + <div 646 + style={{ 647 + color: "white", 648 + fontSize: 16, 649 + textAlign: "center", 650 + }} 651 + > 652 + {photoLoading ? "Loading…" : photoError ? "Failed to load" : "Unavailable"} 653 + </div> 654 + )} 655 + </div> 656 + 657 + {/* Photo counter */} 658 + {totalPhotos > 1 && ( 659 + <div 660 + style={{ 661 + position: "absolute", 662 + bottom: 20, 663 + left: "50%", 664 + transform: "translateX(-50%)", 665 + color: "white", 666 + fontSize: 14, 667 + background: "rgba(0, 0, 0, 0.5)", 668 + padding: "8px 16px", 669 + borderRadius: 20, 670 + }} 671 + > 672 + {photoIndex + 1} / {totalPhotos} 673 + </div> 674 + )} 675 + </div> 676 + ); 677 + }; 678 + 487 679 const GalleryPhotoItem: React.FC<{ 488 680 photo: GrainGalleryPhoto; 489 681 isSingle: boolean; 490 682 span?: { row: number; col: number }; 491 - }> = ({ photo, isSingle, span }) => { 683 + onClick?: () => void; 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 + cursor: onClick ? "pointer" : "default", 521 715 }} 716 + onClick={onClick} 522 717 > 523 718 {url ? ( 524 719 <img src={url} alt={alt} style={isSingle ? styles.photo : styles.photoGrid} />