A personal website powered by Astro and ATProto

working content streaming of all collections

+20
README.md
··· 20 20 - **Leaflet Publications**: Publications with categories and rich content 21 21 - **Grain Image Galleries**: Image galleries with descriptions and captions 22 22 23 + ## Pages 24 + 25 + - **Home Page** (`/`): Displays your latest posts and links to other content 26 + - **Galleries Page** (`/galleries`): Shows all your grain.social image galleries 27 + 23 28 ## Quick Start 24 29 25 30 1. **Clone the template**: ··· 128 133 showTimestamp={true} 129 134 /> 130 135 ``` 136 + 137 + ### Displaying Image Galleries 138 + 139 + The template includes a dedicated galleries page that displays all your grain.social image galleries: 140 + 141 + ```astro 142 + <GrainImageGallery 143 + gallery={galleryData} 144 + showDescription={true} 145 + showTimestamp={true} 146 + columns={3} 147 + /> 148 + ``` 149 + 150 + Visit `/galleries` to see all your image galleries in a beautiful grid layout. 131 151 132 152 ## Project Structure 133 153
+33
src/layouts/Layout.astro
··· 20 20 </head> 21 21 <body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white font-sans"> 22 22 <div class="min-h-screen"> 23 + <!-- Navigation --> 24 + <nav class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700"> 25 + <div class="container mx-auto px-4"> 26 + <div class="flex justify-between items-center h-16"> 27 + <div class="flex items-center space-x-8"> 28 + <a href="/" class="text-xl font-bold text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 transition-colors"> 29 + Tynanverse 30 + </a> 31 + <div class="hidden md:flex space-x-6"> 32 + <a href="/" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 33 + Home 34 + </a> 35 + <a href="/galleries" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 36 + Galleries 37 + </a> 38 + <a href="/debug" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 39 + Debug 40 + </a> 41 + <a href="/test-api" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 42 + Test API 43 + </a> 44 + <a href="/comprehensive-test" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 45 + Comprehensive Test 46 + </a> 47 + <a href="/collection-config" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 48 + Collections 49 + </a> 50 + </div> 51 + </div> 52 + </div> 53 + </div> 54 + </nav> 55 + 23 56 <slot /> 24 57 </div> 25 58 </body>
+205
src/lib/atproto/atproto-browser.ts
··· 1 + // ATProto browser implementation based on atptools 2 + import { AtpAgent } from '@atproto/api'; 3 + import { loadConfig } from '../config/site'; 4 + 5 + export interface AtprotoRecord { 6 + uri: string; 7 + cid: string; 8 + value: any; 9 + indexedAt: string; 10 + collection: string; 11 + $type: string; 12 + } 13 + 14 + export interface RepoInfo { 15 + did: string; 16 + handle: string; 17 + collections: string[]; 18 + recordCount: number; 19 + profile?: any; 20 + } 21 + 22 + export interface CollectionInfo { 23 + collection: string; 24 + recordCount: number; 25 + records: AtprotoRecord[]; 26 + cursor?: string; 27 + } 28 + 29 + export class AtprotoBrowser { 30 + private agent: AtpAgent; 31 + private config: any; 32 + 33 + constructor() { 34 + const siteConfig = loadConfig(); 35 + this.config = { 36 + pdsUrl: siteConfig.atproto.pdsUrl || 'https://bsky.social', 37 + }; 38 + this.agent = new AtpAgent({ service: this.config.pdsUrl }); 39 + } 40 + 41 + // Resolve handle to DID 42 + async resolveHandle(handle: string): Promise<string | null> { 43 + try { 44 + const response = await this.agent.api.com.atproto.identity.resolveHandle({ 45 + handle: handle, 46 + }); 47 + return response.data.did; 48 + } catch (error) { 49 + console.error('Error resolving handle:', error); 50 + return null; 51 + } 52 + } 53 + 54 + // Get repository information 55 + async getRepoInfo(identifier: string): Promise<RepoInfo | null> { 56 + try { 57 + // Resolve handle to DID if needed 58 + let did = identifier; 59 + if (identifier.includes('@') || !identifier.startsWith('did:')) { 60 + const resolvedDid = await this.resolveHandle(identifier); 61 + if (!resolvedDid) { 62 + throw new Error(`Could not resolve handle: ${identifier}`); 63 + } 64 + did = resolvedDid; 65 + } 66 + 67 + // Get repository description 68 + const repoResponse = await this.agent.api.com.atproto.repo.describeRepo({ 69 + repo: did, 70 + }); 71 + 72 + // Get profile if available 73 + let profile = null; 74 + try { 75 + const profileResponse = await this.agent.api.app.bsky.actor.getProfile({ 76 + actor: did, 77 + }); 78 + profile = profileResponse.data; 79 + } catch (error) { 80 + // Profile not available, continue without it 81 + } 82 + 83 + return { 84 + did: did, 85 + handle: repoResponse.data.handle || identifier, 86 + collections: repoResponse.data.collections || [], 87 + recordCount: repoResponse.data.recordCount || 0, 88 + profile: profile, 89 + }; 90 + } catch (error) { 91 + console.error('Error getting repo info:', error); 92 + return null; 93 + } 94 + } 95 + 96 + // Get records from a specific collection 97 + async getCollectionRecords( 98 + identifier: string, 99 + collection: string, 100 + limit: number = 100, 101 + cursor?: string 102 + ): Promise<CollectionInfo | null> { 103 + try { 104 + // Resolve handle to DID if needed 105 + let did = identifier; 106 + if (identifier.includes('@') || !identifier.startsWith('did:')) { 107 + const resolvedDid = await this.resolveHandle(identifier); 108 + if (!resolvedDid) { 109 + throw new Error(`Could not resolve handle: ${identifier}`); 110 + } 111 + did = resolvedDid; 112 + } 113 + 114 + // Get records from collection 115 + const response = await this.agent.api.com.atproto.repo.listRecords({ 116 + repo: did, 117 + collection: collection, 118 + limit: limit, 119 + cursor: cursor, 120 + }); 121 + 122 + const records: AtprotoRecord[] = response.data.records.map((record: any) => ({ 123 + uri: record.uri, 124 + cid: record.cid, 125 + value: record.value, 126 + indexedAt: record.indexedAt, 127 + collection: collection, 128 + $type: (record.value?.$type as string) || 'unknown', 129 + })); 130 + 131 + return { 132 + collection: collection, 133 + recordCount: records.length, 134 + records: records, 135 + cursor: response.data.cursor, 136 + }; 137 + } catch (error) { 138 + console.error('Error getting collection records:', error); 139 + return null; 140 + } 141 + } 142 + 143 + // Get all collections for a repository 144 + async getAllCollections(identifier: string): Promise<string[]> { 145 + try { 146 + const repoInfo = await this.getRepoInfo(identifier); 147 + if (!repoInfo) { 148 + return []; 149 + } 150 + return repoInfo.collections; 151 + } catch (error) { 152 + console.error('Error getting collections:', error); 153 + return []; 154 + } 155 + } 156 + 157 + // Get a specific record 158 + async getRecord(uri: string): Promise<AtprotoRecord | null> { 159 + try { 160 + const response = await this.agent.api.com.atproto.repo.getRecord({ 161 + uri: uri, 162 + }); 163 + 164 + const record = response.data; 165 + return { 166 + uri: record.uri, 167 + cid: record.cid, 168 + value: record.value, 169 + indexedAt: record.indexedAt, 170 + collection: record.uri.split('/')[2] || 'unknown', 171 + $type: (record.value?.$type as string) || 'unknown', 172 + }; 173 + } catch (error) { 174 + console.error('Error getting record:', error); 175 + return null; 176 + } 177 + } 178 + 179 + // Search for records by type 180 + async searchRecordsByType( 181 + identifier: string, 182 + $type: string, 183 + limit: number = 100 184 + ): Promise<AtprotoRecord[]> { 185 + try { 186 + const collections = await this.getAllCollections(identifier); 187 + const results: AtprotoRecord[] = []; 188 + 189 + for (const collection of collections) { 190 + const collectionInfo = await this.getCollectionRecords(identifier, collection, limit); 191 + if (collectionInfo) { 192 + const matchingRecords = collectionInfo.records.filter( 193 + record => record.$type === $type 194 + ); 195 + results.push(...matchingRecords); 196 + } 197 + } 198 + 199 + return results; 200 + } catch (error) { 201 + console.error('Error searching records by type:', error); 202 + return []; 203 + } 204 + } 205 + }
+61
src/lib/atproto/client.ts
··· 165 165 }); 166 166 } 167 167 168 + // Get all records from a repository (using existing API) 169 + async getAllRecords(handle: string, limit: number = 100): Promise<AtprotoRecord[]> { 170 + const cacheKey = `all-records:${handle}:${limit}`; 171 + const cached = this.cache.get(cacheKey); 172 + if (cached) return cached; 173 + 174 + try { 175 + console.log('getAllRecords: Starting with handle:', handle); 176 + 177 + // Resolve handle to DID 178 + const did = await this.resolveHandle(handle); 179 + console.log('getAllRecords: Resolved DID:', did); 180 + 181 + if (!did) { 182 + console.error('getAllRecords: Failed to resolve handle to DID'); 183 + return []; 184 + } 185 + 186 + // Try to get records from common collections 187 + console.log('getAllRecords: Trying common collections...'); 188 + const collections = ['app.bsky.feed.post', 'app.bsky.actor.profile']; 189 + let allRecords: AtprotoRecord[] = []; 190 + 191 + for (const collection of collections) { 192 + try { 193 + console.log(`getAllRecords: Trying collection: ${collection}`); 194 + const response = await this.agent.api.com.atproto.repo.listRecords({ 195 + repo: did, 196 + collection, 197 + limit: Math.floor(limit / collections.length), 198 + }); 199 + 200 + console.log(`getAllRecords: Got ${response.data.records.length} records from ${collection}`); 201 + 202 + const records = response.data.records.map((record: any) => ({ 203 + uri: record.uri, 204 + cid: record.cid, 205 + value: record.value, 206 + indexedAt: record.indexedAt, 207 + })); 208 + 209 + allRecords = allRecords.concat(records); 210 + } catch (error) { 211 + console.log(`getAllRecords: No records in collection ${collection}:`, error); 212 + } 213 + } 214 + 215 + console.log('getAllRecords: Total records found:', allRecords.length); 216 + this.cache.set(cacheKey, allRecords); 217 + return allRecords; 218 + } catch (error) { 219 + console.error('getAllRecords: Error fetching all records:', error); 220 + console.error('getAllRecords: Error details:', { 221 + handle, 222 + limit, 223 + error: error instanceof Error ? error.message : String(error) 224 + }); 225 + return []; 226 + } 227 + } 228 + 168 229 // Clear cache (useful for development) 169 230 clearCache(): void { 170 231 this.cache.clear();
+130
src/lib/atproto/collection-discovery.ts
··· 1 + // Comprehensive collection discovery for ATproto repositories 2 + import { AtpAgent } from '@atproto/api'; 3 + import { collectionManager, type CollectionConfig } from '../config/collections'; 4 + 5 + export interface CollectionTest { 6 + collection: string; 7 + exists: boolean; 8 + recordCount: number; 9 + sampleRecords: any[]; 10 + config?: CollectionConfig; 11 + } 12 + 13 + export class CollectionDiscovery { 14 + private agent: AtpAgent; 15 + 16 + constructor(pdsUrl: string = 'https://bsky.social') { 17 + this.agent = new AtpAgent({ service: pdsUrl }); 18 + } 19 + 20 + // Get collection patterns from configuration 21 + private getCollectionPatterns(): string[] { 22 + return collectionManager.getCollectionNames(); 23 + } 24 + 25 + // Test a single collection 26 + async testCollection(did: string, collection: string): Promise<CollectionTest> { 27 + const config = collectionManager.getCollectionInfo(collection); 28 + 29 + try { 30 + const response = await this.agent.api.com.atproto.repo.listRecords({ 31 + repo: did, 32 + collection, 33 + limit: 10, // Just get a few records to test 34 + }); 35 + 36 + return { 37 + collection, 38 + exists: true, 39 + recordCount: response.data.records.length, 40 + sampleRecords: response.data.records.slice(0, 3), 41 + config 42 + }; 43 + } catch (error) { 44 + return { 45 + collection, 46 + exists: false, 47 + recordCount: 0, 48 + sampleRecords: [], 49 + config 50 + }; 51 + } 52 + } 53 + 54 + // Discover all collections by testing all patterns 55 + async discoverAllCollections(did: string): Promise<CollectionTest[]> { 56 + console.log('Starting comprehensive collection discovery...'); 57 + 58 + const patterns = this.getCollectionPatterns(); 59 + console.log(`Testing ${patterns.length} collection patterns from configuration`); 60 + 61 + const results: CollectionTest[] = []; 62 + 63 + // Test collections in parallel batches to speed up discovery 64 + const batchSize = 10; 65 + for (let i = 0; i < patterns.length; i += batchSize) { 66 + const batch = patterns.slice(i, i + batchSize); 67 + console.log(`Testing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(patterns.length / batchSize)}`); 68 + 69 + const batchPromises = batch.map(collection => this.testCollection(did, collection)); 70 + const batchResults = await Promise.all(batchPromises); 71 + 72 + results.push(...batchResults); 73 + 74 + // Small delay to avoid rate limiting 75 + await new Promise(resolve => setTimeout(resolve, 100)); 76 + } 77 + 78 + const existingCollections = results.filter(r => r.exists); 79 + console.log(`Found ${existingCollections.length} existing collections out of ${patterns.length} tested`); 80 + 81 + return results; 82 + } 83 + 84 + // Resolve handle to DID 85 + async resolveHandle(handle: string): Promise<string | null> { 86 + try { 87 + const response = await this.agent.api.com.atproto.identity.resolveHandle({ 88 + handle: handle, 89 + }); 90 + return response.data.did; 91 + } catch (error) { 92 + console.error('Error resolving handle:', error); 93 + return null; 94 + } 95 + } 96 + 97 + // Get all records from all discovered collections 98 + async getAllRecordsFromAllCollections(did: string): Promise<any[]> { 99 + const collectionTests = await this.discoverAllCollections(did); 100 + const existingCollections = collectionTests.filter(ct => ct.exists); 101 + 102 + const allRecords: any[] = []; 103 + 104 + for (const collectionTest of existingCollections) { 105 + try { 106 + console.log(`Getting all records from ${collectionTest.collection}...`); 107 + const response = await this.agent.api.com.atproto.repo.listRecords({ 108 + repo: did, 109 + collection: collectionTest.collection, 110 + limit: 100, // Get up to 100 records (API limit) 111 + }); 112 + 113 + const records = response.data.records.map((record: any) => ({ 114 + uri: record.uri, 115 + cid: record.cid, 116 + value: record.value, 117 + indexedAt: record.indexedAt, 118 + collection: collectionTest.collection 119 + })); 120 + 121 + allRecords.push(...records); 122 + console.log(`Got ${records.length} records from ${collectionTest.collection}`); 123 + } catch (error) { 124 + console.error(`Error getting records from ${collectionTest.collection}:`, error); 125 + } 126 + } 127 + 128 + return allRecords; 129 + } 130 + }
+309
src/lib/atproto/discovery.ts
··· 1 + import { AtpAgent } from '@atproto/api'; 2 + import type { AtprotoRecord } from '../types/atproto'; 3 + 4 + export interface DiscoveredLexicon { 5 + $type: string; 6 + collection: string; 7 + service: string; 8 + sampleRecord: AtprotoRecord; 9 + properties: Record<string, any>; 10 + description: string; 11 + } 12 + 13 + export interface RepositoryAnalysis { 14 + did: string; 15 + collections: string[]; 16 + lexicons: DiscoveredLexicon[]; 17 + totalRecords: number; 18 + recordTypeCounts: Record<string, number>; 19 + } 20 + 21 + export class ATprotoDiscovery { 22 + private agent: AtpAgent; 23 + 24 + constructor(pdsUrl: string = 'https://bsky.social') { 25 + this.agent = new AtpAgent({ service: pdsUrl }); 26 + } 27 + 28 + // Discover all collections in a repository 29 + async discoverCollections(did: string): Promise<string[]> { 30 + const collections = new Set<string>(); 31 + 32 + // Try common collections that are likely to exist 33 + const commonCollections = [ 34 + // Standard Bluesky collections 35 + 'app.bsky.feed.post', 36 + 'app.bsky.actor.profile', 37 + 'app.bsky.feed.generator', 38 + 'app.bsky.graph.follow', 39 + 'app.bsky.graph.block', 40 + 'app.bsky.feed.like', 41 + 'app.bsky.feed.repost', 42 + // Grain.social collections (if they use different naming) 43 + 'grain.social.feed.post', 44 + 'grain.social.actor.profile', 45 + 'grain.social.feed.gallery', 46 + 'grain.social.feed.image', 47 + // Other potential collections 48 + 'app.bsky.feed.image', 49 + 'app.bsky.feed.gallery', 50 + 'app.bsky.feed.media', 51 + // Generic collections that might contain custom content 52 + 'app.bsky.feed.custom', 53 + 'app.bsky.actor.custom' 54 + ]; 55 + 56 + for (const collection of commonCollections) { 57 + try { 58 + const response = await this.agent.api.com.atproto.repo.listRecords({ 59 + repo: did, 60 + collection, 61 + limit: 1, // Just check if the collection exists 62 + }); 63 + 64 + if (response.data.records.length > 0) { 65 + collections.add(collection); 66 + console.log(`Found collection: ${collection}`); 67 + } 68 + } catch (error) { 69 + console.log(`Collection ${collection} not found or empty`); 70 + } 71 + } 72 + 73 + console.log('Discovered collections:', Array.from(collections)); 74 + return Array.from(collections); 75 + } 76 + 77 + // Get all records from a specific collection 78 + async getRecordsFromCollection(did: string, collection: string, limit: number = 100): Promise<AtprotoRecord[]> { 79 + try { 80 + const response = await this.agent.api.com.atproto.repo.listRecords({ 81 + repo: did, 82 + collection, 83 + limit, 84 + }); 85 + 86 + return response.data.records.map((record: any) => ({ 87 + uri: record.uri, 88 + cid: record.cid, 89 + value: record.value, 90 + indexedAt: record.indexedAt, 91 + })); 92 + } catch (error) { 93 + console.log(`No records found in collection: ${collection}`); 94 + return []; 95 + } 96 + } 97 + 98 + // Analyze a repository completely 99 + async analyzeRepository(handle: string): Promise<RepositoryAnalysis> { 100 + console.log('Starting repository analysis for:', handle); 101 + 102 + // Resolve handle to DID 103 + const did = await this.resolveHandle(handle); 104 + if (!did) { 105 + throw new Error(`Failed to resolve handle: ${handle}`); 106 + } 107 + 108 + console.log('Resolved DID:', did); 109 + 110 + // Discover all collections 111 + const collections = await this.discoverCollections(did); 112 + console.log('Found collections:', collections); 113 + 114 + const lexicons: DiscoveredLexicon[] = []; 115 + const recordTypeCounts: Record<string, number> = {}; 116 + let totalRecords = 0; 117 + 118 + // Analyze each collection 119 + for (const collection of collections) { 120 + console.log(`Analyzing collection: ${collection}`); 121 + try { 122 + const records = await this.getRecordsFromCollection(did, collection, 100); // Increased limit 123 + console.log(`Got ${records.length} records from ${collection}`); 124 + 125 + if (records.length > 0) { 126 + totalRecords += records.length; 127 + 128 + // Group records by type 129 + const typeGroups = new Map<string, AtprotoRecord[]>(); 130 + records.forEach(record => { 131 + const $type = record.value?.$type || 'unknown'; 132 + if (!typeGroups.has($type)) { 133 + typeGroups.set($type, []); 134 + } 135 + typeGroups.get($type)!.push(record); 136 + 137 + // Count record types 138 + recordTypeCounts[$type] = (recordTypeCounts[$type] || 0) + 1; 139 + }); 140 + 141 + console.log(`Found ${typeGroups.size} different types in ${collection}`); 142 + 143 + // Create lexicon definitions for each type 144 + typeGroups.forEach((sampleRecords, $type) => { 145 + const sampleRecord = sampleRecords[0]; 146 + const properties = this.extractProperties(sampleRecord.value); 147 + const service = this.inferService($type, collection); 148 + 149 + lexicons.push({ 150 + $type, 151 + collection, 152 + service, 153 + sampleRecord, 154 + properties, 155 + description: `Discovered in collection ${collection}` 156 + }); 157 + }); 158 + } 159 + } catch (error) { 160 + console.error(`Error analyzing collection ${collection}:`, error); 161 + } 162 + } 163 + 164 + // Also search for grain-related content in existing posts 165 + console.log('Searching for grain-related content in existing posts...'); 166 + const grainContent = await this.findGrainContent(did, collections); 167 + if (grainContent.length > 0) { 168 + console.log(`Found ${grainContent.length} grain-related records`); 169 + grainContent.forEach(record => { 170 + const $type = record.value?.$type || 'unknown'; 171 + if (!recordTypeCounts[$type]) { 172 + recordTypeCounts[$type] = 0; 173 + } 174 + recordTypeCounts[$type]++; 175 + 176 + // Add to lexicons if not already present 177 + const existingLexicon = lexicons.find(l => l.$type === $type); 178 + if (!existingLexicon) { 179 + const properties = this.extractProperties(record.value); 180 + lexicons.push({ 181 + $type, 182 + collection: record.uri.split('/')[2] || 'unknown', 183 + service: 'grain.social', 184 + sampleRecord: record, 185 + properties, 186 + description: 'Grain-related content found in posts' 187 + }); 188 + } 189 + }); 190 + } 191 + 192 + console.log('Analysis complete. Found:', { 193 + collections: collections.length, 194 + lexicons: lexicons.length, 195 + totalRecords 196 + }); 197 + 198 + return { 199 + did, 200 + collections, 201 + lexicons, 202 + totalRecords, 203 + recordTypeCounts 204 + }; 205 + } 206 + 207 + // Find grain-related content in existing posts 208 + private async findGrainContent(did: string, collections: string[]): Promise<AtprotoRecord[]> { 209 + const grainRecords: AtprotoRecord[] = []; 210 + 211 + // Look in posts collection for grain-related content 212 + if (collections.includes('app.bsky.feed.post')) { 213 + try { 214 + const posts = await this.getRecordsFromCollection(did, 'app.bsky.feed.post', 200); 215 + console.log(`Searching ${posts.length} posts for grain content`); 216 + 217 + posts.forEach(post => { 218 + const text = post.value?.text || ''; 219 + const $type = post.value?.$type || ''; 220 + 221 + // Check if post contains grain-related content 222 + if (text.includes('grain.social') || 223 + text.includes('gallery') || 224 + text.includes('grain') || 225 + $type.includes('grain') || 226 + post.uri.includes('grain')) { 227 + grainRecords.push(post); 228 + console.log('Found grain-related post:', { 229 + text: text.substring(0, 100), 230 + type: $type, 231 + uri: post.uri 232 + }); 233 + } 234 + }); 235 + } catch (error) { 236 + console.error('Error searching for grain content:', error); 237 + } 238 + } 239 + 240 + return grainRecords; 241 + } 242 + 243 + // Resolve handle to DID 244 + private async resolveHandle(handle: string): Promise<string | null> { 245 + try { 246 + const response = await this.agent.api.com.atproto.identity.resolveHandle({ 247 + handle: handle, 248 + }); 249 + return response.data.did; 250 + } catch (error) { 251 + console.error('Error resolving handle:', error); 252 + return null; 253 + } 254 + } 255 + 256 + // Extract properties from a record value 257 + private extractProperties(value: any): Record<string, any> { 258 + const properties: Record<string, any> = {}; 259 + 260 + if (value && typeof value === 'object') { 261 + for (const [key, val] of Object.entries(value)) { 262 + if (key === '$type') continue; 263 + properties[key] = this.inferType(val); 264 + } 265 + } 266 + 267 + return properties; 268 + } 269 + 270 + // Infer TypeScript type from value 271 + private inferType(value: any): string { 272 + if (value === null) return 'null'; 273 + if (value === undefined) return 'undefined'; 274 + 275 + const type = typeof value; 276 + 277 + switch (type) { 278 + case 'string': 279 + return 'string'; 280 + case 'number': 281 + return 'number'; 282 + case 'boolean': 283 + return 'boolean'; 284 + case 'object': 285 + if (Array.isArray(value)) { 286 + if (value.length === 0) return 'any[]'; 287 + const itemType = this.inferType(value[0]); 288 + return `${itemType}[]`; 289 + } 290 + return 'Record<string, any>'; 291 + default: 292 + return 'any'; 293 + } 294 + } 295 + 296 + // Infer service from type and collection 297 + private inferService($type: string, collection: string): string { 298 + if ($type.includes('grain')) return 'grain.social'; 299 + if ($type.includes('tangled')) return 'sh.tangled'; 300 + if ($type.includes('bsky')) return 'bsky.app'; 301 + if ($type.includes('atproto')) return 'atproto'; 302 + 303 + // Try to extract service from collection 304 + if (collection.includes('grain')) return 'grain.social'; 305 + if (collection.includes('tangled')) return 'sh.tangled'; 306 + 307 + return 'unknown'; 308 + } 309 + }
+248
src/lib/atproto/jetstream-client.ts
··· 1 + // Jetstream-based repository streaming with DID filtering (based on atptools) 2 + import { loadConfig } from '../config/site'; 3 + 4 + export interface JetstreamRecord { 5 + uri: string; 6 + cid: string; 7 + value: any; 8 + indexedAt: string; 9 + collection: string; 10 + $type: string; 11 + service: string; 12 + did: string; 13 + time_us: number; 14 + operation: 'create' | 'update' | 'delete'; 15 + } 16 + 17 + export interface JetstreamConfig { 18 + handle: string; 19 + did?: string; 20 + endpoint?: string; 21 + wantedCollections?: string[]; 22 + wantedDids?: string[]; 23 + cursor?: number; 24 + } 25 + 26 + export class JetstreamClient { 27 + private ws: WebSocket | null = null; 28 + private config: JetstreamConfig; 29 + private targetDid: string | null = null; 30 + private isStreaming = false; 31 + private listeners: { 32 + onRecord?: (record: JetstreamRecord) => void; 33 + onError?: (error: Error) => void; 34 + onConnect?: () => void; 35 + onDisconnect?: () => void; 36 + } = {}; 37 + 38 + constructor(config?: Partial<JetstreamConfig>) { 39 + const siteConfig = loadConfig(); 40 + this.config = { 41 + handle: config?.handle || siteConfig.atproto.handle, 42 + did: config?.did || siteConfig.atproto.did, 43 + endpoint: config?.endpoint || 'wss://jetstream1.us-east.bsky.network/subscribe', 44 + wantedCollections: config?.wantedCollections || [], 45 + wantedDids: config?.wantedDids || [], 46 + cursor: config?.cursor, 47 + }; 48 + this.targetDid = this.config.did || null; 49 + 50 + console.log('🔧 JetstreamClient initialized with handle:', this.config.handle); 51 + console.log('🎯 Target DID for filtering:', this.targetDid); 52 + console.log('🌐 Endpoint:', this.config.endpoint); 53 + } 54 + 55 + // Start streaming all repository activity 56 + async startStreaming(): Promise<void> { 57 + if (this.isStreaming) { 58 + console.log('⚠️ Already streaming repository'); 59 + return; 60 + } 61 + 62 + console.log('🚀 Starting jetstream repository streaming...'); 63 + this.isStreaming = true; 64 + 65 + try { 66 + // Resolve handle to DID if needed 67 + if (!this.targetDid) { 68 + this.targetDid = await this.resolveHandle(this.config.handle); 69 + if (!this.targetDid) { 70 + throw new Error(`Could not resolve handle: ${this.config.handle}`); 71 + } 72 + console.log('✅ Resolved DID:', this.targetDid); 73 + } 74 + 75 + // Add target DID to wanted DIDs 76 + if (this.targetDid && !this.config.wantedDids!.includes(this.targetDid)) { 77 + this.config.wantedDids!.push(this.targetDid); 78 + } 79 + 80 + // Start WebSocket connection 81 + this.connect(); 82 + 83 + } catch (error) { 84 + this.isStreaming = false; 85 + throw error; 86 + } 87 + } 88 + 89 + // Stop streaming 90 + stopStreaming(): void { 91 + if (this.ws) { 92 + this.ws.close(); 93 + this.ws = null; 94 + } 95 + this.isStreaming = false; 96 + console.log('🛑 Stopped jetstream streaming'); 97 + this.listeners.onDisconnect?.(); 98 + } 99 + 100 + // Connect to jetstream WebSocket 101 + private connect(): void { 102 + try { 103 + const url = new URL(this.config.endpoint!); 104 + 105 + // Add query parameters for filtering (using atptools' parameter names) 106 + this.config.wantedCollections!.forEach((collection) => { 107 + url.searchParams.append('wantedCollections', collection); 108 + }); 109 + this.config.wantedDids!.forEach((did) => { 110 + url.searchParams.append('wantedDids', did); 111 + }); 112 + if (this.config.cursor) { 113 + url.searchParams.set('cursor', this.config.cursor.toString()); 114 + } 115 + 116 + console.log('🔌 Connecting to jetstream:', url.toString()); 117 + 118 + this.ws = new WebSocket(url.toString()); 119 + 120 + this.ws.onopen = () => { 121 + console.log('✅ Connected to jetstream'); 122 + this.listeners.onConnect?.(); 123 + }; 124 + 125 + this.ws.onmessage = (event) => { 126 + try { 127 + const data = JSON.parse(event.data); 128 + this.handleMessage(data); 129 + } catch (error) { 130 + console.error('Error parsing jetstream message:', error); 131 + } 132 + }; 133 + 134 + this.ws.onerror = (error) => { 135 + console.error('❌ Jetstream WebSocket error:', error); 136 + this.listeners.onError?.(new Error('WebSocket error')); 137 + }; 138 + 139 + this.ws.onclose = () => { 140 + console.log('🔌 Disconnected from jetstream'); 141 + this.isStreaming = false; 142 + this.listeners.onDisconnect?.(); 143 + }; 144 + 145 + } catch (error) { 146 + console.error('Error connecting to jetstream:', error); 147 + this.listeners.onError?.(error as Error); 148 + } 149 + } 150 + 151 + // Handle incoming jetstream messages 152 + private handleMessage(data: any): void { 153 + try { 154 + // Handle different message types based on atptools' format 155 + if (data.kind === 'commit') { 156 + this.handleCommit(data); 157 + } else if (data.kind === 'account') { 158 + console.log('Account event:', data); 159 + } else if (data.kind === 'identity') { 160 + console.log('Identity event:', data); 161 + } else { 162 + console.log('Unknown message type:', data); 163 + } 164 + } catch (error) { 165 + console.error('Error handling jetstream message:', error); 166 + } 167 + } 168 + 169 + // Handle commit events (record changes) 170 + private handleCommit(data: any): void { 171 + try { 172 + const commit = data.commit; 173 + const event = data; 174 + 175 + // Filter by DID if specified 176 + if (this.targetDid && event.did !== this.targetDid) { 177 + return; 178 + } 179 + 180 + const jetstreamRecord: JetstreamRecord = { 181 + uri: `at://${event.did}/${commit.collection}/${commit.rkey}`, 182 + cid: commit.cid || '', 183 + value: commit.record || {}, 184 + indexedAt: new Date(event.time_us / 1000).toISOString(), 185 + collection: commit.collection, 186 + $type: (commit.record?.$type as string) || 'unknown', 187 + service: this.inferService((commit.record?.$type as string) || '', commit.collection), 188 + did: event.did, 189 + time_us: event.time_us, 190 + operation: commit.operation, 191 + }; 192 + 193 + console.log('📝 New record from jetstream:', { 194 + collection: jetstreamRecord.collection, 195 + $type: jetstreamRecord.$type, 196 + operation: jetstreamRecord.operation, 197 + uri: jetstreamRecord.uri, 198 + service: jetstreamRecord.service 199 + }); 200 + 201 + this.listeners.onRecord?.(jetstreamRecord); 202 + } catch (error) { 203 + console.error('Error handling commit:', error); 204 + } 205 + } 206 + 207 + // Infer service from record type and collection 208 + private inferService($type: string, collection: string): string { 209 + if (collection.startsWith('grain.social')) return 'grain.social'; 210 + if (collection.startsWith('app.bsky')) return 'bsky.app'; 211 + if ($type.includes('grain')) return 'grain.social'; 212 + return 'unknown'; 213 + } 214 + 215 + // Resolve handle to DID 216 + private async resolveHandle(handle: string): Promise<string | null> { 217 + try { 218 + // For now, use the configured DID 219 + // In a real implementation, you'd call the ATProto API 220 + return this.config.did || null; 221 + } catch (error) { 222 + console.error('Error resolving handle:', error); 223 + return null; 224 + } 225 + } 226 + 227 + // Event listeners 228 + onRecord(callback: (record: JetstreamRecord) => void): void { 229 + this.listeners.onRecord = callback; 230 + } 231 + 232 + onError(callback: (error: Error) => void): void { 233 + this.listeners.onError = callback; 234 + } 235 + 236 + onConnect(callback: () => void): void { 237 + this.listeners.onConnect = callback; 238 + } 239 + 240 + onDisconnect(callback: () => void): void { 241 + this.listeners.onDisconnect = callback; 242 + } 243 + 244 + // Get streaming status 245 + getStatus(): 'streaming' | 'stopped' { 246 + return this.isStreaming ? 'streaming' : 'stopped'; 247 + } 248 + }
+268
src/lib/atproto/repository-stream.ts
··· 1 + // Comprehensive repository streaming system for ATProto repositories 2 + import { AtpAgent } from '@atproto/api'; 3 + import { loadConfig } from '../config/site'; 4 + 5 + export interface RepositoryRecord { 6 + uri: string; 7 + cid: string; 8 + value: any; 9 + indexedAt: string; 10 + collection: string; 11 + $type: string; 12 + service: string; 13 + } 14 + 15 + export interface RepositoryStreamConfig { 16 + handle: string; 17 + did?: string; 18 + pdsUrl?: string; 19 + pollInterval?: number; // milliseconds 20 + maxRecordsPerCollection?: number; 21 + } 22 + 23 + export class RepositoryStream { 24 + private agent: AtpAgent; 25 + private config: RepositoryStreamConfig; 26 + private targetDid: string | null = null; 27 + private discoveredCollections: string[] = []; 28 + private isStreaming = false; 29 + private pollInterval: NodeJS.Timeout | null = null; 30 + private lastSeenRecords: Map<string, string> = new Map(); // collection -> last CID 31 + private listeners: { 32 + onRecord?: (record: RepositoryRecord) => void; 33 + onError?: (error: Error) => void; 34 + onConnect?: () => void; 35 + onDisconnect?: () => void; 36 + onCollectionDiscovered?: (collection: string) => void; 37 + } = {}; 38 + 39 + constructor(config?: Partial<RepositoryStreamConfig>) { 40 + const siteConfig = loadConfig(); 41 + this.config = { 42 + handle: config?.handle || siteConfig.atproto.handle, 43 + did: config?.did || siteConfig.atproto.did, 44 + pdsUrl: config?.pdsUrl || siteConfig.atproto.pdsUrl || 'https://bsky.social', 45 + pollInterval: config?.pollInterval || 5000, // 5 seconds 46 + maxRecordsPerCollection: config?.maxRecordsPerCollection || 50, 47 + }; 48 + this.targetDid = this.config.did || null; 49 + this.agent = new AtpAgent({ service: this.config.pdsUrl || 'https://bsky.social' }); 50 + 51 + console.log('🔧 RepositoryStream initialized with handle:', this.config.handle); 52 + console.log('🎯 Target DID for filtering:', this.targetDid); 53 + } 54 + 55 + // Start streaming all repository content 56 + async startStreaming(): Promise<void> { 57 + if (this.isStreaming) { 58 + console.log('⚠️ Already streaming repository'); 59 + return; 60 + } 61 + 62 + console.log('🚀 Starting comprehensive repository streaming...'); 63 + this.isStreaming = true; 64 + 65 + try { 66 + // Resolve handle to DID if needed 67 + if (!this.targetDid) { 68 + this.targetDid = await this.resolveHandle(this.config.handle); 69 + if (!this.targetDid) { 70 + throw new Error(`Could not resolve handle: ${this.config.handle}`); 71 + } 72 + console.log('✅ Resolved DID:', this.targetDid); 73 + } 74 + 75 + // Discover all collections 76 + await this.discoverCollections(); 77 + 78 + // Start polling all collections 79 + this.startPolling(); 80 + 81 + this.listeners.onConnect?.(); 82 + 83 + } catch (error) { 84 + this.isStreaming = false; 85 + throw error; 86 + } 87 + } 88 + 89 + // Stop streaming 90 + stopStreaming(): void { 91 + if (this.pollInterval) { 92 + clearInterval(this.pollInterval); 93 + this.pollInterval = null; 94 + } 95 + this.isStreaming = false; 96 + console.log('🛑 Stopped repository streaming'); 97 + this.listeners.onDisconnect?.(); 98 + } 99 + 100 + // Discover all collections in the repository 101 + private async discoverCollections(): Promise<void> { 102 + console.log('🔍 Discovering collections using repository sync...'); 103 + 104 + this.discoveredCollections = []; 105 + 106 + try { 107 + // Get all records from the repository using sync API 108 + const response = await this.agent.api.com.atproto.repo.listRecords({ 109 + repo: this.targetDid!, 110 + collection: 'app.bsky.feed.post', // Start with posts 111 + limit: 1000, // Get a large sample 112 + }); 113 + 114 + const collectionSet = new Set<string>(); 115 + 116 + // Extract collections from URIs 117 + for (const record of response.data.records) { 118 + const uriParts = record.uri.split('/'); 119 + if (uriParts.length >= 3) { 120 + const collection = uriParts[2]; 121 + collectionSet.add(collection); 122 + } 123 + } 124 + 125 + // Convert to array and sort 126 + this.discoveredCollections = Array.from(collectionSet).sort(); 127 + 128 + console.log(`📊 Discovered ${this.discoveredCollections.length} collections:`, this.discoveredCollections); 129 + 130 + // Notify listeners for each discovered collection 131 + for (const collection of this.discoveredCollections) { 132 + console.log(`✅ Found collection: ${collection}`); 133 + this.listeners.onCollectionDiscovered?.(collection); 134 + } 135 + 136 + } catch (error) { 137 + console.error('Error discovering collections:', error); 138 + // Fallback to basic collections if discovery fails 139 + this.discoveredCollections = ['app.bsky.feed.post', 'app.bsky.actor.profile']; 140 + } 141 + } 142 + 143 + // Start polling all discovered collections 144 + private startPolling(): void { 145 + console.log('⏰ Starting collection polling...'); 146 + 147 + this.pollInterval = setInterval(async () => { 148 + if (!this.isStreaming) return; 149 + 150 + for (const collection of this.discoveredCollections) { 151 + try { 152 + await this.pollCollection(collection); 153 + } catch (error) { 154 + console.error(`❌ Error polling collection ${collection}:`, error); 155 + } 156 + } 157 + }, this.config.pollInterval); 158 + } 159 + 160 + // Poll a specific collection for new records 161 + private async pollCollection(collection: string): Promise<void> { 162 + try { 163 + const response = await this.agent.api.com.atproto.repo.listRecords({ 164 + repo: this.targetDid!, 165 + collection, 166 + limit: this.config.maxRecordsPerCollection!, 167 + }); 168 + 169 + if (response.data.records.length === 0) return; 170 + 171 + const lastSeenCid = this.lastSeenRecords.get(collection); 172 + const newRecords: RepositoryRecord[] = []; 173 + 174 + for (const record of response.data.records) { 175 + // Check if this is a new record 176 + if (!lastSeenCid || record.cid !== lastSeenCid) { 177 + const repositoryRecord: RepositoryRecord = { 178 + uri: record.uri, 179 + cid: record.cid, 180 + value: record.value, 181 + indexedAt: (record as any).indexedAt || new Date().toISOString(), 182 + collection, 183 + $type: (record.value?.$type as string) || 'unknown', 184 + service: this.inferService((record.value?.$type as string) || '', collection), 185 + }; 186 + 187 + newRecords.push(repositoryRecord); 188 + } else { 189 + // We've reached records we've already seen 190 + break; 191 + } 192 + } 193 + 194 + // Update last seen CID 195 + if (response.data.records.length > 0) { 196 + this.lastSeenRecords.set(collection, response.data.records[0].cid); 197 + } 198 + 199 + // Process new records 200 + for (const record of newRecords.reverse()) { // Process oldest first 201 + console.log('📝 New record from collection:', { 202 + collection: record.collection, 203 + $type: record.$type, 204 + uri: record.uri, 205 + service: record.service 206 + }); 207 + 208 + this.listeners.onRecord?.(record); 209 + } 210 + 211 + } catch (error) { 212 + console.error(`❌ Error polling collection ${collection}:`, error); 213 + } 214 + } 215 + 216 + // Infer service from record type and collection 217 + private inferService($type: string, collection: string): string { 218 + if (collection.startsWith('grain.social')) return 'grain.social'; 219 + if (collection.startsWith('app.bsky')) return 'bsky.app'; 220 + if ($type.includes('grain')) return 'grain.social'; 221 + if (collection === 'grain.social.content') return 'grain.social'; 222 + return 'unknown'; 223 + } 224 + 225 + // Resolve handle to DID 226 + private async resolveHandle(handle: string): Promise<string | null> { 227 + try { 228 + const response = await this.agent.api.com.atproto.identity.resolveHandle({ 229 + handle: handle, 230 + }); 231 + return response.data.did; 232 + } catch (error) { 233 + console.error('Error resolving handle:', error); 234 + return null; 235 + } 236 + } 237 + 238 + // Event listeners 239 + onRecord(callback: (record: RepositoryRecord) => void): void { 240 + this.listeners.onRecord = callback; 241 + } 242 + 243 + onError(callback: (error: Error) => void): void { 244 + this.listeners.onError = callback; 245 + } 246 + 247 + onConnect(callback: () => void): void { 248 + this.listeners.onConnect = callback; 249 + } 250 + 251 + onDisconnect(callback: () => void): void { 252 + this.listeners.onDisconnect = callback; 253 + } 254 + 255 + onCollectionDiscovered(callback: (collection: string) => void): void { 256 + this.listeners.onCollectionDiscovered = callback; 257 + } 258 + 259 + // Get streaming status 260 + getStatus(): 'streaming' | 'stopped' { 261 + return this.isStreaming ? 'streaming' : 'stopped'; 262 + } 263 + 264 + // Get discovered collections 265 + getDiscoveredCollections(): string[] { 266 + return [...this.discoveredCollections]; 267 + } 268 + }
+142
src/lib/atproto/turbostream.ts
··· 1 + // Simple Graze Turbostream client for real-time, hydrated ATproto records 2 + import { loadConfig } from '../config/site'; 3 + 4 + export interface TurbostreamRecord { 5 + at_uri: string; 6 + did: string; 7 + time_us: number; 8 + message: any; // Raw jetstream record 9 + hydrated_metadata: { 10 + user?: any; // profileViewDetailed 11 + mentions?: Record<string, any>; // Map of mentioned DIDs to profile objects 12 + parent_post?: any; // postViewBasic 13 + reply_post?: any; // postView 14 + quote_post?: any; // postView or null 15 + }; 16 + } 17 + 18 + export class TurbostreamClient { 19 + private ws: WebSocket | null = null; 20 + private config: any; 21 + private targetDid: string | null = null; 22 + private listeners: { 23 + onRecord?: (record: TurbostreamRecord) => void; 24 + onError?: (error: Error) => void; 25 + onConnect?: () => void; 26 + onDisconnect?: () => void; 27 + } = {}; 28 + 29 + constructor() { 30 + const siteConfig = loadConfig(); 31 + this.config = { 32 + handle: siteConfig.atproto.handle, 33 + did: siteConfig.atproto.did, 34 + }; 35 + this.targetDid = siteConfig.atproto.did || null; 36 + console.log('🔧 TurbostreamClient initialized with handle:', this.config.handle); 37 + console.log('🎯 Target DID for filtering:', this.targetDid); 38 + } 39 + 40 + // Connect to Turbostream WebSocket 41 + async connect(): Promise<void> { 42 + if (this.ws?.readyState === WebSocket.OPEN) { 43 + console.log('⚠️ Already connected to Turbostream'); 44 + return; 45 + } 46 + 47 + console.log('🔌 Connecting to Graze Turbostream...'); 48 + console.log('📍 WebSocket URL: wss://api.graze.social/app/api/v1/turbostream/turbostream'); 49 + 50 + const wsUrl = `wss://api.graze.social/app/api/v1/turbostream/turbostream`; 51 + this.ws = new WebSocket(wsUrl); 52 + 53 + this.ws.onopen = () => { 54 + console.log('✅ Connected to Graze Turbostream'); 55 + console.log('📡 WebSocket readyState:', this.ws?.readyState); 56 + this.listeners.onConnect?.(); 57 + }; 58 + 59 + this.ws.onmessage = (event) => { 60 + try { 61 + const data = JSON.parse(event.data); 62 + this.processMessage(data); 63 + } catch (error) { 64 + console.error('❌ Error parsing Turbostream message:', error); 65 + this.listeners.onError?.(error as Error); 66 + } 67 + }; 68 + 69 + this.ws.onerror = (error) => { 70 + console.error('❌ Turbostream WebSocket error:', error); 71 + this.listeners.onError?.(new Error('WebSocket error')); 72 + }; 73 + 74 + this.ws.onclose = (event) => { 75 + console.log('🔌 Turbostream WebSocket closed:', event.code, event.reason); 76 + this.listeners.onDisconnect?.(); 77 + }; 78 + } 79 + 80 + // Disconnect from Turbostream 81 + disconnect(): void { 82 + if (this.ws) { 83 + console.log('🔌 Disconnecting from Turbostream...'); 84 + this.ws.close(1000, 'Manual disconnect'); 85 + this.ws = null; 86 + } 87 + } 88 + 89 + // Process incoming messages from Turbostream 90 + private processMessage(data: any): void { 91 + if (Array.isArray(data)) { 92 + data.forEach((record: TurbostreamRecord) => { 93 + this.processRecord(record); 94 + }); 95 + } else if (data && typeof data === 'object') { 96 + this.processRecord(data as TurbostreamRecord); 97 + } 98 + } 99 + 100 + // Process individual record 101 + private processRecord(record: TurbostreamRecord): void { 102 + // Filter records to only show those from our configured handle 103 + if (this.targetDid && record.did !== this.targetDid) { 104 + return; 105 + } 106 + 107 + console.log('📝 Processing record from target DID:', { 108 + uri: record.at_uri, 109 + did: record.did, 110 + time: new Date(record.time_us / 1000).toISOString(), 111 + hasUser: !!record.hydrated_metadata?.user, 112 + hasText: !!record.message?.text, 113 + textPreview: record.message?.text?.substring(0, 50) + '...' 114 + }); 115 + 116 + this.listeners.onRecord?.(record); 117 + } 118 + 119 + // Event listeners 120 + onRecord(callback: (record: TurbostreamRecord) => void): void { 121 + this.listeners.onRecord = callback; 122 + } 123 + 124 + onError(callback: (error: Error) => void): void { 125 + this.listeners.onError = callback; 126 + } 127 + 128 + onConnect(callback: () => void): void { 129 + this.listeners.onConnect = callback; 130 + } 131 + 132 + onDisconnect(callback: () => void): void { 133 + this.listeners.onDisconnect = callback; 134 + } 135 + 136 + // Get connection status 137 + getStatus(): 'connecting' | 'connected' | 'disconnected' { 138 + if (this.ws?.readyState === WebSocket.CONNECTING) return 'connecting'; 139 + if (this.ws?.readyState === WebSocket.OPEN) return 'connected'; 140 + return 'disconnected'; 141 + } 142 + }
+212
src/lib/config/collections.ts
··· 1 + // Configuration for known ATproto collections 2 + export interface CollectionConfig { 3 + name: string; 4 + description: string; 5 + service: string; 6 + priority: number; // Higher priority = test first 7 + enabled: boolean; 8 + } 9 + 10 + // Known collections configuration 11 + export const KNOWN_COLLECTIONS: CollectionConfig[] = [ 12 + // Standard Bluesky collections (high priority) 13 + { 14 + name: 'app.bsky.feed.post', 15 + description: 'Standard Bluesky posts', 16 + service: 'bsky.app', 17 + priority: 100, 18 + enabled: true 19 + }, 20 + { 21 + name: 'app.bsky.actor.profile', 22 + description: 'Bluesky profile information', 23 + service: 'bsky.app', 24 + priority: 90, 25 + enabled: true 26 + }, 27 + { 28 + name: 'app.bsky.feed.generator', 29 + description: 'Bluesky custom feeds', 30 + service: 'bsky.app', 31 + priority: 80, 32 + enabled: true 33 + }, 34 + { 35 + name: 'app.bsky.graph.follow', 36 + description: 'Bluesky follow relationships', 37 + service: 'bsky.app', 38 + priority: 70, 39 + enabled: true 40 + }, 41 + { 42 + name: 'app.bsky.graph.block', 43 + description: 'Bluesky block relationships', 44 + service: 'bsky.app', 45 + priority: 60, 46 + enabled: true 47 + }, 48 + { 49 + name: 'app.bsky.feed.like', 50 + description: 'Bluesky like records', 51 + service: 'bsky.app', 52 + priority: 50, 53 + enabled: true 54 + }, 55 + { 56 + name: 'app.bsky.feed.repost', 57 + description: 'Bluesky repost records', 58 + service: 'bsky.app', 59 + priority: 40, 60 + enabled: true 61 + }, 62 + 63 + // Grain.social collections (high priority for your use case) 64 + { 65 + name: 'grain.social.feed.gallery', 66 + description: 'Grain.social image galleries', 67 + service: 'grain.social', 68 + priority: 95, 69 + enabled: true 70 + }, 71 + { 72 + name: 'grain.social.feed.post', 73 + description: 'Grain.social posts', 74 + service: 'grain.social', 75 + priority: 85, 76 + enabled: true 77 + }, 78 + { 79 + name: 'grain.social.actor.profile', 80 + description: 'Grain.social profile information', 81 + service: 'grain.social', 82 + priority: 75, 83 + enabled: true 84 + }, 85 + { 86 + name: 'grain.social.feed.image', 87 + description: 'Grain.social image posts', 88 + service: 'grain.social', 89 + priority: 65, 90 + enabled: true 91 + }, 92 + { 93 + name: 'grain.social.feed.media', 94 + description: 'Grain.social media posts', 95 + service: 'grain.social', 96 + priority: 55, 97 + enabled: true 98 + }, 99 + 100 + // Sh.tangled collections 101 + { 102 + name: 'sh.tangled.feed.star', 103 + description: 'Sh.tangled star records', 104 + service: 'sh.tangled', 105 + priority: 45, 106 + enabled: true 107 + }, 108 + { 109 + name: 'sh.tangled.feed.post', 110 + description: 'Sh.tangled posts', 111 + service: 'sh.tangled', 112 + priority: 35, 113 + enabled: true 114 + }, 115 + { 116 + name: 'sh.tangled.actor.profile', 117 + description: 'Sh.tangled profile information', 118 + service: 'sh.tangled', 119 + priority: 25, 120 + enabled: true 121 + }, 122 + 123 + // Generic collections that might contain custom content 124 + { 125 + name: 'app.bsky.feed.custom', 126 + description: 'Custom Bluesky feed content', 127 + service: 'bsky.app', 128 + priority: 30, 129 + enabled: true 130 + }, 131 + { 132 + name: 'app.bsky.actor.custom', 133 + description: 'Custom Bluesky actor content', 134 + service: 'bsky.app', 135 + priority: 20, 136 + enabled: true 137 + }, 138 + { 139 + name: 'app.bsky.feed.media', 140 + description: 'Bluesky media content', 141 + service: 'bsky.app', 142 + priority: 15, 143 + enabled: true 144 + }, 145 + { 146 + name: 'app.bsky.feed.image', 147 + description: 'Bluesky image content', 148 + service: 'bsky.app', 149 + priority: 10, 150 + enabled: true 151 + }, 152 + { 153 + name: 'app.bsky.feed.gallery', 154 + description: 'Bluesky gallery content', 155 + service: 'bsky.app', 156 + priority: 5, 157 + enabled: true 158 + } 159 + ]; 160 + 161 + // Collection management utilities 162 + export class CollectionManager { 163 + private collections: CollectionConfig[]; 164 + 165 + constructor(customCollections: CollectionConfig[] = []) { 166 + this.collections = [...KNOWN_COLLECTIONS, ...customCollections]; 167 + } 168 + 169 + // Get all enabled collections sorted by priority 170 + getEnabledCollections(): CollectionConfig[] { 171 + return this.collections 172 + .filter(c => c.enabled) 173 + .sort((a, b) => b.priority - a.priority); 174 + } 175 + 176 + // Get collections by service 177 + getCollectionsByService(service: string): CollectionConfig[] { 178 + return this.collections.filter(c => c.service === service && c.enabled); 179 + } 180 + 181 + // Get collection names for API calls 182 + getCollectionNames(): string[] { 183 + return this.getEnabledCollections().map(c => c.name); 184 + } 185 + 186 + // Add a new collection 187 + addCollection(collection: CollectionConfig): void { 188 + this.collections.push(collection); 189 + } 190 + 191 + // Enable/disable collections by service 192 + setServiceEnabled(service: string, enabled: boolean): void { 193 + this.collections.forEach(c => { 194 + if (c.service === service) { 195 + c.enabled = enabled; 196 + } 197 + }); 198 + } 199 + 200 + // Get collection info by name 201 + getCollectionInfo(name: string): CollectionConfig | undefined { 202 + return this.collections.find(c => c.name === name); 203 + } 204 + 205 + // Get all services 206 + getServices(): string[] { 207 + return [...new Set(this.collections.map(c => c.service))]; 208 + } 209 + } 210 + 211 + // Default collection manager instance 212 + export const collectionManager = new CollectionManager();
+13 -6
src/lib/config/site.ts
··· 1 1 import dotenv from 'dotenv'; 2 2 3 - // Load environment variables from .env file 4 - dotenv.config(); 3 + // Load environment variables from .env file (server-side only) 4 + if (typeof process !== 'undefined') { 5 + dotenv.config(); 6 + } 5 7 6 8 export interface SiteConfig { 7 9 // ATproto configuration ··· 34 36 }; 35 37 } 36 38 37 - // Default configuration 39 + // Default configuration with static handle 38 40 export const defaultConfig: SiteConfig = { 39 41 atproto: { 40 - handle: '', 41 - did: '', 42 + handle: 'tynanpurdy.com', // Static handle - not confidential 43 + did: 'did:plc:6ayddqghxhciedbaofoxkcbs', 42 44 pdsUrl: 'https://bsky.social', 43 45 }, 44 46 site: { ··· 59 61 }, 60 62 }; 61 63 62 - // Load configuration from environment variables 64 + // Load configuration from environment variables (server-side only) 63 65 export function loadConfig(): SiteConfig { 66 + // In browser environment, return default config with static handle 67 + if (typeof process === 'undefined') { 68 + return defaultConfig; 69 + } 70 + 64 71 return { 65 72 atproto: { 66 73 handle: process.env.ATPROTO_HANDLE || defaultConfig.atproto.handle,
+25 -1
src/lib/types/atproto.ts
··· 102 102 createdAt: string; 103 103 } 104 104 105 + // Generic grain gallery post type (for posts that contain galleries) 106 + export interface GrainGalleryPost extends CustomLexiconRecord { 107 + $type: 'app.bsky.feed.post#grainGallery' | 'app.bsky.feed.post#grainImageGallery'; 108 + text?: string; 109 + createdAt: string; 110 + embed?: { 111 + $type: 'app.bsky.embed.images'; 112 + images?: Array<{ 113 + alt?: string; 114 + image: { 115 + $type: 'blob'; 116 + ref: string; 117 + mimeType: string; 118 + size: number; 119 + }; 120 + aspectRatio?: { 121 + width: number; 122 + height: number; 123 + }; 124 + }>; 125 + }; 126 + } 127 + 105 128 // Union type for all supported content types 106 129 export type SupportedContentType = 107 130 | BlueskyPost 108 131 | WhitewindBlogPost 109 132 | LeafletPublication 110 - | GrainImageGallery; 133 + | GrainImageGallery 134 + | GrainGalleryPost; 111 135 112 136 // Component registry type 113 137 export interface ContentComponent {
+145
src/lib/types/generator.ts
··· 1 + import type { DiscoveredLexicon } from '../atproto/discovery'; 2 + 3 + export interface GeneratedType { 4 + name: string; 5 + interface: string; 6 + $type: string; 7 + properties: Record<string, any>; 8 + service: string; 9 + collection: string; 10 + } 11 + 12 + export class TypeGenerator { 13 + private generatedTypes: Map<string, GeneratedType> = new Map(); 14 + 15 + // Generate TypeScript interface from a discovered lexicon 16 + generateTypeFromLexicon(lexicon: DiscoveredLexicon): GeneratedType { 17 + const $type = lexicon.$type; 18 + 19 + // Skip if already generated 20 + if (this.generatedTypes.has($type)) { 21 + return this.generatedTypes.get($type)!; 22 + } 23 + 24 + const typeName = this.generateTypeName($type); 25 + const interfaceCode = this.generateInterfaceCode(typeName, lexicon); 26 + 27 + const generatedType: GeneratedType = { 28 + name: typeName, 29 + interface: interfaceCode, 30 + $type, 31 + properties: lexicon.properties, 32 + service: lexicon.service, 33 + collection: lexicon.collection 34 + }; 35 + 36 + this.generatedTypes.set($type, generatedType); 37 + return generatedType; 38 + } 39 + 40 + // Generate type name from $type 41 + private generateTypeName($type: string): string { 42 + const parts = $type.split('#'); 43 + if (parts.length > 1) { 44 + return parts[1].charAt(0).toUpperCase() + parts[1].slice(1); 45 + } 46 + 47 + const lastPart = $type.split('.').pop() || 'Unknown'; 48 + return lastPart.charAt(0).toUpperCase() + lastPart.slice(1); 49 + } 50 + 51 + // Generate TypeScript interface code 52 + private generateInterfaceCode(name: string, lexicon: DiscoveredLexicon): string { 53 + const propertyLines = Object.entries(lexicon.properties).map(([key, type]) => { 54 + return ` ${key}: ${type};`; 55 + }); 56 + 57 + return `export interface ${name} extends CustomLexiconRecord { 58 + $type: '${lexicon.$type}'; 59 + ${propertyLines.join('\n')} 60 + }`; 61 + } 62 + 63 + // Generate all types from discovered lexicons 64 + generateTypesFromLexicons(lexicons: DiscoveredLexicon[]): GeneratedType[] { 65 + const types: GeneratedType[] = []; 66 + 67 + lexicons.forEach(lexicon => { 68 + const type = this.generateTypeFromLexicon(lexicon); 69 + types.push(type); 70 + }); 71 + 72 + return types; 73 + } 74 + 75 + // Get all generated types 76 + getAllGeneratedTypes(): GeneratedType[] { 77 + return Array.from(this.generatedTypes.values()); 78 + } 79 + 80 + // Generate complete types file content 81 + generateTypesFile(lexicons: DiscoveredLexicon[]): string { 82 + const types = this.generateTypesFromLexicons(lexicons); 83 + 84 + if (types.length === 0) { 85 + return '// No types generated'; 86 + } 87 + 88 + const imports = `import type { CustomLexiconRecord } from './atproto';`; 89 + const interfaces = types.map(type => type.interface).join('\n\n'); 90 + const unionType = this.generateUnionType(types); 91 + const serviceGroups = this.generateServiceGroups(types); 92 + 93 + return `${imports} 94 + 95 + ${interfaces} 96 + 97 + ${unionType} 98 + 99 + ${serviceGroups}`; 100 + } 101 + 102 + // Generate union type for all generated types 103 + private generateUnionType(types: GeneratedType[]): string { 104 + const typeNames = types.map(t => t.name); 105 + return `// Union type for all generated content types 106 + export type GeneratedContentType = ${typeNames.join(' | ')};`; 107 + } 108 + 109 + // Generate service-specific type groups 110 + private generateServiceGroups(types: GeneratedType[]): string { 111 + const serviceGroups = new Map<string, GeneratedType[]>(); 112 + 113 + types.forEach(type => { 114 + if (!serviceGroups.has(type.service)) { 115 + serviceGroups.set(type.service, []); 116 + } 117 + serviceGroups.get(type.service)!.push(type); 118 + }); 119 + 120 + let serviceGroupsCode = ''; 121 + serviceGroups.forEach((types, service) => { 122 + const typeNames = types.map(t => t.name); 123 + serviceGroupsCode += ` 124 + // ${service} types 125 + export type ${this.capitalizeService(service)}ContentType = ${typeNames.join(' | ')};`; 126 + }); 127 + 128 + return serviceGroupsCode; 129 + } 130 + 131 + // Capitalize service name for type name 132 + private capitalizeService(service: string): string { 133 + return service.split('.').map(part => 134 + part.charAt(0).toUpperCase() + part.slice(1) 135 + ).join(''); 136 + } 137 + 138 + // Clear all generated types 139 + clear(): void { 140 + this.generatedTypes.clear(); 141 + } 142 + } 143 + 144 + // Global type generator instance 145 + export const typeGenerator = new TypeGenerator();
+176
src/pages/atproto-browser-test.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + import { loadConfig } from '../lib/config/site'; 4 + 5 + const config = loadConfig(); 6 + --- 7 + 8 + <Layout title="ATProto Browser Test"> 9 + <div class="container mx-auto px-4 py-8"> 10 + <h1 class="text-4xl font-bold mb-8">ATProto Browser Test</h1> 11 + 12 + <div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8"> 13 + <h2 class="text-2xl font-semibold mb-4">Configuration</h2> 14 + <p><strong>Handle:</strong> {config.atproto.handle}</p> 15 + <p><strong>DID:</strong> {config.atproto.did}</p> 16 + <p class="text-sm text-gray-600 mt-2">Browse ATProto accounts and records like atptools.</p> 17 + </div> 18 + 19 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> 20 + <div class="bg-white border border-gray-200 rounded-lg p-6"> 21 + <h2 class="text-2xl font-semibold mb-4">Browse Account</h2> 22 + <div class="space-y-4"> 23 + <div> 24 + <label class="block text-sm font-medium text-gray-700 mb-2">Account (handle or DID)</label> 25 + <input id="account-input" type="text" value={config.atproto.handle} class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> 26 + </div> 27 + <button id="browse-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"> 28 + Browse Account 29 + </button> 30 + </div> 31 + </div> 32 + 33 + <div class="bg-white border border-gray-200 rounded-lg p-6"> 34 + <h2 class="text-2xl font-semibold mb-4">Account Info</h2> 35 + <div id="account-info" class="space-y-2"> 36 + <p class="text-gray-500">Enter an account to browse...</p> 37 + </div> 38 + </div> 39 + </div> 40 + 41 + <div class="mt-8"> 42 + <h2 class="text-2xl font-semibold mb-4">Collections</h2> 43 + <div id="collections-container" class="space-y-4"> 44 + <p class="text-gray-500 text-center py-8">No collections loaded...</p> 45 + </div> 46 + </div> 47 + 48 + <div class="mt-8"> 49 + <h2 class="text-2xl font-semibold mb-4">Records</h2> 50 + <div id="records-container" class="space-y-4"> 51 + <p class="text-gray-500 text-center py-8">No records loaded...</p> 52 + </div> 53 + </div> 54 + </div> 55 + </Layout> 56 + 57 + <script> 58 + import { AtprotoBrowser } from '../lib/atproto/atproto-browser'; 59 + 60 + const browser = new AtprotoBrowser(); 61 + let currentAccount: string | null = null; 62 + 63 + // DOM elements 64 + const accountInput = document.getElementById('account-input') as HTMLInputElement; 65 + const browseBtn = document.getElementById('browse-btn') as HTMLButtonElement; 66 + const accountInfo = document.getElementById('account-info') as HTMLDivElement; 67 + const collectionsContainer = document.getElementById('collections-container') as HTMLDivElement; 68 + const recordsContainer = document.getElementById('records-container') as HTMLDivElement; 69 + 70 + function displayAccountInfo(repoInfo: any) { 71 + accountInfo.innerHTML = ` 72 + <div class="space-y-2"> 73 + <div><strong>DID:</strong> <span class="font-mono text-sm">${repoInfo.did}</span></div> 74 + <div><strong>Handle:</strong> ${repoInfo.handle}</div> 75 + <div><strong>Collections:</strong> ${repoInfo.collections.length}</div> 76 + <div><strong>Total Records:</strong> ${repoInfo.recordCount}</div> 77 + ${repoInfo.profile ? `<div><strong>Display Name:</strong> ${repoInfo.profile.displayName || 'N/A'}</div>` : ''} 78 + </div> 79 + `; 80 + } 81 + 82 + function displayCollections(collections: string[]) { 83 + if (collections.length === 0) { 84 + collectionsContainer.innerHTML = '<p class="text-gray-500 text-center py-8">No collections found</p>'; 85 + return; 86 + } 87 + 88 + collectionsContainer.innerHTML = ` 89 + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> 90 + ${collections.map(collection => ` 91 + <div class="bg-gray-50 border border-gray-200 rounded-lg p-4 cursor-pointer hover:bg-gray-100 transition-colors" onclick="loadCollection('${collection}')"> 92 + <h3 class="font-semibold text-gray-900">${collection}</h3> 93 + <p class="text-sm text-gray-600">Click to view records</p> 94 + </div> 95 + `).join('')} 96 + </div> 97 + `; 98 + } 99 + 100 + async function loadCollection(collection: string) { 101 + if (!currentAccount) return; 102 + 103 + try { 104 + const collectionInfo = await browser.getCollectionRecords(currentAccount, collection, 50); 105 + if (collectionInfo) { 106 + displayRecords(collectionInfo.records, collection); 107 + } 108 + } catch (error) { 109 + console.error('Error loading collection:', error); 110 + recordsContainer.innerHTML = '<p class="text-red-500 text-center py-8">Error loading collection</p>'; 111 + } 112 + } 113 + 114 + function displayRecords(records: any[], collection: string) { 115 + if (records.length === 0) { 116 + recordsContainer.innerHTML = '<p class="text-gray-500 text-center py-8">No records found in this collection</p>'; 117 + return; 118 + } 119 + 120 + recordsContainer.innerHTML = ` 121 + <div class="mb-4"> 122 + <h3 class="text-lg font-semibold">${collection} (${records.length} records)</h3> 123 + </div> 124 + <div class="space-y-4"> 125 + ${records.map(record => ` 126 + <div class="bg-white border border-gray-200 rounded-lg p-4"> 127 + <div class="flex items-center space-x-2 mb-2"> 128 + <span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs font-medium">${record.collection}</span> 129 + <span class="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs font-medium">${record.$type}</span> 130 + </div> 131 + ${record.value?.text ? `<p class="text-sm text-gray-600 mb-2">${record.value.text}</p>` : ''} 132 + <p class="text-xs text-gray-500">${new Date(record.indexedAt).toLocaleString()}</p> 133 + <p class="text-xs font-mono text-gray-400 mt-1 break-all">${record.uri}</p> 134 + </div> 135 + `).join('')} 136 + </div> 137 + `; 138 + } 139 + 140 + browseBtn.addEventListener('click', async () => { 141 + const account = accountInput.value.trim(); 142 + if (!account) { 143 + alert('Please enter an account handle or DID'); 144 + return; 145 + } 146 + 147 + try { 148 + browseBtn.disabled = true; 149 + browseBtn.textContent = 'Loading...'; 150 + 151 + // Get account info 152 + const repoInfo = await browser.getRepoInfo(account); 153 + if (!repoInfo) { 154 + alert('Could not load account information'); 155 + return; 156 + } 157 + 158 + currentAccount = account; 159 + displayAccountInfo(repoInfo); 160 + displayCollections(repoInfo.collections); 161 + 162 + // Clear previous records 163 + recordsContainer.innerHTML = '<p class="text-gray-500 text-center py-8">Select a collection to view records</p>'; 164 + 165 + } catch (error) { 166 + console.error('Error browsing account:', error); 167 + alert('Error browsing account. Check the console for details.'); 168 + } finally { 169 + browseBtn.disabled = false; 170 + browseBtn.textContent = 'Browse Account'; 171 + } 172 + }); 173 + 174 + // Make loadCollection available globally 175 + (window as any).loadCollection = loadCollection; 176 + </script>
+142
src/pages/galleries.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + import GrainImageGallery from '../components/content/GrainImageGallery.astro'; 4 + import { ATprotoDiscovery } from '../lib/atproto/discovery'; 5 + import { loadConfig } from '../lib/config/site'; 6 + import type { AtprotoRecord } from '../lib/types/atproto'; 7 + 8 + const config = loadConfig(); 9 + const discovery = new ATprotoDiscovery(config.atproto.pdsUrl); 10 + 11 + // Fetch all records and filter for galleries 12 + let galleries: AtprotoRecord[] = []; 13 + try { 14 + if (config.atproto.handle && config.atproto.handle !== 'your-handle-here') { 15 + // Perform comprehensive repository analysis 16 + const analysis = await discovery.analyzeRepository(config.atproto.handle); 17 + 18 + // Filter for grain-related content from all discovered lexicons 19 + galleries = analysis.lexicons 20 + .filter(lexicon => 21 + lexicon.$type.includes('grain') || 22 + lexicon.$type.includes('gallery') || 23 + lexicon.service === 'grain.social' || 24 + lexicon.description.includes('grain') 25 + ) 26 + .map(lexicon => lexicon.sampleRecord); 27 + 28 + // Sort by creation date (newest first) 29 + galleries.sort((a, b) => { 30 + const dateA = new Date(a.value?.createdAt || a.indexedAt || 0); 31 + const dateB = new Date(b.value?.createdAt || b.indexedAt || 0); 32 + return dateB.getTime() - dateA.getTime(); 33 + }); 34 + } 35 + } catch (error) { 36 + console.error('Galleries page: Error fetching galleries:', error); 37 + galleries = []; 38 + } 39 + --- 40 + 41 + <Layout title="Image Galleries"> 42 + <div class="container mx-auto px-4 py-8"> 43 + <header class="text-center mb-12"> 44 + <h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4"> 45 + Image Galleries 46 + </h1> 47 + <p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto"> 48 + A collection of my grain.social image galleries 49 + </p> 50 + </header> 51 + 52 + <main class="max-w-6xl mx-auto"> 53 + {config.atproto.handle && config.atproto.handle !== 'your-handle-here' ? ( 54 + galleries.length > 0 ? ( 55 + <div class="space-y-8"> 56 + {galleries.map((record) => { 57 + const $type = record.value?.$type; 58 + 59 + // Handle different types of grain records 60 + if ($type?.includes('grain') || $type?.includes('gallery')) { 61 + // For now, display as a generic gallery component 62 + return ( 63 + <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> 64 + <header class="mb-4"> 65 + <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2"> 66 + {record.value?.title || 'Gallery'} 67 + </h2> 68 + 69 + {record.value?.description && ( 70 + <div class="text-gray-600 dark:text-gray-400 mb-3"> 71 + {record.value.description} 72 + </div> 73 + )} 74 + 75 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4"> 76 + Type: {record.value?.$type || 'Unknown'} 77 + </div> 78 + </header> 79 + 80 + {record.value?.text && ( 81 + <div class="text-gray-900 dark:text-white mb-4"> 82 + {record.value.text} 83 + </div> 84 + )} 85 + 86 + {record.value?.embed && record.value.embed.$type === 'app.bsky.embed.images' && ( 87 + <div class="grid grid-cols-3 gap-4"> 88 + {record.value.embed.images?.map((image: any) => ( 89 + <div class="relative group"> 90 + <img 91 + src={image.image?.ref ? `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=did:plc:6ayddqghxhciedbaofoxkcbs&cid=${image.image.ref}` : ''} 92 + alt={image.alt || 'Gallery image'} 93 + class="w-full h-48 object-cover rounded-lg transition-transform duration-200 group-hover:scale-105" 94 + /> 95 + </div> 96 + ))} 97 + </div> 98 + )} 99 + </article> 100 + ); 101 + } 102 + 103 + return null; 104 + })} 105 + </div> 106 + ) : ( 107 + <div class="text-center py-12"> 108 + <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-8"> 109 + <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4"> 110 + No Galleries Found 111 + </h3> 112 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 113 + No grain.social image galleries were found for your account. 114 + </p> 115 + <p class="text-sm text-gray-500 dark:text-gray-500"> 116 + Make sure you have created galleries using grain.social and they are properly indexed. 117 + </p> 118 + </div> 119 + </div> 120 + ) 121 + ) : ( 122 + <div class="text-center py-12"> 123 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8"> 124 + <h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4"> 125 + Configuration Required 126 + </h3> 127 + <p class="text-yellow-700 dark:text-yellow-300 mb-4"> 128 + To display your galleries, please configure your Bluesky handle in the environment variables. 129 + </p> 130 + <div class="text-sm text-yellow-600 dark:text-yellow-400"> 131 + <p class="mb-2">Create a <code class="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">.env</code> file with:</p> 132 + <pre class="bg-yellow-100 dark:bg-yellow-800 p-3 rounded text-xs overflow-x-auto"> 133 + ATPROTO_HANDLE=your-handle.bsky.social 134 + SITE_TITLE=Your Site Title 135 + SITE_AUTHOR=Your Name</pre> 136 + </div> 137 + </div> 138 + </div> 139 + )} 140 + </main> 141 + </div> 142 + </Layout>
+84 -11
src/pages/index.astro
··· 20 20 <main class="max-w-4xl mx-auto space-y-12"> 21 21 <!-- My Posts Feed --> 22 22 {config.atproto.handle && config.atproto.handle !== 'your-handle-here' ? ( 23 - <section> 24 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6"> 25 - My Posts 26 - </h2> 27 - <ContentFeed 28 - handle={config.atproto.handle} 29 - limit={10} 30 - showAuthor={false} 31 - showTimestamp={true} 32 - /> 33 - </section> 23 + <> 24 + <section> 25 + <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6"> 26 + My Posts 27 + </h2> 28 + <ContentFeed 29 + handle={config.atproto.handle} 30 + limit={10} 31 + showAuthor={false} 32 + showTimestamp={true} 33 + /> 34 + </section> 35 + 36 + <section> 37 + <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6"> 38 + Live Feed 39 + </h2> 40 + <div class="bg-blue-50 border border-blue-200 rounded-lg p-6"> 41 + <h3 class="text-xl font-semibold mb-2">Turbostream Test</h3> 42 + <p class="text-gray-600 mb-4"> 43 + Test the real-time Turbostream connection to see live posts from your handle. 44 + </p> 45 + <a href="/turbostream-test" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 inline-block"> 46 + Test Turbostream 47 + </a> 48 + </div> 49 + </section> 50 + 51 + <section> 52 + <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6"> 53 + Explore More 54 + </h2> 55 + <div class="grid md:grid-cols-2 gap-6"> 56 + <a href="/galleries" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 57 + <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 58 + Image Galleries 59 + </h3> 60 + <p class="text-gray-600 dark:text-gray-400"> 61 + View my grain.social image galleries and photo collections. 62 + </p> 63 + </a> 64 + <a href="/turbostream-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 65 + <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 66 + Turbostream Test 67 + </h3> 68 + <p class="text-gray-600 dark:text-gray-400"> 69 + Test the Graze Turbostream connection and see live records. 70 + </p> 71 + </a> 72 + <a href="/turbostream-simple" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 73 + <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 74 + Simple Debug Test 75 + </h3> 76 + <p class="text-gray-600 dark:text-gray-400"> 77 + Simple connection test with detailed console logging. 78 + </p> 79 + </a> 80 + <a href="/repository-stream-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 81 + <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 82 + Repository Stream Test 83 + </h3> 84 + <p class="text-gray-600 dark:text-gray-400"> 85 + Comprehensive repository streaming (like atptools) - all collections, all content types. 86 + </p> 87 + </a> 88 + <a href="/jetstream-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 89 + <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 90 + Jetstream Test 91 + </h3> 92 + <p class="text-gray-600 dark:text-gray-400"> 93 + ATProto sync API streaming with DID filtering (like atptools) - low latency, real-time. 94 + </p> 95 + </a> 96 + <a href="/atproto-browser-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 97 + <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 98 + ATProto Browser Test 99 + </h3> 100 + <p class="text-gray-600 dark:text-gray-400"> 101 + Browse ATProto accounts and records like atptools - explore collections and records. 102 + </p> 103 + </a> 104 + </div> 105 + </section> 106 + </> 34 107 ) : ( 35 108 <section> 36 109 <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">
+164
src/pages/jetstream-test.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + import { loadConfig } from '../lib/config/site'; 4 + 5 + const config = loadConfig(); 6 + --- 7 + 8 + <Layout title="Jetstream Test"> 9 + <div class="container mx-auto px-4 py-8"> 10 + <h1 class="text-4xl font-bold mb-8">Jetstream Repository Test</h1> 11 + 12 + <div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8"> 13 + <h2 class="text-2xl font-semibold mb-4">Configuration</h2> 14 + <p><strong>Handle:</strong> {config.atproto.handle}</p> 15 + <p><strong>DID:</strong> {config.atproto.did}</p> 16 + <p class="text-sm text-gray-600 mt-2">This uses ATProto sync API to stream all repository activity with DID filtering, similar to atptools.</p> 17 + </div> 18 + 19 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> 20 + <div class="bg-white border border-gray-200 rounded-lg p-6"> 21 + <h2 class="text-2xl font-semibold mb-4">Connection</h2> 22 + <button id="start-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"> 23 + Start Jetstream 24 + </button> 25 + <button id="stop-btn" class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 ml-2" disabled> 26 + Stop Stream 27 + </button> 28 + <div id="status" class="mt-4 p-2 rounded bg-gray-100"> 29 + Status: <span id="status-text">Stopped</span> 30 + </div> 31 + </div> 32 + 33 + <div class="bg-white border border-gray-200 rounded-lg p-6"> 34 + <h2 class="text-2xl font-semibold mb-4">Statistics</h2> 35 + <div class="space-y-2"> 36 + <div>Records received: <span id="records-count" class="font-bold">0</span></div> 37 + <div>Streaming status: <span id="streaming-status" class="font-bold">Stopped</span></div> 38 + <div>Last sync: <span id="last-sync" class="font-bold">Never</span></div> 39 + </div> 40 + </div> 41 + </div> 42 + 43 + <div class="mt-8"> 44 + <h2 class="text-2xl font-semibold mb-4">Live Records</h2> 45 + <div id="records-container" class="space-y-4 max-h-96 overflow-y-auto"> 46 + <p class="text-gray-500 text-center py-8">No records received yet...</p> 47 + </div> 48 + </div> 49 + </div> 50 + </Layout> 51 + 52 + <script> 53 + import { JetstreamClient } from '../lib/atproto/jetstream-client'; 54 + 55 + let client: JetstreamClient | null = null; 56 + let recordsCount = 0; 57 + 58 + // DOM elements 59 + const startBtn = document.getElementById('start-btn') as HTMLButtonElement; 60 + const stopBtn = document.getElementById('stop-btn') as HTMLButtonElement; 61 + const statusText = document.getElementById('status-text') as HTMLSpanElement; 62 + const recordsCountEl = document.getElementById('records-count') as HTMLSpanElement; 63 + const streamingStatusEl = document.getElementById('streaming-status') as HTMLSpanElement; 64 + const lastSyncEl = document.getElementById('last-sync') as HTMLSpanElement; 65 + const recordsContainer = document.getElementById('records-container') as HTMLDivElement; 66 + 67 + function updateStatus(status: string) { 68 + statusText.textContent = status; 69 + streamingStatusEl.textContent = status; 70 + 71 + if (status === 'Streaming') { 72 + startBtn.disabled = true; 73 + stopBtn.disabled = false; 74 + } else { 75 + startBtn.disabled = false; 76 + stopBtn.disabled = true; 77 + } 78 + } 79 + 80 + function updateLastSync() { 81 + lastSyncEl.textContent = new Date().toLocaleTimeString(); 82 + } 83 + 84 + function addRecord(record: any) { 85 + recordsCount++; 86 + recordsCountEl.textContent = recordsCount.toString(); 87 + 88 + const recordEl = document.createElement('div'); 89 + recordEl.className = 'bg-gray-50 border border-gray-200 rounded-lg p-4'; 90 + 91 + const time = new Date(record.time_us / 1000).toLocaleString(); 92 + const collection = record.collection; 93 + const $type = record.$type; 94 + const service = record.service; 95 + const text = record.value?.text || 'No text content'; 96 + 97 + recordEl.innerHTML = ` 98 + <div class="flex items-start space-x-3"> 99 + <div class="flex-1 min-w-0"> 100 + <div class="flex items-center space-x-2 mb-2"> 101 + <span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs font-medium">${service}</span> 102 + <span class="bg-green-100 text-green-800 px-2 py-1 rounded text-xs font-medium">${collection}</span> 103 + <span class="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs font-medium">${$type}</span> 104 + <span class="bg-${record.operation === 'create' ? 'green' : record.operation === 'update' ? 'yellow' : 'red'}-100 text-${record.operation === 'create' ? 'green' : record.operation === 'update' ? 'yellow' : 'red'}-800 px-2 py-1 rounded text-xs font-medium">${record.operation}</span> 105 + </div> 106 + ${text ? `<p class="text-sm text-gray-600 mb-2">${text}</p>` : ''} 107 + <p class="text-xs text-gray-500">${time}</p> 108 + <p class="text-xs font-mono text-gray-400 mt-1 break-all">${record.uri}</p> 109 + </div> 110 + </div> 111 + `; 112 + 113 + recordsContainer.insertBefore(recordEl, recordsContainer.firstChild); 114 + 115 + // Keep only the last 20 records 116 + const records = recordsContainer.querySelectorAll('div'); 117 + if (records.length > 20) { 118 + records[records.length - 1].remove(); 119 + } 120 + } 121 + 122 + startBtn.addEventListener('click', async () => { 123 + try { 124 + updateStatus('Starting...'); 125 + 126 + client = new JetstreamClient(); 127 + 128 + client.onConnect(() => { 129 + updateStatus('Streaming'); 130 + console.log('Jetstream connected'); 131 + }); 132 + 133 + client.onDisconnect(() => { 134 + updateStatus('Stopped'); 135 + console.log('Jetstream disconnected'); 136 + }); 137 + 138 + client.onError((error) => { 139 + console.error('Jetstream error:', error); 140 + updateStatus('Error'); 141 + }); 142 + 143 + client.onRecord((record) => { 144 + console.log('Record received:', record); 145 + addRecord(record); 146 + updateLastSync(); 147 + }); 148 + 149 + await client.startStreaming(); 150 + 151 + } catch (error) { 152 + console.error('Failed to start jetstream:', error); 153 + updateStatus('Error'); 154 + alert('Failed to start jetstream. Check the console for details.'); 155 + } 156 + }); 157 + 158 + stopBtn.addEventListener('click', () => { 159 + if (client) { 160 + client.stopStreaming(); 161 + client = null; 162 + } 163 + }); 164 + </script>
+187
src/pages/repository-stream-test.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + import { loadConfig } from '../lib/config/site'; 4 + 5 + const config = loadConfig(); 6 + --- 7 + 8 + <Layout title="Repository Stream Test"> 9 + <div class="container mx-auto px-4 py-8"> 10 + <h1 class="text-4xl font-bold mb-8">Repository Stream Test</h1> 11 + 12 + <div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8"> 13 + <h2 class="text-2xl font-semibold mb-4">Configuration</h2> 14 + <p><strong>Handle:</strong> {config.atproto.handle}</p> 15 + <p><strong>DID:</strong> {config.atproto.did}</p> 16 + <p class="text-sm text-gray-600 mt-2">This streams ALL repository content, not just posts. Includes galleries, profiles, follows, etc.</p> 17 + </div> 18 + 19 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> 20 + <div class="bg-white border border-gray-200 rounded-lg p-6"> 21 + <h2 class="text-2xl font-semibold mb-4">Connection</h2> 22 + <button id="start-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"> 23 + Start Repository Stream 24 + </button> 25 + <button id="stop-btn" class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 ml-2" disabled> 26 + Stop Stream 27 + </button> 28 + <div id="status" class="mt-4 p-2 rounded bg-gray-100"> 29 + Status: <span id="status-text">Stopped</span> 30 + </div> 31 + </div> 32 + 33 + <div class="bg-white border border-gray-200 rounded-lg p-6"> 34 + <h2 class="text-2xl font-semibold mb-4">Statistics</h2> 35 + <div class="space-y-2"> 36 + <div>Records received: <span id="records-count" class="font-bold">0</span></div> 37 + <div>Collections discovered: <span id="collections-count" class="font-bold">0</span></div> 38 + <div>Streaming status: <span id="streaming-status" class="font-bold">Stopped</span></div> 39 + </div> 40 + </div> 41 + </div> 42 + 43 + <div class="mt-8"> 44 + <h2 class="text-2xl font-semibold mb-4">Discovered Collections</h2> 45 + <div id="collections-container" class="bg-white border border-gray-200 rounded-lg p-6"> 46 + <p class="text-gray-500">No collections discovered yet...</p> 47 + </div> 48 + </div> 49 + 50 + <div class="mt-8"> 51 + <h2 class="text-2xl font-semibold mb-4">Live Records</h2> 52 + <div id="records-container" class="space-y-4 max-h-96 overflow-y-auto"> 53 + <p class="text-gray-500 text-center py-8">No records received yet...</p> 54 + </div> 55 + </div> 56 + </div> 57 + </Layout> 58 + 59 + <script> 60 + import { RepositoryStream } from '../lib/atproto/repository-stream'; 61 + 62 + let stream: RepositoryStream | null = null; 63 + let recordsCount = 0; 64 + let discoveredCollections: string[] = []; 65 + 66 + // DOM elements 67 + const startBtn = document.getElementById('start-btn') as HTMLButtonElement; 68 + const stopBtn = document.getElementById('stop-btn') as HTMLButtonElement; 69 + const statusText = document.getElementById('status-text') as HTMLSpanElement; 70 + const recordsCountEl = document.getElementById('records-count') as HTMLSpanElement; 71 + const collectionsCountEl = document.getElementById('collections-count') as HTMLSpanElement; 72 + const streamingStatusEl = document.getElementById('streaming-status') as HTMLSpanElement; 73 + const collectionsContainer = document.getElementById('collections-container') as HTMLDivElement; 74 + const recordsContainer = document.getElementById('records-container') as HTMLDivElement; 75 + 76 + function updateStatus(status: string) { 77 + statusText.textContent = status; 78 + streamingStatusEl.textContent = status; 79 + 80 + if (status === 'Streaming') { 81 + startBtn.disabled = true; 82 + stopBtn.disabled = false; 83 + } else { 84 + startBtn.disabled = false; 85 + stopBtn.disabled = true; 86 + } 87 + } 88 + 89 + function updateCollections() { 90 + collectionsCountEl.textContent = discoveredCollections.length.toString(); 91 + 92 + if (discoveredCollections.length === 0) { 93 + collectionsContainer.innerHTML = '<p class="text-gray-500">No collections discovered yet...</p>'; 94 + } else { 95 + collectionsContainer.innerHTML = discoveredCollections.map(collection => 96 + `<div class="bg-gray-50 border border-gray-200 rounded p-3 mb-2"> 97 + <span class="font-mono text-sm">${collection}</span> 98 + </div>` 99 + ).join(''); 100 + } 101 + } 102 + 103 + function addRecord(record: any) { 104 + recordsCount++; 105 + recordsCountEl.textContent = recordsCount.toString(); 106 + 107 + const recordEl = document.createElement('div'); 108 + recordEl.className = 'bg-gray-50 border border-gray-200 rounded-lg p-4'; 109 + 110 + const time = new Date(record.indexedAt).toLocaleString(); 111 + const collection = record.collection; 112 + const $type = record.$type; 113 + const service = record.service; 114 + const text = record.value?.text || 'No text content'; 115 + 116 + recordEl.innerHTML = ` 117 + <div class="flex items-start space-x-3"> 118 + <div class="flex-1 min-w-0"> 119 + <div class="flex items-center space-x-2 mb-2"> 120 + <span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs font-medium">${service}</span> 121 + <span class="bg-green-100 text-green-800 px-2 py-1 rounded text-xs font-medium">${collection}</span> 122 + <span class="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs font-medium">${$type}</span> 123 + </div> 124 + ${text ? `<p class="text-sm text-gray-600 mb-2">${text}</p>` : ''} 125 + <p class="text-xs text-gray-500">${time}</p> 126 + <p class="text-xs font-mono text-gray-400 mt-1 break-all">${record.uri}</p> 127 + </div> 128 + </div> 129 + `; 130 + 131 + recordsContainer.insertBefore(recordEl, recordsContainer.firstChild); 132 + 133 + // Keep only the last 20 records 134 + const records = recordsContainer.querySelectorAll('div'); 135 + if (records.length > 20) { 136 + records[records.length - 1].remove(); 137 + } 138 + } 139 + 140 + startBtn.addEventListener('click', async () => { 141 + try { 142 + updateStatus('Starting...'); 143 + 144 + stream = new RepositoryStream(); 145 + 146 + stream.onConnect(() => { 147 + updateStatus('Streaming'); 148 + console.log('Repository stream connected'); 149 + }); 150 + 151 + stream.onDisconnect(() => { 152 + updateStatus('Stopped'); 153 + console.log('Repository stream disconnected'); 154 + }); 155 + 156 + stream.onError((error) => { 157 + console.error('Repository stream error:', error); 158 + updateStatus('Error'); 159 + }); 160 + 161 + stream.onCollectionDiscovered((collection) => { 162 + discoveredCollections.push(collection); 163 + updateCollections(); 164 + console.log('Collection discovered:', collection); 165 + }); 166 + 167 + stream.onRecord((record) => { 168 + console.log('Record received:', record); 169 + addRecord(record); 170 + }); 171 + 172 + await stream.startStreaming(); 173 + 174 + } catch (error) { 175 + console.error('Failed to start repository stream:', error); 176 + updateStatus('Error'); 177 + alert('Failed to start repository stream. Check the console for details.'); 178 + } 179 + }); 180 + 181 + stopBtn.addEventListener('click', () => { 182 + if (stream) { 183 + stream.stopStreaming(); 184 + stream = null; 185 + } 186 + }); 187 + </script>
+114
src/pages/turbostream-simple.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + import { loadConfig } from '../lib/config/site'; 4 + 5 + const config = loadConfig(); 6 + --- 7 + 8 + <Layout title="Simple Turbostream Test"> 9 + <div class="container mx-auto px-4 py-8"> 10 + <h1 class="text-4xl font-bold mb-8">Simple Turbostream Test</h1> 11 + 12 + <div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8"> 13 + <h2 class="text-2xl font-semibold mb-4">Configuration</h2> 14 + <p><strong>Handle:</strong> {config.atproto.handle}</p> 15 + <p><strong>DID:</strong> {config.atproto.did}</p> 16 + <p class="text-sm text-gray-600 mt-2">Filtering for records from your handle only. Open your browser's developer console (F12) to see detailed logs.</p> 17 + </div> 18 + 19 + <div class="bg-white border border-gray-200 rounded-lg p-6 mb-8"> 20 + <h2 class="text-2xl font-semibold mb-4">Connection</h2> 21 + <button id="connect-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"> 22 + Connect to Turbostream 23 + </button> 24 + <button id="disconnect-btn" class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 ml-2" disabled> 25 + Disconnect 26 + </button> 27 + <div id="status" class="mt-4 p-2 rounded bg-gray-100"> 28 + Status: <span id="status-text">Disconnected</span> 29 + </div> 30 + </div> 31 + 32 + <div class="bg-white border border-gray-200 rounded-lg p-6"> 33 + <h2 class="text-2xl font-semibold mb-4">Statistics</h2> 34 + <div class="space-y-2"> 35 + <div>Records received: <span id="count" class="font-bold">0</span></div> 36 + <div>Connection status: <span id="connection-status" class="font-bold">Disconnected</span></div> 37 + </div> 38 + </div> 39 + </div> 40 + </Layout> 41 + 42 + <script> 43 + import { TurbostreamClient } from '../lib/atproto/turbostream'; 44 + 45 + let client: TurbostreamClient | null = null; 46 + let recordsCount = 0; 47 + 48 + const connectBtn = document.getElementById('connect-btn') as HTMLButtonElement; 49 + const disconnectBtn = document.getElementById('disconnect-btn') as HTMLButtonElement; 50 + const statusText = document.getElementById('status-text') as HTMLSpanElement; 51 + const countEl = document.getElementById('count') as HTMLSpanElement; 52 + const connectionStatusEl = document.getElementById('connection-status') as HTMLSpanElement; 53 + 54 + function updateStatus(status: string) { 55 + statusText.textContent = status; 56 + connectionStatusEl.textContent = status; 57 + console.log('🔄 Status updated:', status); 58 + 59 + if (status === 'connected') { 60 + connectBtn.disabled = true; 61 + disconnectBtn.disabled = false; 62 + } else { 63 + connectBtn.disabled = false; 64 + disconnectBtn.disabled = true; 65 + } 66 + } 67 + 68 + connectBtn.addEventListener('click', async () => { 69 + console.log('🚀 Starting Turbostream connection...'); 70 + updateStatus('connecting'); 71 + 72 + try { 73 + client = new TurbostreamClient(); 74 + 75 + client.onConnect(() => { 76 + console.log('🎉 Turbostream connected successfully!'); 77 + updateStatus('connected'); 78 + }); 79 + 80 + client.onDisconnect(() => { 81 + console.log('👋 Turbostream disconnected'); 82 + updateStatus('disconnected'); 83 + }); 84 + 85 + client.onError((error) => { 86 + console.error('💥 Turbostream error:', error); 87 + updateStatus('error'); 88 + }); 89 + 90 + client.onRecord((record) => { 91 + recordsCount++; 92 + countEl.textContent = recordsCount.toString(); 93 + console.log('📝 Record received:', record); 94 + }); 95 + 96 + await client.connect(); 97 + 98 + } catch (error) { 99 + console.error('💥 Failed to connect:', error); 100 + updateStatus('error'); 101 + } 102 + }); 103 + 104 + disconnectBtn.addEventListener('click', () => { 105 + if (client) { 106 + console.log('🔌 Disconnecting...'); 107 + client.disconnect(); 108 + client = null; 109 + } 110 + }); 111 + 112 + // Log when page loads 113 + console.log('📄 Simple Turbostream test page loaded'); 114 + </script>
+158
src/pages/turbostream-test.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + import { loadConfig } from '../lib/config/site'; 4 + 5 + const config = loadConfig(); 6 + --- 7 + 8 + <Layout title="Turbostream Test"> 9 + <div class="container mx-auto px-4 py-8"> 10 + <h1 class="text-4xl font-bold mb-8">Turbostream Test</h1> 11 + 12 + <div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8"> 13 + <h2 class="text-2xl font-semibold mb-4">Configuration</h2> 14 + <p><strong>Handle:</strong> {config.atproto.handle}</p> 15 + <p><strong>DID:</strong> {config.atproto.did}</p> 16 + <p class="text-sm text-gray-600 mt-2">Filtering for records from your handle only. Only posts from @{config.atproto.handle} will be displayed.</p> 17 + </div> 18 + 19 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> 20 + <div class="bg-white border border-gray-200 rounded-lg p-6"> 21 + <h2 class="text-2xl font-semibold mb-4">Connection</h2> 22 + <button id="connect-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"> 23 + Connect to Turbostream 24 + </button> 25 + <button id="disconnect-btn" class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 ml-2" disabled> 26 + Disconnect 27 + </button> 28 + <div id="status" class="mt-4 p-2 rounded bg-gray-100"> 29 + Status: <span id="status-text">Disconnected</span> 30 + </div> 31 + </div> 32 + 33 + <div class="bg-white border border-gray-200 rounded-lg p-6"> 34 + <h2 class="text-2xl font-semibold mb-4">Statistics</h2> 35 + <div class="space-y-2"> 36 + <div>Records received: <span id="records-count" class="font-bold">0</span></div> 37 + <div>Connection status: <span id="connection-status" class="font-bold">Disconnected</span></div> 38 + </div> 39 + </div> 40 + </div> 41 + 42 + <div class="mt-8"> 43 + <h2 class="text-2xl font-semibold mb-4">Live Records</h2> 44 + <div id="records-container" class="space-y-4 max-h-96 overflow-y-auto"> 45 + <p class="text-gray-500 text-center py-8">No records received yet...</p> 46 + </div> 47 + </div> 48 + </div> 49 + </Layout> 50 + 51 + <script> 52 + import { TurbostreamClient } from '../lib/atproto/turbostream'; 53 + 54 + let client: TurbostreamClient | null = null; 55 + let recordsCount = 0; 56 + 57 + // DOM elements 58 + const connectBtn = document.getElementById('connect-btn') as HTMLButtonElement; 59 + const disconnectBtn = document.getElementById('disconnect-btn') as HTMLButtonElement; 60 + const statusText = document.getElementById('status-text') as HTMLSpanElement; 61 + const recordsCountEl = document.getElementById('records-count') as HTMLSpanElement; 62 + const connectionStatusEl = document.getElementById('connection-status') as HTMLSpanElement; 63 + const recordsContainer = document.getElementById('records-container') as HTMLDivElement; 64 + 65 + function updateStatus(status: string) { 66 + statusText.textContent = status; 67 + connectionStatusEl.textContent = status; 68 + 69 + if (status === 'connected') { 70 + connectBtn.disabled = true; 71 + disconnectBtn.disabled = false; 72 + } else { 73 + connectBtn.disabled = false; 74 + disconnectBtn.disabled = true; 75 + } 76 + } 77 + 78 + function addRecord(record: any) { 79 + recordsCount++; 80 + recordsCountEl.textContent = recordsCount.toString(); 81 + 82 + const recordEl = document.createElement('div'); 83 + recordEl.className = 'bg-gray-50 border border-gray-200 rounded-lg p-4'; 84 + 85 + const time = new Date(record.time_us / 1000).toLocaleString(); 86 + const did = record.did; 87 + const uri = record.at_uri; 88 + const user = record.hydrated_metadata?.user; 89 + const text = record.message?.text || 'No text content'; 90 + 91 + recordEl.innerHTML = ` 92 + <div class="flex items-start space-x-3"> 93 + <div class="flex-shrink-0"> 94 + <img src="${user?.avatar || '/favicon.svg'}" alt="Avatar" class="w-10 h-10 rounded-full"> 95 + </div> 96 + <div class="flex-1 min-w-0"> 97 + <div class="flex items-center space-x-2 mb-1"> 98 + <span class="font-semibold">${user?.displayName || 'Unknown'}</span> 99 + <span class="text-gray-500">@${user?.handle || did}</span> 100 + </div> 101 + <p class="text-sm text-gray-600 mb-2">${text}</p> 102 + <p class="text-xs text-gray-500">${time}</p> 103 + <p class="text-xs font-mono text-gray-400 mt-1 break-all">${uri}</p> 104 + </div> 105 + </div> 106 + `; 107 + 108 + recordsContainer.insertBefore(recordEl, recordsContainer.firstChild); 109 + 110 + // Keep only the last 20 records 111 + const records = recordsContainer.querySelectorAll('div'); 112 + if (records.length > 20) { 113 + records[records.length - 1].remove(); 114 + } 115 + } 116 + 117 + connectBtn.addEventListener('click', async () => { 118 + try { 119 + updateStatus('connecting'); 120 + 121 + client = new TurbostreamClient(); 122 + 123 + client.onConnect(() => { 124 + updateStatus('connected'); 125 + console.log('Connected to Turbostream'); 126 + }); 127 + 128 + client.onDisconnect(() => { 129 + updateStatus('disconnected'); 130 + console.log('Disconnected from Turbostream'); 131 + }); 132 + 133 + client.onError((error) => { 134 + console.error('Turbostream error:', error); 135 + updateStatus('error'); 136 + }); 137 + 138 + client.onRecord((record) => { 139 + console.log('Received record:', record); 140 + addRecord(record); 141 + }); 142 + 143 + await client.connect(); 144 + 145 + } catch (error) { 146 + console.error('Failed to connect:', error); 147 + updateStatus('error'); 148 + alert('Failed to connect to Turbostream. Check the console for details.'); 149 + } 150 + }); 151 + 152 + disconnectBtn.addEventListener('click', () => { 153 + if (client) { 154 + client.disconnect(); 155 + client = null; 156 + } 157 + }); 158 + </script>