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

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} />