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 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;