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;