A personal website powered by Astro and ATProto
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}