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

grain.social component

+1211 -2
+3 -2
README.md
··· 1 1 # atproto-ui 2 2 3 - A React component library for rendering AT Protocol records (Bluesky, Leaflet, Tangled, and more). Handles DID resolution, PDS discovery, and record fetching automatically. [Live demo](https://atproto-ui.netlify.app). 3 + A React component library for rendering AT Protocol records (Bluesky, Leaflet, Tangled, and more). Handles DID resolution, PDS discovery, and record fetching automatically as well as caching these so multiple components can render quickly. [Live demo](https://atproto-ui.netlify.app). 4 4 5 5 This project is mostly a wrapper on the extremely amazing work [Mary](https://mary.my.id/) has done with [atcute](https://tangled.org/@mary.my.id/atcute), please support it. I have to give thanks to [phil](https://bsky.app/profile/bad-example.com) for microcosm and slingshot. Incredible services being given for free that is responsible for why the components fetch data so quickly. 6 6 ··· 11 11 12 12 ## Features 13 13 14 - - **Drop-in components** for common record types (`BlueskyPost`, `BlueskyProfile`, `TangledString`, `LeafletDocument`) 14 + - **Drop-in components** for common record types (`BlueskyPost`, `BlueskyProfile`, `TangledRepo`, `LeafletDocument`) 15 15 - **Prefetch support** - Pass data directly to skip API calls (perfect for SSR/caching) 16 + - **Caching** - Blobs, DIDs, and records are cached so components which use the same ones can render even quicker 16 17 - **Customizable theming** - Override CSS variables to match your app's design 17 18 - **Composable hooks** - Build custom renderers with protocol primitives 18 19 - Built on lightweight [`@atcute/*`](https://tangled.org/@mary.my.id/atcute) clients
+327
lib/components/GrainGallery.tsx
··· 1 + import React, { useMemo, useEffect, useState } from "react"; 2 + import { GrainGalleryRenderer, type GrainGalleryPhoto } from "../renderers/GrainGalleryRenderer"; 3 + import type { GrainGalleryRecord, GrainGalleryItemRecord, GrainPhotoRecord } from "../types/grain"; 4 + import type { ProfileRecord } from "../types/bluesky"; 5 + import { useDidResolution } from "../hooks/useDidResolution"; 6 + import { useAtProtoRecord } from "../hooks/useAtProtoRecord"; 7 + import { useBacklinks } from "../hooks/useBacklinks"; 8 + import { useBlob } from "../hooks/useBlob"; 9 + import { BLUESKY_PROFILE_COLLECTION } from "./BlueskyProfile"; 10 + import { getAvatarCid } from "../utils/profile"; 11 + import { formatDidForLabel, parseAtUri } from "../utils/at-uri"; 12 + import { isBlobWithCdn } from "../utils/blob"; 13 + import { createAtprotoClient } from "../utils/atproto-client"; 14 + 15 + /** 16 + * Props for rendering a grain.social gallery. 17 + */ 18 + export interface GrainGalleryProps { 19 + /** 20 + * Decentralized identifier for the repository that owns the gallery. 21 + */ 22 + did: string; 23 + /** 24 + * Record key identifying the specific gallery within the collection. 25 + */ 26 + rkey: string; 27 + /** 28 + * Prefetched gallery record. When provided, skips fetching the gallery from the network. 29 + */ 30 + record?: GrainGalleryRecord; 31 + /** 32 + * Custom renderer component that receives resolved gallery data and status flags. 33 + */ 34 + renderer?: React.ComponentType<GrainGalleryRendererInjectedProps>; 35 + /** 36 + * React node shown while the gallery query has not yet produced data or an error. 37 + */ 38 + fallback?: React.ReactNode; 39 + /** 40 + * React node displayed while the gallery fetch is actively loading. 41 + */ 42 + loadingIndicator?: React.ReactNode; 43 + /** 44 + * Constellation API base URL for fetching backlinks. 45 + */ 46 + constellationBaseUrl?: string; 47 + } 48 + 49 + /** 50 + * Values injected by `GrainGallery` into a downstream renderer component. 51 + */ 52 + export type GrainGalleryRendererInjectedProps = { 53 + /** 54 + * Resolved gallery record 55 + */ 56 + gallery: GrainGalleryRecord; 57 + /** 58 + * Array of photos in the gallery with their records and metadata 59 + */ 60 + photos: GrainGalleryPhoto[]; 61 + /** 62 + * `true` while network operations are in-flight. 63 + */ 64 + loading: boolean; 65 + /** 66 + * Error encountered during loading, if any. 67 + */ 68 + error?: Error; 69 + /** 70 + * The author's public handle derived from the DID. 71 + */ 72 + authorHandle?: string; 73 + /** 74 + * The author's display name from their profile. 75 + */ 76 + authorDisplayName?: string; 77 + /** 78 + * Resolved URL for the author's avatar blob, if available. 79 + */ 80 + avatarUrl?: string; 81 + }; 82 + 83 + export const GRAIN_GALLERY_COLLECTION = "social.grain.gallery"; 84 + export const GRAIN_GALLERY_ITEM_COLLECTION = "social.grain.gallery.item"; 85 + export const GRAIN_PHOTO_COLLECTION = "social.grain.photo"; 86 + 87 + /** 88 + * Fetches a grain.social gallery, resolves all photos via constellation backlinks, 89 + * and renders them in a grid layout. 90 + * 91 + * @param did - DID of the repository that stores the gallery. 92 + * @param rkey - Record key for the gallery. 93 + * @param record - Prefetched gallery record. 94 + * @param renderer - Optional renderer component to override the default. 95 + * @param fallback - Node rendered before the first fetch attempt resolves. 96 + * @param loadingIndicator - Node rendered while the gallery is loading. 97 + * @param constellationBaseUrl - Constellation API base URL. 98 + * @returns A component that renders loading/fallback states and the resolved gallery. 99 + */ 100 + export const GrainGallery: React.FC<GrainGalleryProps> = React.memo( 101 + ({ 102 + did: handleOrDid, 103 + rkey, 104 + record, 105 + renderer, 106 + fallback, 107 + loadingIndicator, 108 + constellationBaseUrl, 109 + }) => { 110 + const { 111 + did: resolvedDid, 112 + handle, 113 + loading: resolvingIdentity, 114 + error: resolutionError, 115 + } = useDidResolution(handleOrDid); 116 + 117 + const repoIdentifier = resolvedDid ?? handleOrDid; 118 + 119 + // Fetch author profile 120 + const { record: profile } = useAtProtoRecord<ProfileRecord>({ 121 + did: repoIdentifier, 122 + collection: BLUESKY_PROFILE_COLLECTION, 123 + rkey: "self", 124 + }); 125 + const avatar = profile?.avatar; 126 + const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined; 127 + const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(profile); 128 + const authorDisplayName = profile?.displayName; 129 + const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid); 130 + const avatarUrl = avatarCdnUrl || avatarUrlFromBlob; 131 + 132 + // Fetch gallery record 133 + const { 134 + record: fetchedGallery, 135 + loading: galleryLoading, 136 + error: galleryError, 137 + } = useAtProtoRecord<GrainGalleryRecord>({ 138 + did: record ? "" : repoIdentifier, 139 + collection: record ? "" : GRAIN_GALLERY_COLLECTION, 140 + rkey: record ? "" : rkey, 141 + }); 142 + 143 + const galleryRecord = record ?? fetchedGallery; 144 + const galleryUri = resolvedDid 145 + ? `at://${resolvedDid}/${GRAIN_GALLERY_COLLECTION}/${rkey}` 146 + : undefined; 147 + 148 + // Fetch backlinks to get gallery items 149 + const { 150 + backlinks, 151 + loading: backlinksLoading, 152 + error: backlinksError, 153 + } = useBacklinks({ 154 + subject: galleryUri || "", 155 + source: `${GRAIN_GALLERY_ITEM_COLLECTION}:gallery`, 156 + enabled: !!galleryUri && !!galleryRecord, 157 + constellationBaseUrl, 158 + }); 159 + 160 + // Fetch all gallery item records and photo records 161 + const [photos, setPhotos] = useState<GrainGalleryPhoto[]>([]); 162 + const [photosLoading, setPhotosLoading] = useState(false); 163 + const [photosError, setPhotosError] = useState<Error | undefined>(undefined); 164 + 165 + useEffect(() => { 166 + if (!backlinks || backlinks.length === 0) { 167 + setPhotos([]); 168 + return; 169 + } 170 + 171 + let cancelled = false; 172 + setPhotosLoading(true); 173 + setPhotosError(undefined); 174 + 175 + (async () => { 176 + try { 177 + const photoPromises = backlinks.map(async (backlink) => { 178 + // Create client for gallery item DID (uses slingshot + PDS fallback) 179 + const { rpc: galleryItemClient } = await createAtprotoClient({ 180 + did: backlink.did, 181 + }); 182 + 183 + // Fetch gallery item record 184 + const galleryItemRes = await ( 185 + galleryItemClient as unknown as { 186 + get: ( 187 + nsid: string, 188 + opts: { 189 + params: { 190 + repo: string; 191 + collection: string; 192 + rkey: string; 193 + }; 194 + }, 195 + ) => Promise<{ ok: boolean; data: { value: GrainGalleryItemRecord } }>; 196 + } 197 + ).get("com.atproto.repo.getRecord", { 198 + params: { 199 + repo: backlink.did, 200 + collection: GRAIN_GALLERY_ITEM_COLLECTION, 201 + rkey: backlink.rkey, 202 + }, 203 + }); 204 + 205 + if (!galleryItemRes.ok) return null; 206 + 207 + const galleryItem = galleryItemRes.data.value; 208 + 209 + // Parse photo URI 210 + const photoUri = parseAtUri(galleryItem.item); 211 + if (!photoUri) return null; 212 + 213 + // Create client for photo DID (uses slingshot + PDS fallback) 214 + const { rpc: photoClient } = await createAtprotoClient({ 215 + did: photoUri.did, 216 + }); 217 + 218 + // Fetch photo record 219 + const photoRes = await ( 220 + photoClient as unknown as { 221 + get: ( 222 + nsid: string, 223 + opts: { 224 + params: { 225 + repo: string; 226 + collection: string; 227 + rkey: string; 228 + }; 229 + }, 230 + ) => Promise<{ ok: boolean; data: { value: GrainPhotoRecord } }>; 231 + } 232 + ).get("com.atproto.repo.getRecord", { 233 + params: { 234 + repo: photoUri.did, 235 + collection: photoUri.collection, 236 + rkey: photoUri.rkey, 237 + }, 238 + }); 239 + 240 + if (!photoRes.ok) return null; 241 + 242 + const photoRecord = photoRes.data.value; 243 + 244 + return { 245 + record: photoRecord, 246 + did: photoUri.did, 247 + rkey: photoUri.rkey, 248 + position: galleryItem.position, 249 + } as GrainGalleryPhoto; 250 + }); 251 + 252 + const resolvedPhotos = await Promise.all(photoPromises); 253 + const validPhotos = resolvedPhotos.filter((p): p is NonNullable<typeof p> => p !== null) as GrainGalleryPhoto[]; 254 + 255 + if (!cancelled) { 256 + setPhotos(validPhotos); 257 + setPhotosLoading(false); 258 + } 259 + } catch (err) { 260 + if (!cancelled) { 261 + setPhotosError(err instanceof Error ? err : new Error("Failed to fetch photos")); 262 + setPhotosLoading(false); 263 + } 264 + } 265 + })(); 266 + 267 + return () => { 268 + cancelled = true; 269 + }; 270 + }, [backlinks]); 271 + 272 + const Comp: React.ComponentType<GrainGalleryRendererInjectedProps> = 273 + useMemo( 274 + () => 275 + renderer ?? ((props) => <GrainGalleryRenderer {...props} />), 276 + [renderer], 277 + ); 278 + 279 + const displayHandle = 280 + handle ?? 281 + (handleOrDid.startsWith("did:") ? undefined : handleOrDid); 282 + const authorHandle = 283 + displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid); 284 + 285 + if (!displayHandle && resolvingIdentity) { 286 + return loadingIndicator || <div style={{ padding: 8 }}>Resolving handle…</div>; 287 + } 288 + if (!displayHandle && resolutionError) { 289 + return ( 290 + <div style={{ padding: 8, color: "crimson" }}> 291 + Could not resolve handle. 292 + </div> 293 + ); 294 + } 295 + 296 + if (galleryError || backlinksError || photosError) { 297 + return ( 298 + <div style={{ padding: 8, color: "crimson" }}> 299 + Failed to load gallery. 300 + </div> 301 + ); 302 + } 303 + 304 + if (!galleryRecord && galleryLoading) { 305 + return loadingIndicator || <div style={{ padding: 8 }}>Loading gallery…</div>; 306 + } 307 + 308 + if (!galleryRecord) { 309 + return fallback || <div style={{ padding: 8 }}>Gallery not found.</div>; 310 + } 311 + 312 + const loading = galleryLoading || backlinksLoading || photosLoading; 313 + 314 + return ( 315 + <Comp 316 + gallery={galleryRecord} 317 + photos={photos} 318 + loading={loading} 319 + authorHandle={authorHandle} 320 + authorDisplayName={authorDisplayName} 321 + avatarUrl={avatarUrl} 322 + /> 323 + ); 324 + }, 325 + ); 326 + 327 + export default GrainGallery;
+3
lib/index.ts
··· 12 12 export * from "./components/BlueskyPostList"; 13 13 export * from "./components/BlueskyProfile"; 14 14 export * from "./components/BlueskyQuotePost"; 15 + export * from "./components/GrainGallery"; 15 16 export * from "./components/LeafletDocument"; 16 17 export * from "./components/TangledRepo"; 17 18 export * from "./components/TangledString"; ··· 31 32 // Renderers 32 33 export * from "./renderers/BlueskyPostRenderer"; 33 34 export * from "./renderers/BlueskyProfileRenderer"; 35 + export * from "./renderers/GrainGalleryRenderer"; 34 36 export * from "./renderers/LeafletDocumentRenderer"; 35 37 export * from "./renderers/TangledRepoRenderer"; 36 38 export * from "./renderers/TangledStringRenderer"; 37 39 38 40 // Types 39 41 export * from "./types/bluesky"; 42 + export * from "./types/grain"; 40 43 export * from "./types/leaflet"; 41 44 export * from "./types/tangled"; 42 45 export * from "./types/theme";
+764
lib/renderers/GrainGalleryRenderer.tsx
··· 1 + import React from "react"; 2 + import type { GrainGalleryRecord, GrainPhotoRecord } from "../types/grain"; 3 + import { useBlob } from "../hooks/useBlob"; 4 + import { isBlobWithCdn, extractCidFromBlob } from "../utils/blob"; 5 + 6 + export interface GrainGalleryPhoto { 7 + record: GrainPhotoRecord; 8 + did: string; 9 + rkey: string; 10 + position?: number; 11 + } 12 + 13 + export 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 + 23 + export 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 + // Open lightbox 45 + const openLightbox = React.useCallback((photoIndex: number) => { 46 + setLightboxPhotoIndex(photoIndex); 47 + setLightboxOpen(true); 48 + }, []); 49 + 50 + // Close lightbox 51 + const closeLightbox = React.useCallback(() => { 52 + setLightboxOpen(false); 53 + }, []); 54 + 55 + // Navigate lightbox 56 + const goToNextPhoto = React.useCallback(() => { 57 + setLightboxPhotoIndex((prev) => (prev + 1) % sortedPhotos.length); 58 + }, [sortedPhotos.length]); 59 + 60 + const goToPrevPhoto = React.useCallback(() => { 61 + setLightboxPhotoIndex((prev) => (prev - 1 + sortedPhotos.length) % sortedPhotos.length); 62 + }, [sortedPhotos.length]); 63 + 64 + // Keyboard navigation 65 + React.useEffect(() => { 66 + if (!lightboxOpen) return; 67 + 68 + const handleKeyDown = (e: KeyboardEvent) => { 69 + if (e.key === "Escape") closeLightbox(); 70 + if (e.key === "ArrowLeft") goToPrevPhoto(); 71 + if (e.key === "ArrowRight") goToNextPhoto(); 72 + }; 73 + 74 + window.addEventListener("keydown", handleKeyDown); 75 + return () => window.removeEventListener("keydown", handleKeyDown); 76 + }, [lightboxOpen, closeLightbox, goToPrevPhoto, goToNextPhoto]); 77 + 78 + // Memoize sorted photos to prevent re-sorting on every render 79 + const sortedPhotos = React.useMemo( 80 + () => [...photos].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)), 81 + [photos] 82 + ); 83 + 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 key={`${sortedPhotos[0].did}-${sortedPhotos[0].rkey}`} photo={sortedPhotos[0]} isSingle={true} /> 201 + </div> 202 + ) : ( 203 + <div style={styles.carouselContainer}> 204 + {hasMultiplePages && currentPage > 0 && ( 205 + <button 206 + onClick={goToPrevPage} 207 + onMouseEnter={(e) => (e.currentTarget.style.opacity = "1")} 208 + onMouseLeave={(e) => (e.currentTarget.style.opacity = "0.7")} 209 + style={{ 210 + ...styles.navButton, 211 + ...styles.navButtonLeft, 212 + color: "white", 213 + background: "rgba(0, 0, 0, 0.5)", 214 + boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)", 215 + }} 216 + aria-label="Previous photos" 217 + > 218 + 219 + </button> 220 + )} 221 + <div style={styles.photosGrid}> 222 + {layoutPhotos.map((item) => ( 223 + <GalleryPhotoItem 224 + key={`${item.did}-${item.rkey}`} 225 + photo={item} 226 + isSingle={false} 227 + span={item.span} 228 + /> 229 + ))} 230 + </div> 231 + {hasMultiplePages && currentPage < totalPages - 1 && ( 232 + <button 233 + onClick={goToNextPage} 234 + onMouseEnter={(e) => (e.currentTarget.style.opacity = "1")} 235 + onMouseLeave={(e) => (e.currentTarget.style.opacity = "0.7")} 236 + style={{ 237 + ...styles.navButton, 238 + ...styles.navButtonRight, 239 + color: "white", 240 + background: "rgba(0, 0, 0, 0.5)", 241 + boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)", 242 + }} 243 + aria-label="Next photos" 244 + > 245 + 246 + </button> 247 + )} 248 + </div> 249 + )} 250 + 251 + <footer style={styles.footer}> 252 + <time 253 + style={{ 254 + ...styles.time, 255 + color: `var(--atproto-color-text-muted)`, 256 + }} 257 + dateTime={gallery.createdAt} 258 + > 259 + {created} 260 + </time> 261 + {hasMultiplePages && !isSinglePhoto && ( 262 + <div style={styles.paginationDots}> 263 + {Array.from({ length: totalPages }, (_, i) => ( 264 + <button 265 + key={i} 266 + onClick={() => setCurrentPage(i)} 267 + style={{ 268 + ...styles.paginationDot, 269 + background: i === currentPage 270 + ? `var(--atproto-color-text)` 271 + : `var(--atproto-color-border)`, 272 + }} 273 + aria-label={`Go to page ${i + 1}`} 274 + aria-current={i === currentPage ? "page" : undefined} 275 + /> 276 + ))} 277 + </div> 278 + )} 279 + </footer> 280 + </article> 281 + </> 282 + ); 283 + }; 284 + 285 + // Component to preload a single photo's blob 286 + const PreloadPhoto: React.FC<{ photo: GrainGalleryPhoto }> = ({ photo }) => { 287 + const photoBlob = photo.record.photo; 288 + const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined; 289 + const cid = cdnUrl ? undefined : extractCidFromBlob(photoBlob); 290 + 291 + // Trigger blob loading via the hook 292 + useBlob(photo.did, cid); 293 + 294 + // Preload CDN images via Image element 295 + React.useEffect(() => { 296 + if (cdnUrl) { 297 + const img = new Image(); 298 + img.src = cdnUrl; 299 + } 300 + }, [cdnUrl]); 301 + 302 + return null; 303 + }; 304 + 305 + // Hook to preload all photos (CDN-based) 306 + const usePreloadAllPhotos = (photos: GrainGalleryPhoto[]) => { 307 + React.useEffect(() => { 308 + // Preload CDN images 309 + photos.forEach((photo) => { 310 + const photoBlob = photo.record.photo; 311 + const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined; 312 + 313 + if (cdnUrl) { 314 + const img = new Image(); 315 + img.src = cdnUrl; 316 + } 317 + }); 318 + }, [photos]); 319 + }; 320 + 321 + // Calculate pages with intelligent photo count (1, 2, or 3) 322 + // Only includes multiple photos when they fit well together 323 + const calculatePages = (photos: GrainGalleryPhoto[]): GrainGalleryPhoto[][] => { 324 + if (photos.length === 0) return []; 325 + if (photos.length === 1) return [[photos[0]]]; 326 + 327 + const pages: GrainGalleryPhoto[][] = []; 328 + let i = 0; 329 + 330 + while (i < photos.length) { 331 + const remaining = photos.length - i; 332 + 333 + // Only one photo left - use it 334 + if (remaining === 1) { 335 + pages.push([photos[i]]); 336 + break; 337 + } 338 + 339 + // Check if next 3 photos can fit well together 340 + if (remaining >= 3) { 341 + const nextThree = photos.slice(i, i + 3); 342 + if (canFitThreePhotos(nextThree)) { 343 + pages.push(nextThree); 344 + i += 3; 345 + continue; 346 + } 347 + } 348 + 349 + // Check if next 2 photos can fit well together 350 + if (remaining >= 2) { 351 + const nextTwo = photos.slice(i, i + 2); 352 + if (canFitTwoPhotos(nextTwo)) { 353 + pages.push(nextTwo); 354 + i += 2; 355 + continue; 356 + } 357 + } 358 + 359 + // Photos don't fit well together, use 1 per page 360 + pages.push([photos[i]]); 361 + i += 1; 362 + } 363 + 364 + return pages; 365 + }; 366 + 367 + // Helper functions for aspect ratio classification 368 + const isPortrait = (ratio: number) => ratio < 0.8; 369 + const isLandscape = (ratio: number) => ratio > 1.2; 370 + const isSquarish = (ratio: number) => ratio >= 0.8 && ratio <= 1.2; 371 + 372 + // Determine if 2 photos can fit well together side by side 373 + const canFitTwoPhotos = (photos: GrainGalleryPhoto[]): boolean => { 374 + if (photos.length !== 2) return false; 375 + 376 + const ratios = photos.map((p) => { 377 + const ar = p.record.aspectRatio; 378 + return ar ? ar.width / ar.height : 1; 379 + }); 380 + 381 + const [r1, r2] = ratios; 382 + 383 + // Two portraits side by side don't work well (too narrow) 384 + if (isPortrait(r1) && isPortrait(r2)) return false; 385 + 386 + // Portrait + landscape/square creates awkward layout 387 + if (isPortrait(r1) && !isPortrait(r2)) return false; 388 + if (!isPortrait(r1) && isPortrait(r2)) return false; 389 + 390 + // Two landscape or two squarish photos work well 391 + if ((isLandscape(r1) || isSquarish(r1)) && (isLandscape(r2) || isSquarish(r2))) { 392 + return true; 393 + } 394 + 395 + // Default to not fitting 396 + return false; 397 + }; 398 + 399 + // Determine if 3 photos can fit well together in a layout 400 + const canFitThreePhotos = (photos: GrainGalleryPhoto[]): boolean => { 401 + if (photos.length !== 3) return false; 402 + 403 + const ratios = photos.map((p) => { 404 + const ar = p.record.aspectRatio; 405 + return ar ? ar.width / ar.height : 1; 406 + }); 407 + 408 + const [r1, r2, r3] = ratios; 409 + 410 + // Good pattern: one portrait, two landscape/square 411 + if (isPortrait(r1) && !isPortrait(r2) && !isPortrait(r3)) return true; 412 + if (isPortrait(r3) && !isPortrait(r1) && !isPortrait(r2)) return true; 413 + 414 + // Good pattern: all similar aspect ratios (all landscape or all squarish) 415 + const allLandscape = ratios.every(isLandscape); 416 + const allSquarish = ratios.every(isSquarish); 417 + if (allLandscape || allSquarish) return true; 418 + 419 + // Three portraits in a row can work 420 + const allPortrait = ratios.every(isPortrait); 421 + if (allPortrait) return true; 422 + 423 + // Otherwise don't fit 3 together 424 + return false; 425 + }; 426 + 427 + // Layout calculator for intelligent photo grid arrangement 428 + const calculateLayout = (photos: GrainGalleryPhoto[]) => { 429 + if (photos.length === 0) return []; 430 + if (photos.length === 1) { 431 + return [{ ...photos[0], span: { row: 2, col: 2 } }]; 432 + } 433 + 434 + const photosWithRatios = photos.map((photo) => { 435 + const ratio = photo.record.aspectRatio 436 + ? photo.record.aspectRatio.width / photo.record.aspectRatio.height 437 + : 1; 438 + return { 439 + ...photo, 440 + ratio, 441 + isPortrait: isPortrait(ratio), 442 + isLandscape: isLandscape(ratio) 443 + }; 444 + }); 445 + 446 + // For 2 photos: side by side 447 + if (photos.length === 2) { 448 + return photosWithRatios.map((p) => ({ ...p, span: { row: 2, col: 1 } })); 449 + } 450 + 451 + // For 3 photos: try to create a balanced layout 452 + if (photos.length === 3) { 453 + const [p1, p2, p3] = photosWithRatios; 454 + 455 + // Pattern 1: One tall on left, two stacked on right 456 + if (p1.isPortrait && !p2.isPortrait && !p3.isPortrait) { 457 + return [ 458 + { ...p1, span: { row: 2, col: 1 } }, 459 + { ...p2, span: { row: 1, col: 1 } }, 460 + { ...p3, span: { row: 1, col: 1 } }, 461 + ]; 462 + } 463 + 464 + // Pattern 2: Two stacked on left, one tall on right 465 + if (!p1.isPortrait && !p2.isPortrait && p3.isPortrait) { 466 + return [ 467 + { ...p1, span: { row: 1, col: 1 } }, 468 + { ...p2, span: { row: 1, col: 1 } }, 469 + { ...p3, span: { row: 2, col: 1 } }, 470 + ]; 471 + } 472 + 473 + // Pattern 3: All in a row 474 + const allPortrait = photosWithRatios.every((p) => p.isPortrait); 475 + if (allPortrait) { 476 + // All portraits: display in a row with smaller cells 477 + return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } })); 478 + } 479 + 480 + // Default: All three in a row 481 + return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } })); 482 + } 483 + 484 + return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } })); 485 + }; 486 + 487 + const GalleryPhotoItem: React.FC<{ 488 + photo: GrainGalleryPhoto; 489 + isSingle: boolean; 490 + span?: { row: number; col: number }; 491 + }> = ({ photo, isSingle, span }) => { 492 + const [showAltText, setShowAltText] = React.useState(false); 493 + const photoBlob = photo.record.photo; 494 + const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined; 495 + const cid = cdnUrl ? undefined : extractCidFromBlob(photoBlob); 496 + const { url: urlFromBlob, loading: photoLoading, error: photoError } = useBlob(photo.did, cid); 497 + const url = cdnUrl || urlFromBlob; 498 + const alt = photo.record.alt?.trim() || "grain.social photo"; 499 + const hasAlt = photo.record.alt && photo.record.alt.trim().length > 0; 500 + 501 + const aspect = 502 + photo.record.aspectRatio && photo.record.aspectRatio.height > 0 503 + ? `${photo.record.aspectRatio.width} / ${photo.record.aspectRatio.height}` 504 + : undefined; 505 + 506 + const gridItemStyle = span 507 + ? { 508 + gridRow: `span ${span.row}`, 509 + gridColumn: `span ${span.col}`, 510 + } 511 + : {}; 512 + 513 + return ( 514 + <figure style={{ ...(isSingle ? styles.singlePhotoItem : styles.photoItem), ...gridItemStyle }}> 515 + <div 516 + style={{ 517 + ...(isSingle ? styles.singlePhotoMedia : styles.photoContainer), 518 + background: `var(--atproto-color-image-bg)`, 519 + // Only apply aspect ratio for single photos; grid photos fill their cells 520 + ...(isSingle && aspect ? { aspectRatio: aspect } : {}), 521 + }} 522 + > 523 + {url ? ( 524 + <img src={url} alt={alt} style={isSingle ? styles.photo : styles.photoGrid} /> 525 + ) : ( 526 + <div 527 + style={{ 528 + ...styles.placeholder, 529 + color: `var(--atproto-color-text-muted)`, 530 + }} 531 + > 532 + {photoLoading 533 + ? "Loading…" 534 + : photoError 535 + ? "Failed to load" 536 + : "Unavailable"} 537 + </div> 538 + )} 539 + {hasAlt && ( 540 + <button 541 + onClick={() => setShowAltText(!showAltText)} 542 + style={{ 543 + ...styles.altBadge, 544 + background: showAltText 545 + ? `var(--atproto-color-text)` 546 + : `var(--atproto-color-bg-secondary)`, 547 + color: showAltText 548 + ? `var(--atproto-color-bg)` 549 + : `var(--atproto-color-text)`, 550 + }} 551 + title="Toggle alt text" 552 + aria-label="Toggle alt text" 553 + > 554 + ALT 555 + </button> 556 + )} 557 + </div> 558 + {hasAlt && showAltText && ( 559 + <figcaption 560 + style={{ 561 + ...styles.caption, 562 + color: `var(--atproto-color-text-secondary)`, 563 + }} 564 + > 565 + {photo.record.alt} 566 + </figcaption> 567 + )} 568 + </figure> 569 + ); 570 + }; 571 + 572 + const styles: Record<string, React.CSSProperties> = { 573 + card: { 574 + borderRadius: 12, 575 + border: `1px solid var(--atproto-color-border)`, 576 + background: `var(--atproto-color-bg)`, 577 + color: `var(--atproto-color-text)`, 578 + fontFamily: "system-ui, sans-serif", 579 + display: "flex", 580 + flexDirection: "column", 581 + maxWidth: 600, 582 + transition: 583 + "background-color 180ms ease, border-color 180ms ease, color 180ms ease", 584 + overflow: "hidden", 585 + }, 586 + header: { 587 + display: "flex", 588 + alignItems: "center", 589 + gap: 12, 590 + padding: 12, 591 + paddingBottom: 0, 592 + }, 593 + avatarPlaceholder: { 594 + width: 32, 595 + height: 32, 596 + borderRadius: "50%", 597 + background: `var(--atproto-color-border)`, 598 + }, 599 + avatarImg: { 600 + width: 32, 601 + height: 32, 602 + borderRadius: "50%", 603 + objectFit: "cover", 604 + }, 605 + authorInfo: { 606 + display: "flex", 607 + flexDirection: "column", 608 + gap: 2, 609 + }, 610 + displayName: { 611 + fontSize: 14, 612 + fontWeight: 600, 613 + }, 614 + handle: { 615 + fontSize: 12, 616 + }, 617 + galleryInfo: { 618 + padding: 12, 619 + paddingBottom: 8, 620 + }, 621 + title: { 622 + margin: 0, 623 + fontSize: 18, 624 + fontWeight: 600, 625 + marginBottom: 4, 626 + }, 627 + description: { 628 + margin: 0, 629 + fontSize: 14, 630 + lineHeight: 1.4, 631 + whiteSpace: "pre-wrap", 632 + }, 633 + singlePhotoContainer: { 634 + padding: 0, 635 + }, 636 + carouselContainer: { 637 + position: "relative", 638 + padding: 4, 639 + }, 640 + photosGrid: { 641 + display: "grid", 642 + gridTemplateColumns: "repeat(2, 1fr)", 643 + gridTemplateRows: "repeat(2, 1fr)", 644 + gap: 4, 645 + minHeight: 400, 646 + }, 647 + navButton: { 648 + position: "absolute", 649 + top: "50%", 650 + transform: "translateY(-50%)", 651 + width: 28, 652 + height: 28, 653 + border: "none", 654 + borderRadius: "50%", 655 + fontSize: 18, 656 + fontWeight: "600", 657 + cursor: "pointer", 658 + display: "flex", 659 + alignItems: "center", 660 + justifyContent: "center", 661 + zIndex: 10, 662 + transition: "opacity 150ms ease", 663 + userSelect: "none", 664 + opacity: 0.7, 665 + }, 666 + navButtonLeft: { 667 + left: 8, 668 + }, 669 + navButtonRight: { 670 + right: 8, 671 + }, 672 + photoItem: { 673 + margin: 0, 674 + display: "flex", 675 + flexDirection: "column", 676 + gap: 4, 677 + }, 678 + singlePhotoItem: { 679 + margin: 0, 680 + display: "flex", 681 + flexDirection: "column", 682 + gap: 8, 683 + }, 684 + photoContainer: { 685 + position: "relative", 686 + width: "100%", 687 + height: "100%", 688 + overflow: "hidden", 689 + borderRadius: 4, 690 + }, 691 + singlePhotoMedia: { 692 + position: "relative", 693 + width: "100%", 694 + overflow: "hidden", 695 + borderRadius: 0, 696 + }, 697 + photo: { 698 + width: "100%", 699 + height: "100%", 700 + objectFit: "cover", 701 + display: "block", 702 + }, 703 + photoGrid: { 704 + width: "100%", 705 + height: "100%", 706 + objectFit: "cover", 707 + display: "block", 708 + }, 709 + placeholder: { 710 + display: "flex", 711 + alignItems: "center", 712 + justifyContent: "center", 713 + width: "100%", 714 + height: "100%", 715 + minHeight: 100, 716 + fontSize: 12, 717 + }, 718 + caption: { 719 + fontSize: 12, 720 + lineHeight: 1.3, 721 + padding: "0 12px 8px", 722 + }, 723 + altBadge: { 724 + position: "absolute", 725 + bottom: 8, 726 + right: 8, 727 + padding: "4px 8px", 728 + fontSize: 10, 729 + fontWeight: 600, 730 + letterSpacing: "0.5px", 731 + border: "none", 732 + borderRadius: 4, 733 + cursor: "pointer", 734 + transition: "background 150ms ease, color 150ms ease", 735 + fontFamily: "system-ui, sans-serif", 736 + }, 737 + footer: { 738 + padding: 12, 739 + paddingTop: 8, 740 + display: "flex", 741 + justifyContent: "space-between", 742 + alignItems: "center", 743 + }, 744 + time: { 745 + fontSize: 11, 746 + }, 747 + paginationDots: { 748 + display: "flex", 749 + gap: 6, 750 + alignItems: "center", 751 + }, 752 + paginationDot: { 753 + width: 6, 754 + height: 6, 755 + borderRadius: "50%", 756 + border: "none", 757 + padding: 0, 758 + cursor: "pointer", 759 + transition: "background 200ms ease, transform 150ms ease", 760 + flexShrink: 0, 761 + }, 762 + }; 763 + 764 + export default GrainGalleryRenderer;
+95
lib/types/grain.ts
··· 1 + /** 2 + * Type definitions for grain.social records 3 + * Uses standard atcute blob types for compatibility 4 + */ 5 + import type { Blob } from "@atcute/lexicons/interfaces"; 6 + import type { BlobWithCdn } from "../hooks/useBlueskyAppview"; 7 + 8 + /** 9 + * grain.social gallery record 10 + * A container for a collection of photos 11 + */ 12 + export interface GrainGalleryRecord { 13 + /** 14 + * Record type identifier 15 + */ 16 + $type: "social.grain.gallery"; 17 + /** 18 + * Gallery title 19 + */ 20 + title: string; 21 + /** 22 + * Gallery description 23 + */ 24 + description?: string; 25 + /** 26 + * Self-label values (content warnings) 27 + */ 28 + labels?: { 29 + $type: "com.atproto.label.defs#selfLabels"; 30 + values: Array<{ val: string }>; 31 + }; 32 + /** 33 + * Timestamp when the gallery was created 34 + */ 35 + createdAt: string; 36 + } 37 + 38 + /** 39 + * grain.social gallery item record 40 + * Links a photo to a gallery 41 + */ 42 + export interface GrainGalleryItemRecord { 43 + /** 44 + * Record type identifier 45 + */ 46 + $type: "social.grain.gallery.item"; 47 + /** 48 + * AT URI of the photo (social.grain.photo) 49 + */ 50 + item: string; 51 + /** 52 + * AT URI of the gallery this item belongs to 53 + */ 54 + gallery: string; 55 + /** 56 + * Position/order within the gallery 57 + */ 58 + position?: number; 59 + /** 60 + * Timestamp when the item was added to the gallery 61 + */ 62 + createdAt: string; 63 + } 64 + 65 + /** 66 + * grain.social photo record 67 + * Compatible with records from @atcute clients 68 + */ 69 + export interface GrainPhotoRecord { 70 + /** 71 + * Record type identifier 72 + */ 73 + $type: "social.grain.photo"; 74 + /** 75 + * Alt text description of the image (required for accessibility) 76 + */ 77 + alt: string; 78 + /** 79 + * Photo blob reference - uses standard AT Proto blob format 80 + * Supports any image/* mime type 81 + * May include cdnUrl when fetched from appview 82 + */ 83 + photo: Blob<`image/${string}`> | BlobWithCdn; 84 + /** 85 + * Timestamp when the photo was created 86 + */ 87 + createdAt?: string; 88 + /** 89 + * Aspect ratio of the photo 90 + */ 91 + aspectRatio?: { 92 + width: number; 93 + height: number; 94 + }; 95 + }
+19
src/App.tsx
··· 12 12 } from "../lib/components/BlueskyPost"; 13 13 import { BlueskyPostList } from "../lib/components/BlueskyPostList"; 14 14 import { BlueskyQuotePost } from "../lib/components/BlueskyQuotePost"; 15 + import { GrainGallery } from "../lib/components/GrainGallery"; 15 16 import { useDidResolution } from "../lib/hooks/useDidResolution"; 16 17 import { useLatestRecord } from "../lib/hooks/useLatestRecord"; 17 18 import type { FeedPostRecord } from "../lib/types/bluesky"; ··· 283 284 <section style={panelStyle}> 284 285 <h3 style={sectionHeaderStyle}>Recent Posts</h3> 285 286 <BlueskyPostList did={did} /> 287 + </section> 288 + <section style={panelStyle}> 289 + <h3 style={sectionHeaderStyle}> 290 + grain.social Gallery Demo 291 + </h3> 292 + <p 293 + style={{ 294 + fontSize: 12, 295 + color: `var(--demo-text-secondary)`, 296 + margin: "0 0 8px", 297 + }} 298 + > 299 + Instagram-style photo gallery from grain.social 300 + </p> 301 + <GrainGallery 302 + did="kat.meangirls.online" 303 + rkey="3m2e2qikseq2f" 304 + /> 286 305 </section> 287 306 </div> 288 307 <div style={columnStackStyle}>