+20
README.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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>