A personal website powered by Astro and ATProto
at main 232 lines 6.7 kB view raw
1import { AtprotoBrowser } from '../atproto/atproto-browser'; 2import { loadConfig } from '../config/site'; 3import type { AtprotoRecord } from '../atproto/atproto-browser'; 4 5export interface ContentRendererOptions { 6 showAuthor?: boolean; 7 showTimestamp?: boolean; 8 showType?: boolean; 9 limit?: number; 10 filter?: (record: AtprotoRecord) => boolean; 11} 12 13export interface RenderedContent { 14 type: string; 15 component: string; 16 props: Record<string, any>; 17 metadata: { 18 uri: string; 19 cid: string; 20 collection: string; 21 $type: string; 22 createdAt: string; 23 }; 24} 25 26export class ContentRenderer { 27 private browser: AtprotoBrowser; 28 private config: any; 29 30 constructor() { 31 this.config = loadConfig(); 32 this.browser = new AtprotoBrowser(); 33 } 34 35 // Determine the appropriate component for a record type 36 private getComponentForType($type: string): string { 37 // Map ATProto types to component names 38 const componentMap: Record<string, string> = { 39 'app.bsky.feed.post': 'BlueskyPost', 40 'app.bsky.actor.profile#whitewindBlogPost': 'WhitewindBlogPost', 41 'app.bsky.actor.profile#leafletPublication': 'LeafletPublication', 42 43 'gallery.display': 'GalleryDisplay', 44 }; 45 46 // Check for gallery-related types 47 if ($type.includes('gallery') || $type.includes('grain')) { 48 return 'GalleryDisplay'; 49 } 50 51 return componentMap[$type] || 'BlueskyPost'; 52 } 53 54 // Process a record into a renderable format 55 private processRecord(record: AtprotoRecord): RenderedContent | null { 56 const value = record.value; 57 if (!value || !value.$type) return null; 58 59 const component = this.getComponentForType(value.$type); 60 61 // Extract common metadata 62 const metadata = { 63 uri: record.uri, 64 cid: record.cid, 65 collection: record.collection, 66 $type: value.$type, 67 createdAt: value.createdAt || record.indexedAt, 68 }; 69 70 // For gallery display, use the gallery service format 71 if (component === 'GalleryDisplay') { 72 // This would need to be processed by the gallery service 73 // For now, return a basic format 74 return { 75 type: 'gallery', 76 component: 'GalleryDisplay', 77 props: { 78 gallery: { 79 uri: record.uri, 80 cid: record.cid, 81 title: value.title || 'Untitled Gallery', 82 description: value.description, 83 text: value.text, 84 createdAt: value.createdAt || record.indexedAt, 85 images: this.extractImages(value), 86 $type: value.$type, 87 collection: record.collection, 88 }, 89 showDescription: true, 90 showTimestamp: true, 91 showType: false, 92 columns: 3, 93 }, 94 metadata, 95 }; 96 } 97 98 // For other content types, return the record directly 99 return { 100 type: 'content', 101 component, 102 props: { 103 post: value, 104 showAuthor: false, 105 showTimestamp: true, 106 }, 107 metadata, 108 }; 109 } 110 111 // Extract images from various embed formats 112 private extractImages(value: any): Array<{ alt?: string; url: string }> { 113 const images: Array<{ alt?: string; url: string }> = []; 114 115 // Extract from embed.images 116 if (value.embed?.$type === 'app.bsky.embed.images' && value.embed.images) { 117 for (const image of value.embed.images) { 118 if (image.image?.ref) { 119 const did = this.config.atproto.did; 120 const url = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${image.image.ref}`; 121 images.push({ 122 alt: image.alt, 123 url, 124 }); 125 } 126 } 127 } 128 129 // Extract from direct images array 130 if (value.images && Array.isArray(value.images)) { 131 for (const image of value.images) { 132 if (image.url) { 133 images.push({ 134 alt: image.alt, 135 url: image.url, 136 }); 137 } 138 } 139 } 140 141 return images; 142 } 143 144 // Fetch and render content for a given identifier 145 async renderContent( 146 identifier: string, 147 options: ContentRendererOptions = {} 148 ): Promise<RenderedContent[]> { 149 try { 150 const { limit = 50, filter } = options; 151 152 // Get repository info 153 const repoInfo = await this.browser.getRepoInfo(identifier); 154 if (!repoInfo) { 155 throw new Error(`Could not get repository info for: ${identifier}`); 156 } 157 158 const renderedContent: RenderedContent[] = []; 159 160 // Get records from main collections 161 const collections = ['app.bsky.feed.post', 'app.bsky.actor.profile', 'social.grain.gallery']; 162 163 for (const collection of collections) { 164 if (repoInfo.collections.includes(collection)) { 165 const records = await this.browser.getCollectionRecords(identifier, collection, limit); 166 if (records && records.records) { 167 for (const record of records.records) { 168 // Apply filter if provided 169 if (filter && !filter(record)) continue; 170 171 const rendered = this.processRecord(record); 172 if (rendered) { 173 renderedContent.push(rendered); 174 } 175 } 176 } 177 } 178 } 179 180 // Sort by creation date (newest first) 181 renderedContent.sort((a, b) => { 182 const dateA = new Date(a.metadata.createdAt); 183 const dateB = new Date(b.metadata.createdAt); 184 return dateB.getTime() - dateA.getTime(); 185 }); 186 187 return renderedContent; 188 } catch (error) { 189 console.error('Error rendering content:', error); 190 return []; 191 } 192 } 193 194 // Render a specific record by URI 195 async renderRecord(uri: string): Promise<RenderedContent | null> { 196 try { 197 const record = await this.browser.getRecord(uri); 198 if (!record) return null; 199 200 return this.processRecord(record); 201 } catch (error) { 202 console.error('Error rendering record:', error); 203 return null; 204 } 205 } 206 207 // Get available content types for an identifier 208 async getContentTypes(identifier: string): Promise<string[]> { 209 try { 210 const repoInfo = await this.browser.getRepoInfo(identifier); 211 if (!repoInfo) return []; 212 213 const types = new Set<string>(); 214 215 for (const collection of repoInfo.collections) { 216 const records = await this.browser.getCollectionRecords(identifier, collection, 10); 217 if (records && records.records) { 218 for (const record of records.records) { 219 if (record.value?.$type) { 220 types.add(record.value.$type); 221 } 222 } 223 } 224 } 225 226 return Array.from(types); 227 } catch (error) { 228 console.error('Error getting content types:', error); 229 return []; 230 } 231 } 232}