A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
at main 24 kB view raw
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 role="alert" style={{ padding: 8, color: "crimson" }}> 125 Failed to load gallery. 126 </div> 127 ); 128 } 129 130 if (loading && photos.length === 0) { 131 return <div role="status" aria-live="polite" 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={`${authorDisplayName || authorHandle || 'User'}'s profile picture`} 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 role="dialog" 515 aria-modal="true" 516 aria-label={`Photo ${photoIndex + 1} of ${totalPhotos}`} 517 style={{ 518 position: "fixed", 519 top: 0, 520 left: 0, 521 right: 0, 522 bottom: 0, 523 background: "rgba(0, 0, 0, 0.95)", 524 zIndex: 9999, 525 display: "flex", 526 alignItems: "center", 527 justifyContent: "center", 528 padding: 20, 529 }} 530 onClick={onClose} 531 > 532 {/* Close button */} 533 <button 534 onClick={onClose} 535 style={{ 536 position: "absolute", 537 top: 20, 538 right: 20, 539 width: 40, 540 height: 40, 541 border: "none", 542 borderRadius: "50%", 543 background: "rgba(255, 255, 255, 0.1)", 544 color: "white", 545 fontSize: 24, 546 cursor: "pointer", 547 display: "flex", 548 alignItems: "center", 549 justifyContent: "center", 550 transition: "background 200ms ease", 551 }} 552 onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")} 553 onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")} 554 aria-label="Close lightbox" 555 > 556 × 557 </button> 558 559 {/* Previous button */} 560 {totalPhotos > 1 && ( 561 <button 562 onClick={(e) => { 563 e.stopPropagation(); 564 onPrev(); 565 }} 566 style={{ 567 position: "absolute", 568 left: 20, 569 top: "50%", 570 transform: "translateY(-50%)", 571 width: 50, 572 height: 50, 573 border: "none", 574 borderRadius: "50%", 575 background: "rgba(255, 255, 255, 0.1)", 576 color: "white", 577 fontSize: 24, 578 cursor: "pointer", 579 display: "flex", 580 alignItems: "center", 581 justifyContent: "center", 582 transition: "background 200ms ease", 583 }} 584 onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")} 585 onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")} 586 aria-label={`Previous photo (${photoIndex} of ${totalPhotos})`} 587 > 588 589 </button> 590 )} 591 592 {/* Next button */} 593 {totalPhotos > 1 && ( 594 <button 595 onClick={(e) => { 596 e.stopPropagation(); 597 onNext(); 598 }} 599 style={{ 600 position: "absolute", 601 right: 20, 602 top: "50%", 603 transform: "translateY(-50%)", 604 width: 50, 605 height: 50, 606 border: "none", 607 borderRadius: "50%", 608 background: "rgba(255, 255, 255, 0.1)", 609 color: "white", 610 fontSize: 24, 611 cursor: "pointer", 612 display: "flex", 613 alignItems: "center", 614 justifyContent: "center", 615 transition: "background 200ms ease", 616 }} 617 onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")} 618 onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")} 619 aria-label={`Next photo (${photoIndex + 2} of ${totalPhotos})`} 620 > 621 622 </button> 623 )} 624 625 {/* Image */} 626 <div 627 style={{ 628 maxWidth: "90vw", 629 maxHeight: "90vh", 630 display: "flex", 631 alignItems: "center", 632 justifyContent: "center", 633 }} 634 onClick={(e) => e.stopPropagation()} 635 > 636 {url ? ( 637 <img 638 src={url} 639 alt={alt} 640 style={{ 641 maxWidth: "100%", 642 maxHeight: "100%", 643 objectFit: "contain", 644 borderRadius: 8, 645 }} 646 /> 647 ) : ( 648 <div 649 style={{ 650 color: "white", 651 fontSize: 16, 652 textAlign: "center", 653 }} 654 > 655 {photoLoading ? "Loading…" : photoError ? "Failed to load" : "Unavailable"} 656 </div> 657 )} 658 </div> 659 660 {/* Photo counter */} 661 {totalPhotos > 1 && ( 662 <div 663 style={{ 664 position: "absolute", 665 bottom: 20, 666 left: "50%", 667 transform: "translateX(-50%)", 668 color: "white", 669 fontSize: 14, 670 background: "rgba(0, 0, 0, 0.5)", 671 padding: "8px 16px", 672 borderRadius: 20, 673 }} 674 > 675 {photoIndex + 1} / {totalPhotos} 676 </div> 677 )} 678 </div> 679 ); 680}; 681 682const GalleryPhotoItem: React.FC<{ 683 photo: GrainGalleryPhoto; 684 isSingle: boolean; 685 span?: { row: number; col: number }; 686 onClick?: () => void; 687}> = ({ photo, isSingle, span, onClick }) => { 688 const [showAltText, setShowAltText] = React.useState(false); 689 const photoBlob = photo.record.photo; 690 const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined; 691 const cid = cdnUrl ? undefined : extractCidFromBlob(photoBlob); 692 const { url: urlFromBlob, loading: photoLoading, error: photoError } = useBlob(photo.did, cid); 693 const url = cdnUrl || urlFromBlob; 694 const alt = photo.record.alt?.trim() || "grain.social photo"; 695 const hasAlt = photo.record.alt && photo.record.alt.trim().length > 0; 696 697 const aspect = 698 photo.record.aspectRatio && photo.record.aspectRatio.height > 0 699 ? `${photo.record.aspectRatio.width} / ${photo.record.aspectRatio.height}` 700 : undefined; 701 702 const gridItemStyle = span 703 ? { 704 gridRow: `span ${span.row}`, 705 gridColumn: `span ${span.col}`, 706 } 707 : {}; 708 709 return ( 710 <figure style={{ ...(isSingle ? styles.singlePhotoItem : styles.photoItem), ...gridItemStyle }}> 711 <button 712 onClick={onClick} 713 aria-label={hasAlt ? `View photo: ${alt}` : "View photo"} 714 style={{ 715 ...(isSingle ? styles.singlePhotoMedia : styles.photoContainer), 716 background: `var(--atproto-color-image-bg)`, 717 // Only apply aspect ratio for single photos; grid photos fill their cells 718 ...(isSingle && aspect ? { aspectRatio: aspect } : {}), 719 cursor: onClick ? "pointer" : "default", 720 border: "none", 721 padding: 0, 722 display: "block", 723 width: "100%", 724 }} 725 > 726 {url ? ( 727 <img src={url} alt={alt} style={isSingle ? styles.photo : styles.photoGrid} /> 728 ) : ( 729 <div 730 style={{ 731 ...styles.placeholder, 732 color: `var(--atproto-color-text-muted)`, 733 }} 734 > 735 {photoLoading 736 ? "Loading…" 737 : photoError 738 ? "Failed to load" 739 : "Unavailable"} 740 </div> 741 )} 742 {hasAlt && ( 743 <button 744 onClick={(e) => { 745 e.stopPropagation(); 746 setShowAltText(!showAltText); 747 }} 748 style={{ 749 ...styles.altBadge, 750 background: showAltText 751 ? `var(--atproto-color-text)` 752 : `var(--atproto-color-bg-secondary)`, 753 color: showAltText 754 ? `var(--atproto-color-bg)` 755 : `var(--atproto-color-text)`, 756 }} 757 title="Toggle alt text" 758 aria-label="Toggle alt text" 759 aria-pressed={showAltText} 760 > 761 ALT 762 </button> 763 )} 764 </button> 765 {hasAlt && showAltText && ( 766 <figcaption 767 style={{ 768 ...styles.caption, 769 color: `var(--atproto-color-text-secondary)`, 770 }} 771 > 772 {photo.record.alt} 773 </figcaption> 774 )} 775 </figure> 776 ); 777}; 778 779const styles: Record<string, React.CSSProperties> = { 780 card: { 781 borderRadius: 12, 782 border: `1px solid var(--atproto-color-border)`, 783 background: `var(--atproto-color-bg)`, 784 color: `var(--atproto-color-text)`, 785 fontFamily: "system-ui, sans-serif", 786 display: "flex", 787 flexDirection: "column", 788 maxWidth: 600, 789 transition: 790 "background-color 180ms ease, border-color 180ms ease, color 180ms ease", 791 overflow: "hidden", 792 }, 793 header: { 794 display: "flex", 795 alignItems: "center", 796 gap: 12, 797 padding: 12, 798 paddingBottom: 0, 799 }, 800 avatarPlaceholder: { 801 width: 32, 802 height: 32, 803 borderRadius: "50%", 804 background: `var(--atproto-color-border)`, 805 }, 806 avatarImg: { 807 width: 32, 808 height: 32, 809 borderRadius: "50%", 810 objectFit: "cover", 811 }, 812 authorInfo: { 813 display: "flex", 814 flexDirection: "column", 815 gap: 2, 816 }, 817 displayName: { 818 fontSize: 14, 819 fontWeight: 600, 820 }, 821 handle: { 822 fontSize: 12, 823 }, 824 galleryInfo: { 825 padding: 12, 826 paddingBottom: 8, 827 }, 828 title: { 829 margin: 0, 830 fontSize: 18, 831 fontWeight: 600, 832 marginBottom: 4, 833 }, 834 description: { 835 margin: 0, 836 fontSize: 14, 837 lineHeight: 1.4, 838 whiteSpace: "pre-wrap", 839 }, 840 singlePhotoContainer: { 841 padding: 0, 842 }, 843 carouselContainer: { 844 position: "relative", 845 padding: 4, 846 }, 847 photosGrid: { 848 display: "grid", 849 gridTemplateColumns: "repeat(2, 1fr)", 850 gridTemplateRows: "repeat(2, 1fr)", 851 gap: 4, 852 minHeight: 400, 853 }, 854 navButton: { 855 position: "absolute", 856 top: "50%", 857 transform: "translateY(-50%)", 858 width: 28, 859 height: 28, 860 border: "none", 861 borderRadius: "50%", 862 fontSize: 18, 863 fontWeight: "600", 864 cursor: "pointer", 865 display: "flex", 866 alignItems: "center", 867 justifyContent: "center", 868 zIndex: 10, 869 transition: "opacity 150ms ease", 870 userSelect: "none", 871 opacity: 0.7, 872 }, 873 navButtonLeft: { 874 left: 8, 875 }, 876 navButtonRight: { 877 right: 8, 878 }, 879 photoItem: { 880 margin: 0, 881 display: "flex", 882 flexDirection: "column", 883 gap: 4, 884 }, 885 singlePhotoItem: { 886 margin: 0, 887 display: "flex", 888 flexDirection: "column", 889 gap: 8, 890 }, 891 photoContainer: { 892 position: "relative", 893 width: "100%", 894 height: "100%", 895 overflow: "hidden", 896 borderRadius: 4, 897 }, 898 singlePhotoMedia: { 899 position: "relative", 900 width: "100%", 901 overflow: "hidden", 902 borderRadius: 0, 903 }, 904 photo: { 905 width: "100%", 906 height: "100%", 907 objectFit: "cover", 908 display: "block", 909 }, 910 photoGrid: { 911 width: "100%", 912 height: "100%", 913 objectFit: "cover", 914 display: "block", 915 }, 916 placeholder: { 917 display: "flex", 918 alignItems: "center", 919 justifyContent: "center", 920 width: "100%", 921 height: "100%", 922 minHeight: 100, 923 fontSize: 12, 924 }, 925 caption: { 926 fontSize: 12, 927 lineHeight: 1.3, 928 padding: "0 12px 8px", 929 }, 930 altBadge: { 931 position: "absolute", 932 bottom: 8, 933 right: 8, 934 padding: "4px 8px", 935 fontSize: 10, 936 fontWeight: 600, 937 letterSpacing: "0.5px", 938 border: "none", 939 borderRadius: 4, 940 cursor: "pointer", 941 transition: "background 150ms ease, color 150ms ease", 942 fontFamily: "system-ui, sans-serif", 943 }, 944 footer: { 945 padding: 12, 946 paddingTop: 8, 947 display: "flex", 948 justifyContent: "space-between", 949 alignItems: "center", 950 }, 951 time: { 952 fontSize: 11, 953 }, 954 paginationDots: { 955 display: "flex", 956 gap: 6, 957 alignItems: "center", 958 }, 959 paginationDot: { 960 width: 6, 961 height: 6, 962 borderRadius: "50%", 963 border: "none", 964 padding: 0, 965 cursor: "pointer", 966 transition: "background 200ms ease, transform 150ms ease", 967 flexShrink: 0, 968 }, 969}; 970 971export default GrainGalleryRenderer;