A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React from "react"; 2import type { GrainGalleryRecord, GrainPhotoRecord } from "../types/grain"; 3import { useBlob } from "../hooks/useBlob"; 4import { isBlobWithCdn, extractCidFromBlob } from "../utils/blob"; 5 6export interface GrainGalleryPhoto { 7 record: GrainPhotoRecord; 8 did: string; 9 rkey: string; 10 position?: number; 11} 12 13export interface GrainGalleryRendererProps { 14 gallery: GrainGalleryRecord; 15 photos: GrainGalleryPhoto[]; 16 loading: boolean; 17 error?: Error; 18 authorHandle?: string; 19 authorDisplayName?: string; 20 avatarUrl?: string; 21} 22 23export const GrainGalleryRenderer: React.FC<GrainGalleryRendererProps> = ({ 24 gallery, 25 photos, 26 loading, 27 error, 28 authorDisplayName, 29 authorHandle, 30 avatarUrl, 31}) => { 32 const [currentPage, setCurrentPage] = React.useState(0); 33 const [lightboxOpen, setLightboxOpen] = React.useState(false); 34 const [lightboxPhotoIndex, setLightboxPhotoIndex] = React.useState(0); 35 36 const createdDate = new Date(gallery.createdAt); 37 const created = createdDate.toLocaleString(undefined, { 38 dateStyle: "medium", 39 timeStyle: "short", 40 }); 41 42 const primaryName = authorDisplayName || authorHandle || "…"; 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 50 // Open lightbox 51 const openLightbox = React.useCallback((photoIndex: number) => { 52 setLightboxPhotoIndex(photoIndex); 53 setLightboxOpen(true); 54 }, []); 55 56 // Close lightbox 57 const closeLightbox = React.useCallback(() => { 58 setLightboxOpen(false); 59 }, []); 60 61 // Navigate lightbox 62 const goToNextPhoto = React.useCallback(() => { 63 setLightboxPhotoIndex((prev) => (prev + 1) % sortedPhotos.length); 64 }, [sortedPhotos.length]); 65 66 const goToPrevPhoto = React.useCallback(() => { 67 setLightboxPhotoIndex((prev) => (prev - 1 + sortedPhotos.length) % sortedPhotos.length); 68 }, [sortedPhotos.length]); 69 70 // Keyboard navigation 71 React.useEffect(() => { 72 if (!lightboxOpen) return; 73 74 const handleKeyDown = (e: KeyboardEvent) => { 75 if (e.key === "Escape") closeLightbox(); 76 if (e.key === "ArrowLeft") goToPrevPhoto(); 77 if (e.key === "ArrowRight") goToNextPhoto(); 78 }; 79 80 window.addEventListener("keydown", handleKeyDown); 81 return () => window.removeEventListener("keydown", handleKeyDown); 82 }, [lightboxOpen, closeLightbox, goToPrevPhoto, goToNextPhoto]); 83 84 const isSinglePhoto = sortedPhotos.length === 1; 85 86 // Preload all photos to avoid loading states when paginating 87 usePreloadAllPhotos(sortedPhotos); 88 89 // Reset to first page when photos change 90 React.useEffect(() => { 91 setCurrentPage(0); 92 }, [sortedPhotos.length]); 93 94 // Memoize pagination calculations with intelligent photo count per page 95 const paginationData = React.useMemo(() => { 96 const pages = calculatePages(sortedPhotos); 97 const totalPages = pages.length; 98 const visiblePhotos = pages[currentPage] || []; 99 const hasMultiplePages = totalPages > 1; 100 const layoutPhotos = calculateLayout(visiblePhotos); 101 102 return { 103 pages, 104 totalPages, 105 visiblePhotos, 106 hasMultiplePages, 107 layoutPhotos, 108 }; 109 }, [sortedPhotos, currentPage]); 110 111 const { totalPages, hasMultiplePages, layoutPhotos } = paginationData; 112 113 // Memoize navigation handlers to prevent re-creation 114 const goToNextPage = React.useCallback(() => { 115 setCurrentPage((prev) => (prev + 1) % totalPages); 116 }, [totalPages]); 117 118 const goToPrevPage = React.useCallback(() => { 119 setCurrentPage((prev) => (prev - 1 + totalPages) % totalPages); 120 }, [totalPages]); 121 122 if (error) { 123 return ( 124 <div style={{ padding: 8, color: "crimson" }}> 125 Failed to load gallery. 126 </div> 127 ); 128 } 129 130 if (loading && photos.length === 0) { 131 return <div style={{ padding: 8 }}>Loading gallery</div>; 132 } 133 134 return ( 135 <> 136 {/* Hidden preload elements for all photos */} 137 <div style={{ display: "none" }} aria-hidden> 138 {sortedPhotos.map((photo) => ( 139 <PreloadPhoto key={`${photo.did}-${photo.rkey}-preload`} photo={photo} /> 140 ))} 141 </div> 142 143 {/* Lightbox */} 144 {lightboxOpen && ( 145 <Lightbox 146 photo={sortedPhotos[lightboxPhotoIndex]} 147 photoIndex={lightboxPhotoIndex} 148 totalPhotos={sortedPhotos.length} 149 onClose={closeLightbox} 150 onNext={goToNextPhoto} 151 onPrev={goToPrevPhoto} 152 /> 153 )} 154 155 <article style={styles.card}> 156 <header style={styles.header}> 157 {avatarUrl ? ( 158 <img src={avatarUrl} alt="avatar" style={styles.avatarImg} /> 159 ) : ( 160 <div style={styles.avatarPlaceholder} aria-hidden /> 161 )} 162 <div style={styles.authorInfo}> 163 <strong style={styles.displayName}>{primaryName}</strong> 164 {authorHandle && ( 165 <span 166 style={{ 167 ...styles.handle, 168 color: `var(--atproto-color-text-secondary)`, 169 }} 170 > 171 @{authorHandle} 172 </span> 173 )} 174 </div> 175 </header> 176 177 <div style={styles.galleryInfo}> 178 <h2 179 style={{ 180 ...styles.title, 181 color: `var(--atproto-color-text)`, 182 }} 183 > 184 {gallery.title} 185 </h2> 186 {gallery.description && ( 187 <p 188 style={{ 189 ...styles.description, 190 color: `var(--atproto-color-text-secondary)`, 191 }} 192 > 193 {gallery.description} 194 </p> 195 )} 196 </div> 197 198 {isSinglePhoto ? ( 199 <div style={styles.singlePhotoContainer}> 200 <GalleryPhotoItem 201 key={`${sortedPhotos[0].did}-${sortedPhotos[0].rkey}`} 202 photo={sortedPhotos[0]} 203 isSingle={true} 204 onClick={() => openLightbox(0)} 205 /> 206 </div> 207 ) : ( 208 <div style={styles.carouselContainer}> 209 {hasMultiplePages && currentPage > 0 && ( 210 <button 211 onClick={goToPrevPage} 212 onMouseEnter={(e) => (e.currentTarget.style.opacity = "1")} 213 onMouseLeave={(e) => (e.currentTarget.style.opacity = "0.7")} 214 style={{ 215 ...styles.navButton, 216 ...styles.navButtonLeft, 217 color: "white", 218 background: "rgba(0, 0, 0, 0.5)", 219 boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)", 220 }} 221 aria-label="Previous photos" 222 > 223 224 </button> 225 )} 226 <div style={styles.photosGrid}> 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 })} 239 </div> 240 {hasMultiplePages && currentPage < totalPages - 1 && ( 241 <button 242 onClick={goToNextPage} 243 onMouseEnter={(e) => (e.currentTarget.style.opacity = "1")} 244 onMouseLeave={(e) => (e.currentTarget.style.opacity = "0.7")} 245 style={{ 246 ...styles.navButton, 247 ...styles.navButtonRight, 248 color: "white", 249 background: "rgba(0, 0, 0, 0.5)", 250 boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)", 251 }} 252 aria-label="Next photos" 253 > 254 255 </button> 256 )} 257 </div> 258 )} 259 260 <footer style={styles.footer}> 261 <time 262 style={{ 263 ...styles.time, 264 color: `var(--atproto-color-text-muted)`, 265 }} 266 dateTime={gallery.createdAt} 267 > 268 {created} 269 </time> 270 {hasMultiplePages && !isSinglePhoto && ( 271 <div style={styles.paginationDots}> 272 {Array.from({ length: totalPages }, (_, i) => ( 273 <button 274 key={i} 275 onClick={() => setCurrentPage(i)} 276 style={{ 277 ...styles.paginationDot, 278 background: i === currentPage 279 ? `var(--atproto-color-text)` 280 : `var(--atproto-color-border)`, 281 }} 282 aria-label={`Go to page ${i + 1}`} 283 aria-current={i === currentPage ? "page" : undefined} 284 /> 285 ))} 286 </div> 287 )} 288 </footer> 289 </article> 290 </> 291 ); 292}; 293 294// Component to preload a single photo's blob 295const PreloadPhoto: React.FC<{ photo: GrainGalleryPhoto }> = ({ photo }) => { 296 const photoBlob = photo.record.photo; 297 const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined; 298 const cid = cdnUrl ? undefined : extractCidFromBlob(photoBlob); 299 300 // Trigger blob loading via the hook 301 useBlob(photo.did, cid); 302 303 // Preload CDN images via Image element 304 React.useEffect(() => { 305 if (cdnUrl) { 306 const img = new Image(); 307 img.src = cdnUrl; 308 } 309 }, [cdnUrl]); 310 311 return null; 312}; 313 314// Hook to preload all photos (CDN-based) 315const usePreloadAllPhotos = (photos: GrainGalleryPhoto[]) => { 316 React.useEffect(() => { 317 // Preload CDN images 318 photos.forEach((photo) => { 319 const photoBlob = photo.record.photo; 320 const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined; 321 322 if (cdnUrl) { 323 const img = new Image(); 324 img.src = cdnUrl; 325 } 326 }); 327 }, [photos]); 328}; 329 330// Calculate pages with intelligent photo count (1, 2, or 3) 331// Only includes multiple photos when they fit well together 332const calculatePages = (photos: GrainGalleryPhoto[]): GrainGalleryPhoto[][] => { 333 if (photos.length === 0) return []; 334 if (photos.length === 1) return [[photos[0]]]; 335 336 const pages: GrainGalleryPhoto[][] = []; 337 let i = 0; 338 339 while (i < photos.length) { 340 const remaining = photos.length - i; 341 342 // Only one photo left - use it 343 if (remaining === 1) { 344 pages.push([photos[i]]); 345 break; 346 } 347 348 // Check if next 3 photos can fit well together 349 if (remaining >= 3) { 350 const nextThree = photos.slice(i, i + 3); 351 if (canFitThreePhotos(nextThree)) { 352 pages.push(nextThree); 353 i += 3; 354 continue; 355 } 356 } 357 358 // Check if next 2 photos can fit well together 359 if (remaining >= 2) { 360 const nextTwo = photos.slice(i, i + 2); 361 if (canFitTwoPhotos(nextTwo)) { 362 pages.push(nextTwo); 363 i += 2; 364 continue; 365 } 366 } 367 368 // Photos don't fit well together, use 1 per page 369 pages.push([photos[i]]); 370 i += 1; 371 } 372 373 return pages; 374}; 375 376// Helper functions for aspect ratio classification 377const isPortrait = (ratio: number) => ratio < 0.8; 378const isLandscape = (ratio: number) => ratio > 1.2; 379const isSquarish = (ratio: number) => ratio >= 0.8 && ratio <= 1.2; 380 381// Determine if 2 photos can fit well together side by side 382const canFitTwoPhotos = (photos: GrainGalleryPhoto[]): boolean => { 383 if (photos.length !== 2) return false; 384 385 const ratios = photos.map((p) => { 386 const ar = p.record.aspectRatio; 387 return ar ? ar.width / ar.height : 1; 388 }); 389 390 const [r1, r2] = ratios; 391 392 // Two portraits side by side don't work well (too narrow) 393 if (isPortrait(r1) && isPortrait(r2)) return false; 394 395 // Portrait + landscape/square creates awkward layout 396 if (isPortrait(r1) && !isPortrait(r2)) return false; 397 if (!isPortrait(r1) && isPortrait(r2)) return false; 398 399 // Two landscape or two squarish photos work well 400 if ((isLandscape(r1) || isSquarish(r1)) && (isLandscape(r2) || isSquarish(r2))) { 401 return true; 402 } 403 404 // Default to not fitting 405 return false; 406}; 407 408// Determine if 3 photos can fit well together in a layout 409const canFitThreePhotos = (photos: GrainGalleryPhoto[]): boolean => { 410 if (photos.length !== 3) return false; 411 412 const ratios = photos.map((p) => { 413 const ar = p.record.aspectRatio; 414 return ar ? ar.width / ar.height : 1; 415 }); 416 417 const [r1, r2, r3] = ratios; 418 419 // Good pattern: one portrait, two landscape/square 420 if (isPortrait(r1) && !isPortrait(r2) && !isPortrait(r3)) return true; 421 if (isPortrait(r3) && !isPortrait(r1) && !isPortrait(r2)) return true; 422 423 // Good pattern: all similar aspect ratios (all landscape or all squarish) 424 const allLandscape = ratios.every(isLandscape); 425 const allSquarish = ratios.every(isSquarish); 426 if (allLandscape || allSquarish) return true; 427 428 // Three portraits in a row can work 429 const allPortrait = ratios.every(isPortrait); 430 if (allPortrait) return true; 431 432 // Otherwise don't fit 3 together 433 return false; 434}; 435 436// Layout calculator for intelligent photo grid arrangement 437const calculateLayout = (photos: GrainGalleryPhoto[]) => { 438 if (photos.length === 0) return []; 439 if (photos.length === 1) { 440 return [{ ...photos[0], span: { row: 2, col: 2 } }]; 441 } 442 443 const photosWithRatios = photos.map((photo) => { 444 const ratio = photo.record.aspectRatio 445 ? photo.record.aspectRatio.width / photo.record.aspectRatio.height 446 : 1; 447 return { 448 ...photo, 449 ratio, 450 isPortrait: isPortrait(ratio), 451 isLandscape: isLandscape(ratio) 452 }; 453 }); 454 455 // For 2 photos: side by side 456 if (photos.length === 2) { 457 return photosWithRatios.map((p) => ({ ...p, span: { row: 2, col: 1 } })); 458 } 459 460 // For 3 photos: try to create a balanced layout 461 if (photos.length === 3) { 462 const [p1, p2, p3] = photosWithRatios; 463 464 // Pattern 1: One tall on left, two stacked on right 465 if (p1.isPortrait && !p2.isPortrait && !p3.isPortrait) { 466 return [ 467 { ...p1, span: { row: 2, col: 1 } }, 468 { ...p2, span: { row: 1, col: 1 } }, 469 { ...p3, span: { row: 1, col: 1 } }, 470 ]; 471 } 472 473 // Pattern 2: Two stacked on left, one tall on right 474 if (!p1.isPortrait && !p2.isPortrait && p3.isPortrait) { 475 return [ 476 { ...p1, span: { row: 1, col: 1 } }, 477 { ...p2, span: { row: 1, col: 1 } }, 478 { ...p3, span: { row: 2, col: 1 } }, 479 ]; 480 } 481 482 // Pattern 3: All in a row 483 const allPortrait = photosWithRatios.every((p) => p.isPortrait); 484 if (allPortrait) { 485 // All portraits: display in a row with smaller cells 486 return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } })); 487 } 488 489 // Default: All three in a row 490 return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } })); 491 } 492 493 return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } })); 494}; 495 496// Lightbox component for fullscreen image viewing 497const 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 679const GalleryPhotoItem: React.FC<{ 680 photo: GrainGalleryPhoto; 681 isSingle: boolean; 682 span?: { row: number; col: number }; 683 onClick?: () => void; 684}> = ({ photo, isSingle, span, onClick }) => { 685 const [showAltText, setShowAltText] = React.useState(false); 686 const photoBlob = photo.record.photo; 687 const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined; 688 const cid = cdnUrl ? undefined : extractCidFromBlob(photoBlob); 689 const { url: urlFromBlob, loading: photoLoading, error: photoError } = useBlob(photo.did, cid); 690 const url = cdnUrl || urlFromBlob; 691 const alt = photo.record.alt?.trim() || "grain.social photo"; 692 const hasAlt = photo.record.alt && photo.record.alt.trim().length > 0; 693 694 const aspect = 695 photo.record.aspectRatio && photo.record.aspectRatio.height > 0 696 ? `${photo.record.aspectRatio.width} / ${photo.record.aspectRatio.height}` 697 : undefined; 698 699 const gridItemStyle = span 700 ? { 701 gridRow: `span ${span.row}`, 702 gridColumn: `span ${span.col}`, 703 } 704 : {}; 705 706 return ( 707 <figure style={{ ...(isSingle ? styles.singlePhotoItem : styles.photoItem), ...gridItemStyle }}> 708 <div 709 style={{ 710 ...(isSingle ? styles.singlePhotoMedia : styles.photoContainer), 711 background: `var(--atproto-color-image-bg)`, 712 // Only apply aspect ratio for single photos; grid photos fill their cells 713 ...(isSingle && aspect ? { aspectRatio: aspect } : {}), 714 cursor: onClick ? "pointer" : "default", 715 }} 716 onClick={onClick} 717 > 718 {url ? ( 719 <img src={url} alt={alt} style={isSingle ? styles.photo : styles.photoGrid} /> 720 ) : ( 721 <div 722 style={{ 723 ...styles.placeholder, 724 color: `var(--atproto-color-text-muted)`, 725 }} 726 > 727 {photoLoading 728 ? "Loading…" 729 : photoError 730 ? "Failed to load" 731 : "Unavailable"} 732 </div> 733 )} 734 {hasAlt && ( 735 <button 736 onClick={() => setShowAltText(!showAltText)} 737 style={{ 738 ...styles.altBadge, 739 background: showAltText 740 ? `var(--atproto-color-text)` 741 : `var(--atproto-color-bg-secondary)`, 742 color: showAltText 743 ? `var(--atproto-color-bg)` 744 : `var(--atproto-color-text)`, 745 }} 746 title="Toggle alt text" 747 aria-label="Toggle alt text" 748 > 749 ALT 750 </button> 751 )} 752 </div> 753 {hasAlt && showAltText && ( 754 <figcaption 755 style={{ 756 ...styles.caption, 757 color: `var(--atproto-color-text-secondary)`, 758 }} 759 > 760 {photo.record.alt} 761 </figcaption> 762 )} 763 </figure> 764 ); 765}; 766 767const styles: Record<string, React.CSSProperties> = { 768 card: { 769 borderRadius: 12, 770 border: `1px solid var(--atproto-color-border)`, 771 background: `var(--atproto-color-bg)`, 772 color: `var(--atproto-color-text)`, 773 fontFamily: "system-ui, sans-serif", 774 display: "flex", 775 flexDirection: "column", 776 maxWidth: 600, 777 transition: 778 "background-color 180ms ease, border-color 180ms ease, color 180ms ease", 779 overflow: "hidden", 780 }, 781 header: { 782 display: "flex", 783 alignItems: "center", 784 gap: 12, 785 padding: 12, 786 paddingBottom: 0, 787 }, 788 avatarPlaceholder: { 789 width: 32, 790 height: 32, 791 borderRadius: "50%", 792 background: `var(--atproto-color-border)`, 793 }, 794 avatarImg: { 795 width: 32, 796 height: 32, 797 borderRadius: "50%", 798 objectFit: "cover", 799 }, 800 authorInfo: { 801 display: "flex", 802 flexDirection: "column", 803 gap: 2, 804 }, 805 displayName: { 806 fontSize: 14, 807 fontWeight: 600, 808 }, 809 handle: { 810 fontSize: 12, 811 }, 812 galleryInfo: { 813 padding: 12, 814 paddingBottom: 8, 815 }, 816 title: { 817 margin: 0, 818 fontSize: 18, 819 fontWeight: 600, 820 marginBottom: 4, 821 }, 822 description: { 823 margin: 0, 824 fontSize: 14, 825 lineHeight: 1.4, 826 whiteSpace: "pre-wrap", 827 }, 828 singlePhotoContainer: { 829 padding: 0, 830 }, 831 carouselContainer: { 832 position: "relative", 833 padding: 4, 834 }, 835 photosGrid: { 836 display: "grid", 837 gridTemplateColumns: "repeat(2, 1fr)", 838 gridTemplateRows: "repeat(2, 1fr)", 839 gap: 4, 840 minHeight: 400, 841 }, 842 navButton: { 843 position: "absolute", 844 top: "50%", 845 transform: "translateY(-50%)", 846 width: 28, 847 height: 28, 848 border: "none", 849 borderRadius: "50%", 850 fontSize: 18, 851 fontWeight: "600", 852 cursor: "pointer", 853 display: "flex", 854 alignItems: "center", 855 justifyContent: "center", 856 zIndex: 10, 857 transition: "opacity 150ms ease", 858 userSelect: "none", 859 opacity: 0.7, 860 }, 861 navButtonLeft: { 862 left: 8, 863 }, 864 navButtonRight: { 865 right: 8, 866 }, 867 photoItem: { 868 margin: 0, 869 display: "flex", 870 flexDirection: "column", 871 gap: 4, 872 }, 873 singlePhotoItem: { 874 margin: 0, 875 display: "flex", 876 flexDirection: "column", 877 gap: 8, 878 }, 879 photoContainer: { 880 position: "relative", 881 width: "100%", 882 height: "100%", 883 overflow: "hidden", 884 borderRadius: 4, 885 }, 886 singlePhotoMedia: { 887 position: "relative", 888 width: "100%", 889 overflow: "hidden", 890 borderRadius: 0, 891 }, 892 photo: { 893 width: "100%", 894 height: "100%", 895 objectFit: "cover", 896 display: "block", 897 }, 898 photoGrid: { 899 width: "100%", 900 height: "100%", 901 objectFit: "cover", 902 display: "block", 903 }, 904 placeholder: { 905 display: "flex", 906 alignItems: "center", 907 justifyContent: "center", 908 width: "100%", 909 height: "100%", 910 minHeight: 100, 911 fontSize: 12, 912 }, 913 caption: { 914 fontSize: 12, 915 lineHeight: 1.3, 916 padding: "0 12px 8px", 917 }, 918 altBadge: { 919 position: "absolute", 920 bottom: 8, 921 right: 8, 922 padding: "4px 8px", 923 fontSize: 10, 924 fontWeight: 600, 925 letterSpacing: "0.5px", 926 border: "none", 927 borderRadius: 4, 928 cursor: "pointer", 929 transition: "background 150ms ease, color 150ms ease", 930 fontFamily: "system-ui, sans-serif", 931 }, 932 footer: { 933 padding: 12, 934 paddingTop: 8, 935 display: "flex", 936 justifyContent: "space-between", 937 alignItems: "center", 938 }, 939 time: { 940 fontSize: 11, 941 }, 942 paginationDots: { 943 display: "flex", 944 gap: 6, 945 alignItems: "center", 946 }, 947 paginationDot: { 948 width: 6, 949 height: 6, 950 borderRadius: "50%", 951 border: "none", 952 padding: 0, 953 cursor: "pointer", 954 transition: "background 200ms ease, transform 150ms ease", 955 flexShrink: 0, 956 }, 957}; 958 959export default GrainGalleryRenderer;