+1
package.json
+1
package.json
+51
scripts/discover-collections.ts
+51
scripts/discover-collections.ts
···
1
+
#!/usr/bin/env node
2
+
3
+
import { CollectionDiscovery } from '../src/lib/build/collection-discovery';
4
+
import { loadConfig } from '../src/lib/config/site';
5
+
import path from 'path';
6
+
7
+
async function main() {
8
+
console.log('🚀 Starting collection discovery...');
9
+
10
+
try {
11
+
const config = loadConfig();
12
+
const discovery = new CollectionDiscovery();
13
+
14
+
if (!config.atproto.handle || config.atproto.handle === 'your-handle-here') {
15
+
console.error('❌ No ATProto handle configured. Please set ATPROTO_HANDLE in your environment.');
16
+
process.exit(1);
17
+
}
18
+
19
+
console.log(`🔍 Discovering collections for: ${config.atproto.handle}`);
20
+
21
+
// Discover collections
22
+
const results = await discovery.discoverCollections(config.atproto.handle);
23
+
24
+
// Save results
25
+
const outputPath = path.join(process.cwd(), 'src/lib/generated/discovered-types.ts');
26
+
await discovery.saveDiscoveryResults(results, outputPath);
27
+
28
+
console.log('✅ Collection discovery complete!');
29
+
console.log(`📊 Summary:`);
30
+
console.log(` - Collections: ${results.totalCollections}`);
31
+
console.log(` - Records: ${results.totalRecords}`);
32
+
console.log(` - Repository: ${results.repository.handle}`);
33
+
console.log(` - Output: ${outputPath}`);
34
+
35
+
// Log discovered collections
36
+
console.log('\n📦 Discovered Collections:');
37
+
for (const collection of results.collections) {
38
+
console.log(` - ${collection.name} (${collection.service})`);
39
+
console.log(` Types: ${collection.$types.join(', ')}`);
40
+
}
41
+
42
+
} catch (error) {
43
+
console.error('❌ Collection discovery failed:', error);
44
+
process.exit(1);
45
+
}
46
+
}
47
+
48
+
// Run if called directly
49
+
if (require.main === module) {
50
+
main();
51
+
}
+113
src/components/content/GalleryDisplay.astro
+113
src/components/content/GalleryDisplay.astro
···
1
+
---
2
+
import type { ProcessedGallery } from '../../lib/services/gallery-service';
3
+
4
+
interface Props {
5
+
gallery: ProcessedGallery;
6
+
showDescription?: boolean;
7
+
showTimestamp?: boolean;
8
+
columns?: number;
9
+
showType?: boolean;
10
+
}
11
+
12
+
const {
13
+
gallery,
14
+
showDescription = true,
15
+
showTimestamp = true,
16
+
columns = 3,
17
+
showType = false
18
+
} = Astro.props;
19
+
20
+
const formatDate = (dateString: string) => {
21
+
return new Date(dateString).toLocaleDateString('en-US', {
22
+
year: 'numeric',
23
+
month: 'long',
24
+
day: 'numeric',
25
+
});
26
+
};
27
+
28
+
const gridCols = {
29
+
1: 'grid-cols-1',
30
+
2: 'grid-cols-2',
31
+
3: 'grid-cols-3',
32
+
4: 'grid-cols-4',
33
+
5: 'grid-cols-5',
34
+
6: 'grid-cols-6',
35
+
}[columns] || 'grid-cols-3';
36
+
37
+
// Determine image layout based on number of images
38
+
const getImageLayout = (imageCount: number) => {
39
+
if (imageCount === 1) return 'grid-cols-1';
40
+
if (imageCount === 2) return 'grid-cols-2';
41
+
if (imageCount === 3) return 'grid-cols-3';
42
+
if (imageCount === 4) return 'grid-cols-2 md:grid-cols-4';
43
+
return gridCols;
44
+
};
45
+
46
+
const imageLayout = getImageLayout(gallery.images.length);
47
+
---
48
+
49
+
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
50
+
<header class="mb-4">
51
+
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
52
+
{gallery.title}
53
+
</h2>
54
+
55
+
{showDescription && gallery.description && (
56
+
<div class="text-gray-600 dark:text-gray-400 mb-3">
57
+
{gallery.description}
58
+
</div>
59
+
)}
60
+
61
+
{gallery.text && (
62
+
<div class="text-gray-900 dark:text-white mb-4">
63
+
{gallery.text}
64
+
</div>
65
+
)}
66
+
67
+
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400 mb-4">
68
+
{showTimestamp && (
69
+
<span>
70
+
Created on {formatDate(gallery.createdAt)}
71
+
</span>
72
+
)}
73
+
74
+
{showType && (
75
+
<span class="bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded text-xs">
76
+
{gallery.$type}
77
+
</span>
78
+
)}
79
+
80
+
<span class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-1 rounded text-xs">
81
+
{gallery.images.length} image{gallery.images.length !== 1 ? 's' : ''}
82
+
</span>
83
+
</div>
84
+
</header>
85
+
86
+
{gallery.images && gallery.images.length > 0 && (
87
+
<div class={`grid ${imageLayout} gap-4`}>
88
+
{gallery.images.map((image, index) => (
89
+
<div class="relative group">
90
+
<img
91
+
src={image.url}
92
+
alt={image.alt || `Gallery image ${index + 1}`}
93
+
class="w-full h-48 object-cover rounded-lg transition-transform duration-200 group-hover:scale-105"
94
+
style={image.aspectRatio ? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height}` : ''}
95
+
loading="lazy"
96
+
/>
97
+
{image.alt && (
98
+
<div class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm p-2 rounded-b-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200">
99
+
{image.alt}
100
+
</div>
101
+
)}
102
+
</div>
103
+
))}
104
+
</div>
105
+
)}
106
+
107
+
<footer class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
108
+
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
109
+
<span>Collection: {gallery.collection}</span>
110
+
<span>Type: {gallery.$type}</span>
111
+
</div>
112
+
</footer>
113
+
</article>
+124
src/components/content/GrainGalleryDisplay.astro
+124
src/components/content/GrainGalleryDisplay.astro
···
1
+
---
2
+
import type { ProcessedGrainGallery } from '../../lib/services/grain-gallery-service';
3
+
4
+
interface Props {
5
+
gallery: ProcessedGrainGallery;
6
+
showDescription?: boolean;
7
+
showTimestamp?: boolean;
8
+
showCollections?: boolean;
9
+
columns?: number;
10
+
}
11
+
12
+
const {
13
+
gallery,
14
+
showDescription = true,
15
+
showTimestamp = true,
16
+
showCollections = false,
17
+
columns = 3
18
+
} = Astro.props;
19
+
20
+
const formatDate = (dateString: string) => {
21
+
return new Date(dateString).toLocaleDateString('en-US', {
22
+
year: 'numeric',
23
+
month: 'long',
24
+
day: 'numeric',
25
+
});
26
+
};
27
+
28
+
const gridCols = {
29
+
1: 'grid-cols-1',
30
+
2: 'grid-cols-2',
31
+
3: 'grid-cols-3',
32
+
4: 'grid-cols-4',
33
+
5: 'grid-cols-5',
34
+
6: 'grid-cols-6',
35
+
}[columns] || 'grid-cols-3';
36
+
37
+
// Determine image layout based on number of images
38
+
const getImageLayout = (imageCount: number) => {
39
+
if (imageCount === 1) return 'grid-cols-1';
40
+
if (imageCount === 2) return 'grid-cols-2';
41
+
if (imageCount === 3) return 'grid-cols-3';
42
+
if (imageCount === 4) return 'grid-cols-2 md:grid-cols-4';
43
+
return gridCols;
44
+
};
45
+
46
+
const imageLayout = getImageLayout(gallery.images.length);
47
+
---
48
+
49
+
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
50
+
<header class="mb-4">
51
+
<div class="flex items-start justify-between mb-2">
52
+
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
53
+
{gallery.title}
54
+
</h2>
55
+
<div class="flex items-center gap-2">
56
+
<span class="bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded text-xs">
57
+
{gallery.id}
58
+
</span>
59
+
</div>
60
+
</div>
61
+
62
+
{showDescription && gallery.description && (
63
+
<div class="text-gray-600 dark:text-gray-400 mb-3">
64
+
{gallery.description}
65
+
</div>
66
+
)}
67
+
68
+
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400 mb-4">
69
+
{showTimestamp && (
70
+
<span>
71
+
Created on {formatDate(gallery.createdAt)}
72
+
</span>
73
+
)}
74
+
75
+
<span class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-1 rounded text-xs">
76
+
{gallery.images.length} image{gallery.images.length !== 1 ? 's' : ''}
77
+
</span>
78
+
79
+
<span class="bg-green-100 dark:bg-green-700 text-green-600 dark:text-green-300 px-2 py-1 rounded text-xs">
80
+
{gallery.itemCount} item{gallery.itemCount !== 1 ? 's' : ''}
81
+
</span>
82
+
</div>
83
+
84
+
{showCollections && gallery.collections.length > 0 && (
85
+
<div class="mb-4">
86
+
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Collections:</p>
87
+
<div class="flex flex-wrap gap-1">
88
+
{gallery.collections.map((collection) => (
89
+
<span class="bg-purple-100 dark:bg-purple-800 text-purple-600 dark:text-purple-300 px-2 py-1 rounded text-xs">
90
+
{collection}
91
+
</span>
92
+
))}
93
+
</div>
94
+
</div>
95
+
)}
96
+
</header>
97
+
98
+
{gallery.images && gallery.images.length > 0 && (
99
+
<div class={`grid ${imageLayout} gap-4`}>
100
+
{gallery.images.map((image, index) => (
101
+
<div class="relative group">
102
+
<img
103
+
src={image.url}
104
+
alt={image.alt || `Gallery image ${index + 1}`}
105
+
class="w-full h-48 object-cover rounded-lg transition-transform duration-200 group-hover:scale-105"
106
+
loading="lazy"
107
+
/>
108
+
{(image.alt || image.caption) && (
109
+
<div class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm p-2 rounded-b-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200">
110
+
{image.alt || image.caption}
111
+
</div>
112
+
)}
113
+
</div>
114
+
))}
115
+
</div>
116
+
)}
117
+
118
+
<footer class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
119
+
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
120
+
<span>Gallery ID: {gallery.id}</span>
121
+
<span>Collections: {gallery.collections.join(', ')}</span>
122
+
</div>
123
+
</footer>
124
+
</article>
+303
src/lib/build/collection-discovery.ts
+303
src/lib/build/collection-discovery.ts
···
1
+
import { AtprotoBrowser } from '../atproto/atproto-browser';
2
+
import { loadConfig } from '../config/site';
3
+
import fs from 'fs/promises';
4
+
import path from 'path';
5
+
6
+
export interface CollectionType {
7
+
name: string;
8
+
description: string;
9
+
service: string;
10
+
sampleRecords: any[];
11
+
generatedTypes: string;
12
+
$types: string[];
13
+
}
14
+
15
+
export interface BuildTimeDiscovery {
16
+
collections: CollectionType[];
17
+
totalCollections: number;
18
+
totalRecords: number;
19
+
generatedAt: string;
20
+
repository: {
21
+
handle: string;
22
+
did: string;
23
+
recordCount: number;
24
+
};
25
+
}
26
+
27
+
export class CollectionDiscovery {
28
+
private browser: AtprotoBrowser;
29
+
private config: any;
30
+
31
+
constructor() {
32
+
this.config = loadConfig();
33
+
this.browser = new AtprotoBrowser();
34
+
}
35
+
36
+
// Discover all collections and generate types
37
+
async discoverCollections(identifier: string): Promise<BuildTimeDiscovery> {
38
+
console.log('🔍 Starting collection discovery for:', identifier);
39
+
40
+
try {
41
+
// Get repository info
42
+
const repoInfo = await this.browser.getRepoInfo(identifier);
43
+
if (!repoInfo) {
44
+
throw new Error(`Could not get repository info for: ${identifier}`);
45
+
}
46
+
47
+
console.log('📊 Repository info:', {
48
+
handle: repoInfo.handle,
49
+
did: repoInfo.did,
50
+
collections: repoInfo.collections.length,
51
+
recordCount: repoInfo.recordCount
52
+
});
53
+
54
+
const collections: CollectionType[] = [];
55
+
let totalRecords = 0;
56
+
57
+
// Process each collection
58
+
for (const collectionName of repoInfo.collections) {
59
+
console.log(`📦 Processing collection: ${collectionName}`);
60
+
61
+
const collectionType = await this.processCollection(identifier, collectionName);
62
+
if (collectionType) {
63
+
collections.push(collectionType);
64
+
totalRecords += collectionType.sampleRecords.length;
65
+
}
66
+
}
67
+
68
+
const discovery: BuildTimeDiscovery = {
69
+
collections,
70
+
totalCollections: collections.length,
71
+
totalRecords,
72
+
generatedAt: new Date().toISOString(),
73
+
repository: {
74
+
handle: repoInfo.handle,
75
+
did: repoInfo.did,
76
+
recordCount: repoInfo.recordCount
77
+
}
78
+
};
79
+
80
+
console.log(`✅ Discovery complete: ${collections.length} collections, ${totalRecords} records`);
81
+
return discovery;
82
+
83
+
} catch (error) {
84
+
console.error('Error discovering collections:', error);
85
+
throw error;
86
+
}
87
+
}
88
+
89
+
// Process a single collection
90
+
private async processCollection(identifier: string, collectionName: string): Promise<CollectionType | null> {
91
+
try {
92
+
// Get records from collection
93
+
const records = await this.browser.getCollectionRecords(identifier, collectionName, 10);
94
+
if (!records || records.records.length === 0) {
95
+
console.log(`⚠️ No records found in collection: ${collectionName}`);
96
+
return null;
97
+
}
98
+
99
+
// Group records by $type
100
+
const recordsByType = new Map<string, any[]>();
101
+
for (const record of records.records) {
102
+
const $type = record.$type || 'unknown';
103
+
if (!recordsByType.has($type)) {
104
+
recordsByType.set($type, []);
105
+
}
106
+
recordsByType.get($type)!.push(record.value);
107
+
}
108
+
109
+
// Generate types for each $type
110
+
const generatedTypes: string[] = [];
111
+
const $types: string[] = [];
112
+
113
+
for (const [$type, typeRecords] of recordsByType) {
114
+
if ($type === 'unknown') continue;
115
+
116
+
$types.push($type);
117
+
const typeDefinition = this.generateTypeDefinition($type, typeRecords);
118
+
generatedTypes.push(typeDefinition);
119
+
}
120
+
121
+
// Create collection type
122
+
const collectionType: CollectionType = {
123
+
name: collectionName,
124
+
description: this.getCollectionDescription(collectionName),
125
+
service: this.inferService(collectionName),
126
+
sampleRecords: records.records.slice(0, 3).map(r => r.value),
127
+
generatedTypes: generatedTypes.join('\n\n'),
128
+
$types
129
+
};
130
+
131
+
console.log(`✅ Processed collection ${collectionName}: ${$types.length} types`);
132
+
return collectionType;
133
+
134
+
} catch (error) {
135
+
console.error(`Error processing collection ${collectionName}:`, error);
136
+
return null;
137
+
}
138
+
}
139
+
140
+
// Generate TypeScript type definition
141
+
private generateTypeDefinition($type: string, records: any[]): string {
142
+
if (records.length === 0) return '';
143
+
144
+
// Analyze the first record to understand the structure
145
+
const sampleRecord = records[0];
146
+
const properties = this.extractProperties(sampleRecord);
147
+
148
+
// Generate interface
149
+
const interfaceName = this.$typeToInterfaceName($type);
150
+
let typeDefinition = `export interface ${interfaceName} {\n`;
151
+
152
+
// Add $type property
153
+
typeDefinition += ` $type: '${$type}';\n`;
154
+
155
+
// Add other properties
156
+
for (const [key, value] of Object.entries(properties)) {
157
+
const type = this.inferType(value);
158
+
typeDefinition += ` ${key}?: ${type};\n`;
159
+
}
160
+
161
+
typeDefinition += '}\n';
162
+
163
+
return typeDefinition;
164
+
}
165
+
166
+
// Extract properties from a record
167
+
private extractProperties(obj: any, maxDepth: number = 3, currentDepth: number = 0): Record<string, any> {
168
+
if (currentDepth >= maxDepth || !obj || typeof obj !== 'object') {
169
+
return {};
170
+
}
171
+
172
+
const properties: Record<string, any> = {};
173
+
174
+
for (const [key, value] of Object.entries(obj)) {
175
+
if (key === '$type') continue; // Skip $type as it's handled separately
176
+
177
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
178
+
// Nested object
179
+
const nestedProps = this.extractProperties(value, maxDepth, currentDepth + 1);
180
+
if (Object.keys(nestedProps).length > 0) {
181
+
properties[key] = nestedProps;
182
+
}
183
+
} else {
184
+
// Simple property
185
+
properties[key] = value;
186
+
}
187
+
}
188
+
189
+
return properties;
190
+
}
191
+
192
+
// Infer TypeScript type from value
193
+
private inferType(value: any): string {
194
+
if (value === null) return 'null';
195
+
if (value === undefined) return 'undefined';
196
+
197
+
const type = typeof value;
198
+
199
+
switch (type) {
200
+
case 'string':
201
+
return 'string';
202
+
case 'number':
203
+
return 'number';
204
+
case 'boolean':
205
+
return 'boolean';
206
+
case 'object':
207
+
if (Array.isArray(value)) {
208
+
if (value.length === 0) return 'any[]';
209
+
const elementType = this.inferType(value[0]);
210
+
return `${elementType}[]`;
211
+
}
212
+
return 'Record<string, any>';
213
+
default:
214
+
return 'any';
215
+
}
216
+
}
217
+
218
+
// Convert $type to interface name
219
+
private $typeToInterfaceName($type: string): string {
220
+
// Convert app.bsky.feed.post to AppBskyFeedPost
221
+
return $type
222
+
.split('.')
223
+
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
224
+
.join('');
225
+
}
226
+
227
+
// Get collection description
228
+
private getCollectionDescription(collectionName: string): string {
229
+
const descriptions: Record<string, string> = {
230
+
'app.bsky.feed.post': 'Bluesky posts',
231
+
'app.bsky.actor.profile': 'Bluesky profile information',
232
+
'app.bsky.feed.generator': 'Bluesky custom feeds',
233
+
'app.bsky.graph.follow': 'Bluesky follow relationships',
234
+
'app.bsky.graph.block': 'Bluesky block relationships',
235
+
'app.bsky.feed.like': 'Bluesky like records',
236
+
'app.bsky.feed.repost': 'Bluesky repost records',
237
+
'social.grain.gallery': 'Grain.social image galleries',
238
+
'grain.social.feed.gallery': 'Grain.social galleries',
239
+
'grain.social.feed.post': 'Grain.social posts',
240
+
'grain.social.actor.profile': 'Grain.social profile information',
241
+
};
242
+
243
+
return descriptions[collectionName] || `${collectionName} records`;
244
+
}
245
+
246
+
// Infer service from collection name
247
+
private inferService(collectionName: string): string {
248
+
if (collectionName.startsWith('grain.social') || collectionName.startsWith('social.grain')) {
249
+
return 'grain.social';
250
+
}
251
+
if (collectionName.startsWith('app.bsky')) {
252
+
return 'bsky.app';
253
+
}
254
+
if (collectionName.startsWith('sh.tangled')) {
255
+
return 'sh.tangled';
256
+
}
257
+
return 'unknown';
258
+
}
259
+
260
+
// Save discovery results to file
261
+
async saveDiscoveryResults(discovery: BuildTimeDiscovery, outputPath: string): Promise<void> {
262
+
try {
263
+
// Create output directory if it doesn't exist
264
+
const outputDir = path.dirname(outputPath);
265
+
await fs.mkdir(outputDir, { recursive: true });
266
+
267
+
// Save discovery metadata
268
+
const metadataPath = outputPath.replace('.ts', '.json');
269
+
await fs.writeFile(metadataPath, JSON.stringify(discovery, null, 2));
270
+
271
+
// Generate TypeScript file with all types
272
+
let typesContent = '// Auto-generated types from collection discovery\n';
273
+
typesContent += `// Generated at: ${discovery.generatedAt}\n`;
274
+
typesContent += `// Repository: ${discovery.repository.handle} (${discovery.repository.did})\n`;
275
+
typesContent += `// Collections: ${discovery.totalCollections}, Records: ${discovery.totalRecords}\n\n`;
276
+
277
+
// Add all generated types
278
+
for (const collection of discovery.collections) {
279
+
typesContent += `// Collection: ${collection.name}\n`;
280
+
typesContent += `// Service: ${collection.service}\n`;
281
+
typesContent += `// Types: ${collection.$types.join(', ')}\n`;
282
+
typesContent += collection.generatedTypes;
283
+
typesContent += '\n\n';
284
+
}
285
+
286
+
// Add union type for all discovered types
287
+
const allTypes = discovery.collections.flatMap(c => c.$types);
288
+
if (allTypes.length > 0) {
289
+
typesContent += '// Union type for all discovered types\n';
290
+
typesContent += `export type DiscoveredTypes = ${allTypes.map(t => `'${t}'`).join(' | ')};\n\n`;
291
+
}
292
+
293
+
await fs.writeFile(outputPath, typesContent);
294
+
295
+
console.log(`💾 Saved discovery results to: ${outputPath}`);
296
+
console.log(`📊 Generated ${discovery.totalCollections} collection types`);
297
+
298
+
} catch (error) {
299
+
console.error('Error saving discovery results:', error);
300
+
throw error;
301
+
}
302
+
}
303
+
}
+117
src/lib/components/discovered-registry.ts
+117
src/lib/components/discovered-registry.ts
···
1
+
import type { DiscoveredTypes } from '../generated/discovered-types';
2
+
3
+
export interface DiscoveredComponent {
4
+
$type: DiscoveredTypes;
5
+
component: string;
6
+
props: Record<string, any>;
7
+
}
8
+
9
+
export interface ComponentRegistry {
10
+
[key: string]: {
11
+
component: string;
12
+
props?: Record<string, any>;
13
+
};
14
+
}
15
+
16
+
export class DiscoveredComponentRegistry {
17
+
private registry: ComponentRegistry = {};
18
+
private discoveredTypes: DiscoveredTypes[] = [];
19
+
20
+
constructor() {
21
+
this.initializeRegistry();
22
+
}
23
+
24
+
// Initialize the registry with discovered types
25
+
private initializeRegistry(): void {
26
+
// This will be populated with discovered types
27
+
// For now, we'll use a basic mapping
28
+
this.registry = {
29
+
'app.bsky.feed.post': {
30
+
component: 'BlueskyPost',
31
+
props: { showAuthor: false, showTimestamp: true }
32
+
},
33
+
'app.bsky.actor.profile': {
34
+
component: 'ProfileDisplay',
35
+
props: { showHandle: true }
36
+
},
37
+
'social.grain.gallery': {
38
+
component: 'GrainGalleryDisplay',
39
+
props: { showCollections: true, columns: 3 }
40
+
},
41
+
'grain.social.feed.gallery': {
42
+
component: 'GrainGalleryDisplay',
43
+
props: { showCollections: true, columns: 3 }
44
+
}
45
+
};
46
+
}
47
+
48
+
// Register a component for a specific $type
49
+
registerComponent($type: DiscoveredTypes, component: string, props?: Record<string, any>): void {
50
+
this.registry[$type] = {
51
+
component,
52
+
props
53
+
};
54
+
}
55
+
56
+
// Get component info for a $type
57
+
getComponent($type: DiscoveredTypes): { component: string; props?: Record<string, any> } | null {
58
+
return this.registry[$type] || null;
59
+
}
60
+
61
+
// Get all registered $types
62
+
getRegisteredTypes(): DiscoveredTypes[] {
63
+
return Object.keys(this.registry) as DiscoveredTypes[];
64
+
}
65
+
66
+
// Check if a $type has a registered component
67
+
hasComponent($type: DiscoveredTypes): boolean {
68
+
return $type in this.registry;
69
+
}
70
+
71
+
// Get component mapping for rendering
72
+
getComponentMapping(): ComponentRegistry {
73
+
return this.registry;
74
+
}
75
+
76
+
// Update discovered types (called after build-time discovery)
77
+
updateDiscoveredTypes(types: DiscoveredTypes[]): void {
78
+
this.discoveredTypes = types;
79
+
80
+
// Auto-register components for discovered types that don't have explicit mappings
81
+
for (const $type of types) {
82
+
if (!this.hasComponent($type)) {
83
+
// Auto-assign based on service/collection
84
+
const component = this.autoAssignComponent($type);
85
+
if (component) {
86
+
this.registerComponent($type, component);
87
+
}
88
+
}
89
+
}
90
+
}
91
+
92
+
// Auto-assign component based on $type
93
+
private autoAssignComponent($type: DiscoveredTypes): string | null {
94
+
if ($type.includes('grain') || $type.includes('gallery')) {
95
+
return 'GrainGalleryDisplay';
96
+
}
97
+
if ($type.includes('post') || $type.includes('feed')) {
98
+
return 'BlueskyPost';
99
+
}
100
+
if ($type.includes('profile') || $type.includes('actor')) {
101
+
return 'ProfileDisplay';
102
+
}
103
+
return 'GenericContentDisplay';
104
+
}
105
+
106
+
// Get component info for rendering
107
+
getComponentInfo($type: DiscoveredTypes): DiscoveredComponent | null {
108
+
const componentInfo = this.getComponent($type);
109
+
if (!componentInfo) return null;
110
+
111
+
return {
112
+
$type,
113
+
component: componentInfo.component,
114
+
props: componentInfo.props || {}
115
+
};
116
+
}
117
+
}
+8
-1
src/lib/config/collections.ts
+8
-1
src/lib/config/collections.ts
···
62
62
63
63
// Grain.social collections (high priority for your use case)
64
64
{
65
-
name: 'grain.social.feed.gallery',
65
+
name: 'social.grain.gallery',
66
66
description: 'Grain.social image galleries',
67
67
service: 'grain.social',
68
68
priority: 95,
69
+
enabled: true
70
+
},
71
+
{
72
+
name: 'grain.social.feed.gallery',
73
+
description: 'Grain.social image galleries (legacy)',
74
+
service: 'grain.social',
75
+
priority: 85,
69
76
enabled: true
70
77
},
71
78
{
+232
src/lib/services/content-renderer.ts
+232
src/lib/services/content-renderer.ts
···
1
+
import { AtprotoBrowser } from '../atproto/atproto-browser';
2
+
import { loadConfig } from '../config/site';
3
+
import type { AtprotoRecord } from '../types/atproto';
4
+
5
+
export interface ContentRendererOptions {
6
+
showAuthor?: boolean;
7
+
showTimestamp?: boolean;
8
+
showType?: boolean;
9
+
limit?: number;
10
+
filter?: (record: AtprotoRecord) => boolean;
11
+
}
12
+
13
+
export interface RenderedContent {
14
+
type: string;
15
+
component: string;
16
+
props: Record<string, any>;
17
+
metadata: {
18
+
uri: string;
19
+
cid: string;
20
+
collection: string;
21
+
$type: string;
22
+
createdAt: string;
23
+
};
24
+
}
25
+
26
+
export class ContentRenderer {
27
+
private browser: AtprotoBrowser;
28
+
private config: any;
29
+
30
+
constructor() {
31
+
this.config = loadConfig();
32
+
this.browser = new AtprotoBrowser();
33
+
}
34
+
35
+
// Determine the appropriate component for a record type
36
+
private getComponentForType($type: string): string {
37
+
// Map ATProto types to component names
38
+
const componentMap: Record<string, string> = {
39
+
'app.bsky.feed.post': 'BlueskyPost',
40
+
'app.bsky.actor.profile#whitewindBlogPost': 'WhitewindBlogPost',
41
+
'app.bsky.actor.profile#leafletPublication': 'LeafletPublication',
42
+
'app.bsky.actor.profile#grainImageGallery': 'GrainImageGallery',
43
+
'gallery.display': 'GalleryDisplay',
44
+
};
45
+
46
+
// Check for gallery-related types
47
+
if ($type.includes('gallery') || $type.includes('grain')) {
48
+
return 'GalleryDisplay';
49
+
}
50
+
51
+
return componentMap[$type] || 'BlueskyPost';
52
+
}
53
+
54
+
// Process a record into a renderable format
55
+
private processRecord(record: AtprotoRecord): RenderedContent | null {
56
+
const value = record.value;
57
+
if (!value || !value.$type) return null;
58
+
59
+
const component = this.getComponentForType(value.$type);
60
+
61
+
// Extract common metadata
62
+
const metadata = {
63
+
uri: record.uri,
64
+
cid: record.cid,
65
+
collection: record.collection,
66
+
$type: value.$type,
67
+
createdAt: value.createdAt || record.indexedAt,
68
+
};
69
+
70
+
// For gallery display, use the gallery service format
71
+
if (component === 'GalleryDisplay') {
72
+
// This would need to be processed by the gallery service
73
+
// For now, return a basic format
74
+
return {
75
+
type: 'gallery',
76
+
component: 'GalleryDisplay',
77
+
props: {
78
+
gallery: {
79
+
uri: record.uri,
80
+
cid: record.cid,
81
+
title: value.title || 'Untitled Gallery',
82
+
description: value.description,
83
+
text: value.text,
84
+
createdAt: value.createdAt || record.indexedAt,
85
+
images: this.extractImages(value),
86
+
$type: value.$type,
87
+
collection: record.collection,
88
+
},
89
+
showDescription: true,
90
+
showTimestamp: true,
91
+
showType: false,
92
+
columns: 3,
93
+
},
94
+
metadata,
95
+
};
96
+
}
97
+
98
+
// For other content types, return the record directly
99
+
return {
100
+
type: 'content',
101
+
component,
102
+
props: {
103
+
post: value,
104
+
showAuthor: false,
105
+
showTimestamp: true,
106
+
},
107
+
metadata,
108
+
};
109
+
}
110
+
111
+
// Extract images from various embed formats
112
+
private extractImages(value: any): Array<{ alt?: string; url: string }> {
113
+
const images: Array<{ alt?: string; url: string }> = [];
114
+
115
+
// Extract from embed.images
116
+
if (value.embed?.$type === 'app.bsky.embed.images' && value.embed.images) {
117
+
for (const image of value.embed.images) {
118
+
if (image.image?.ref) {
119
+
const did = this.config.atproto.did;
120
+
const url = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${image.image.ref}`;
121
+
images.push({
122
+
alt: image.alt,
123
+
url,
124
+
});
125
+
}
126
+
}
127
+
}
128
+
129
+
// Extract from direct images array
130
+
if (value.images && Array.isArray(value.images)) {
131
+
for (const image of value.images) {
132
+
if (image.url) {
133
+
images.push({
134
+
alt: image.alt,
135
+
url: image.url,
136
+
});
137
+
}
138
+
}
139
+
}
140
+
141
+
return images;
142
+
}
143
+
144
+
// Fetch and render content for a given identifier
145
+
async renderContent(
146
+
identifier: string,
147
+
options: ContentRendererOptions = {}
148
+
): Promise<RenderedContent[]> {
149
+
try {
150
+
const { limit = 50, filter } = options;
151
+
152
+
// Get repository info
153
+
const repoInfo = await this.browser.getRepoInfo(identifier);
154
+
if (!repoInfo) {
155
+
throw new Error(`Could not get repository info for: ${identifier}`);
156
+
}
157
+
158
+
const renderedContent: RenderedContent[] = [];
159
+
160
+
// Get records from main collections
161
+
const collections = ['app.bsky.feed.post', 'app.bsky.actor.profile', 'social.grain.gallery'];
162
+
163
+
for (const collection of collections) {
164
+
if (repoInfo.collections.includes(collection)) {
165
+
const records = await this.browser.getCollectionRecords(identifier, collection, limit);
166
+
if (records && records.records) {
167
+
for (const record of records.records) {
168
+
// Apply filter if provided
169
+
if (filter && !filter(record)) continue;
170
+
171
+
const rendered = this.processRecord(record);
172
+
if (rendered) {
173
+
renderedContent.push(rendered);
174
+
}
175
+
}
176
+
}
177
+
}
178
+
}
179
+
180
+
// Sort by creation date (newest first)
181
+
renderedContent.sort((a, b) => {
182
+
const dateA = new Date(a.metadata.createdAt);
183
+
const dateB = new Date(b.metadata.createdAt);
184
+
return dateB.getTime() - dateA.getTime();
185
+
});
186
+
187
+
return renderedContent;
188
+
} catch (error) {
189
+
console.error('Error rendering content:', error);
190
+
return [];
191
+
}
192
+
}
193
+
194
+
// Render a specific record by URI
195
+
async renderRecord(uri: string): Promise<RenderedContent | null> {
196
+
try {
197
+
const record = await this.browser.getRecord(uri);
198
+
if (!record) return null;
199
+
200
+
return this.processRecord(record);
201
+
} catch (error) {
202
+
console.error('Error rendering record:', error);
203
+
return null;
204
+
}
205
+
}
206
+
207
+
// Get available content types for an identifier
208
+
async getContentTypes(identifier: string): Promise<string[]> {
209
+
try {
210
+
const repoInfo = await this.browser.getRepoInfo(identifier);
211
+
if (!repoInfo) return [];
212
+
213
+
const types = new Set<string>();
214
+
215
+
for (const collection of repoInfo.collections) {
216
+
const records = await this.browser.getCollectionRecords(identifier, collection, 10);
217
+
if (records && records.records) {
218
+
for (const record of records.records) {
219
+
if (record.value?.$type) {
220
+
types.add(record.value.$type);
221
+
}
222
+
}
223
+
}
224
+
}
225
+
226
+
return Array.from(types);
227
+
} catch (error) {
228
+
console.error('Error getting content types:', error);
229
+
return [];
230
+
}
231
+
}
232
+
}
+271
src/lib/services/content-system.ts
+271
src/lib/services/content-system.ts
···
1
+
import { AtprotoBrowser } from '../atproto/atproto-browser';
2
+
import { JetstreamClient } from '../atproto/jetstream-client';
3
+
import { GrainGalleryService } from './grain-gallery-service';
4
+
import { loadConfig } from '../config/site';
5
+
import type { AtprotoRecord } from '../types/atproto';
6
+
7
+
export interface ContentItem {
8
+
uri: string;
9
+
cid: string;
10
+
$type: string;
11
+
collection: string;
12
+
createdAt: string;
13
+
indexedAt: string;
14
+
value: any;
15
+
service: string;
16
+
operation?: 'create' | 'update' | 'delete';
17
+
}
18
+
19
+
export interface ContentFeed {
20
+
items: ContentItem[];
21
+
lastUpdated: string;
22
+
totalItems: number;
23
+
collections: string[];
24
+
}
25
+
26
+
export interface ContentSystemConfig {
27
+
enableStreaming?: boolean;
28
+
buildTimeOnly?: boolean;
29
+
collections?: string[];
30
+
maxItems?: number;
31
+
}
32
+
33
+
export class ContentSystem {
34
+
private browser: AtprotoBrowser;
35
+
private jetstream: JetstreamClient;
36
+
private grainGalleryService: GrainGalleryService;
37
+
private config: any;
38
+
private contentFeed: ContentFeed;
39
+
private isStreaming = false;
40
+
41
+
constructor() {
42
+
this.config = loadConfig();
43
+
this.browser = new AtprotoBrowser();
44
+
this.jetstream = new JetstreamClient();
45
+
this.grainGalleryService = new GrainGalleryService();
46
+
47
+
this.contentFeed = {
48
+
items: [],
49
+
lastUpdated: new Date().toISOString(),
50
+
totalItems: 0,
51
+
collections: []
52
+
};
53
+
}
54
+
55
+
// Initialize content system (build-time)
56
+
async initialize(identifier: string, options: ContentSystemConfig = {}): Promise<ContentFeed> {
57
+
console.log('🚀 Initializing content system for:', identifier);
58
+
59
+
try {
60
+
// Get repository info
61
+
const repoInfo = await this.browser.getRepoInfo(identifier);
62
+
if (!repoInfo) {
63
+
throw new Error(`Could not get repository info for: ${identifier}`);
64
+
}
65
+
66
+
console.log('📊 Repository info:', {
67
+
handle: repoInfo.handle,
68
+
did: repoInfo.did,
69
+
collections: repoInfo.collections.length,
70
+
recordCount: repoInfo.recordCount
71
+
});
72
+
73
+
// Gather all content from collections
74
+
const allItems: ContentItem[] = [];
75
+
const collections = options.collections || repoInfo.collections;
76
+
77
+
for (const collection of collections) {
78
+
console.log(`📦 Fetching from collection: ${collection}`);
79
+
const records = await this.browser.getCollectionRecords(identifier, collection, options.maxItems || 100);
80
+
81
+
if (records && records.records) {
82
+
for (const record of records.records) {
83
+
const contentItem: ContentItem = {
84
+
uri: record.uri,
85
+
cid: record.cid,
86
+
$type: record.$type,
87
+
collection: record.collection,
88
+
createdAt: record.value?.createdAt || record.indexedAt,
89
+
indexedAt: record.indexedAt,
90
+
value: record.value,
91
+
service: this.inferService(record.$type, record.collection),
92
+
operation: 'create' // Build-time items are existing
93
+
};
94
+
95
+
allItems.push(contentItem);
96
+
}
97
+
}
98
+
}
99
+
100
+
// Sort by creation date (newest first)
101
+
allItems.sort((a, b) => {
102
+
const dateA = new Date(a.createdAt);
103
+
const dateB = new Date(b.createdAt);
104
+
return dateB.getTime() - dateA.getTime();
105
+
});
106
+
107
+
this.contentFeed = {
108
+
items: allItems,
109
+
lastUpdated: new Date().toISOString(),
110
+
totalItems: allItems.length,
111
+
collections: collections
112
+
};
113
+
114
+
console.log(`✅ Content system initialized with ${allItems.length} items`);
115
+
116
+
// Start streaming if enabled
117
+
if (!options.buildTimeOnly && options.enableStreaming !== false) {
118
+
await this.startStreaming(identifier);
119
+
}
120
+
121
+
return this.contentFeed;
122
+
} catch (error) {
123
+
console.error('Error initializing content system:', error);
124
+
throw error;
125
+
}
126
+
}
127
+
128
+
// Start real-time streaming
129
+
async startStreaming(identifier: string): Promise<void> {
130
+
if (this.isStreaming) {
131
+
console.log('⚠️ Already streaming');
132
+
return;
133
+
}
134
+
135
+
console.log('🌊 Starting real-time content streaming...');
136
+
this.isStreaming = true;
137
+
138
+
// Set up jetstream event handlers
139
+
this.jetstream.onRecord((record) => {
140
+
this.handleNewContent(record);
141
+
});
142
+
143
+
this.jetstream.onError((error) => {
144
+
console.error('❌ Jetstream error:', error);
145
+
});
146
+
147
+
this.jetstream.onConnect(() => {
148
+
console.log('✅ Connected to real-time stream');
149
+
});
150
+
151
+
this.jetstream.onDisconnect(() => {
152
+
console.log('🔌 Disconnected from real-time stream');
153
+
this.isStreaming = false;
154
+
});
155
+
156
+
// Start streaming
157
+
await this.jetstream.startStreaming();
158
+
}
159
+
160
+
// Handle new content from streaming
161
+
private handleNewContent(jetstreamRecord: any): void {
162
+
const contentItem: ContentItem = {
163
+
uri: jetstreamRecord.uri,
164
+
cid: jetstreamRecord.cid,
165
+
$type: jetstreamRecord.$type,
166
+
collection: jetstreamRecord.collection,
167
+
createdAt: jetstreamRecord.value?.createdAt || jetstreamRecord.indexedAt,
168
+
indexedAt: jetstreamRecord.indexedAt,
169
+
value: jetstreamRecord.value,
170
+
service: jetstreamRecord.service,
171
+
operation: jetstreamRecord.operation
172
+
};
173
+
174
+
// Add to beginning of feed (newest first)
175
+
this.contentFeed.items.unshift(contentItem);
176
+
this.contentFeed.totalItems++;
177
+
this.contentFeed.lastUpdated = new Date().toISOString();
178
+
179
+
console.log('📝 New content added:', {
180
+
$type: contentItem.$type,
181
+
collection: contentItem.collection,
182
+
operation: contentItem.operation
183
+
});
184
+
185
+
// Emit event for UI updates
186
+
this.emitContentUpdate(contentItem);
187
+
}
188
+
189
+
// Get current content feed
190
+
getContentFeed(): ContentFeed {
191
+
return this.contentFeed;
192
+
}
193
+
194
+
// Get content by type
195
+
getContentByType($type: string): ContentItem[] {
196
+
return this.contentFeed.items.filter(item => item.$type === $type);
197
+
}
198
+
199
+
// Get content by collection
200
+
getContentByCollection(collection: string): ContentItem[] {
201
+
return this.contentFeed.items.filter(item => item.collection === collection);
202
+
}
203
+
204
+
// Get galleries (using specialized service)
205
+
async getGalleries(identifier: string): Promise<any[]> {
206
+
return await this.grainGalleryService.getGalleries(identifier);
207
+
}
208
+
209
+
// Filter content by function
210
+
filterContent(filterFn: (item: ContentItem) => boolean): ContentItem[] {
211
+
return this.contentFeed.items.filter(filterFn);
212
+
}
213
+
214
+
// Search content
215
+
searchContent(query: string): ContentItem[] {
216
+
const lowerQuery = query.toLowerCase();
217
+
return this.contentFeed.items.filter(item => {
218
+
const text = JSON.stringify(item.value).toLowerCase();
219
+
return text.includes(lowerQuery);
220
+
});
221
+
}
222
+
223
+
// Stop streaming
224
+
stopStreaming(): void {
225
+
if (this.isStreaming) {
226
+
this.jetstream.stopStreaming();
227
+
this.isStreaming = false;
228
+
}
229
+
}
230
+
231
+
// Infer service from record type and collection
232
+
private inferService($type: string, collection: string): string {
233
+
if (collection.startsWith('grain.social') || $type.includes('grain')) return 'grain.social';
234
+
if (collection.startsWith('app.bsky')) return 'bsky.app';
235
+
if (collection.startsWith('sh.tangled')) return 'sh.tangled';
236
+
return 'unknown';
237
+
}
238
+
239
+
// Event system for UI updates
240
+
private listeners: {
241
+
onContentUpdate?: (item: ContentItem) => void;
242
+
onContentAdd?: (item: ContentItem) => void;
243
+
onContentRemove?: (item: ContentItem) => void;
244
+
} = {};
245
+
246
+
onContentUpdate(callback: (item: ContentItem) => void): void {
247
+
this.listeners.onContentUpdate = callback;
248
+
}
249
+
250
+
onContentAdd(callback: (item: ContentItem) => void): void {
251
+
this.listeners.onContentAdd = callback;
252
+
}
253
+
254
+
onContentRemove(callback: (item: ContentItem) => void): void {
255
+
this.listeners.onContentRemove = callback;
256
+
}
257
+
258
+
private emitContentUpdate(item: ContentItem): void {
259
+
this.listeners.onContentUpdate?.(item);
260
+
if (item.operation === 'create') {
261
+
this.listeners.onContentAdd?.(item);
262
+
} else if (item.operation === 'delete') {
263
+
this.listeners.onContentRemove?.(item);
264
+
}
265
+
}
266
+
267
+
// Get streaming status
268
+
getStreamingStatus(): 'streaming' | 'stopped' {
269
+
return this.isStreaming ? 'streaming' : 'stopped';
270
+
}
271
+
}
+280
src/lib/services/grain-gallery-service.ts
+280
src/lib/services/grain-gallery-service.ts
···
1
+
import { AtprotoBrowser } from '../atproto/atproto-browser';
2
+
import { loadConfig } from '../config/site';
3
+
import type { AtprotoRecord } from '../types/atproto';
4
+
5
+
export interface GrainGalleryItem {
6
+
uri: string;
7
+
cid: string;
8
+
value: {
9
+
$type: string;
10
+
galleryId?: string;
11
+
gallery_id?: string;
12
+
id?: string;
13
+
title?: string;
14
+
description?: string;
15
+
caption?: string;
16
+
image?: {
17
+
url?: string;
18
+
src?: string;
19
+
alt?: string;
20
+
caption?: string;
21
+
};
22
+
photo?: {
23
+
url?: string;
24
+
src?: string;
25
+
alt?: string;
26
+
caption?: string;
27
+
};
28
+
media?: {
29
+
url?: string;
30
+
src?: string;
31
+
alt?: string;
32
+
caption?: string;
33
+
};
34
+
createdAt: string;
35
+
};
36
+
indexedAt: string;
37
+
collection: string;
38
+
}
39
+
40
+
export interface GrainGallery {
41
+
id: string;
42
+
title: string;
43
+
description?: string;
44
+
createdAt: string;
45
+
items: GrainGalleryItem[];
46
+
imageCount: number;
47
+
collections: string[];
48
+
}
49
+
50
+
export interface ProcessedGrainGallery {
51
+
id: string;
52
+
title: string;
53
+
description?: string;
54
+
createdAt: string;
55
+
images: Array<{
56
+
alt?: string;
57
+
url: string;
58
+
caption?: string;
59
+
}>;
60
+
itemCount: number;
61
+
collections: string[];
62
+
}
63
+
64
+
export class GrainGalleryService {
65
+
private browser: AtprotoBrowser;
66
+
private config: any;
67
+
68
+
constructor() {
69
+
this.config = loadConfig();
70
+
this.browser = new AtprotoBrowser();
71
+
}
72
+
73
+
// Extract gallery ID from various possible fields
74
+
private extractGalleryId(item: GrainGalleryItem): string | null {
75
+
const value = item.value;
76
+
77
+
// Direct gallery ID fields
78
+
if (value.galleryId) return value.galleryId;
79
+
if (value.gallery_id) return value.gallery_id;
80
+
if (value.id) return value.id;
81
+
82
+
// Extract from URI if it contains gallery ID
83
+
const uriMatch = item.uri.match(/gallery\/([^\/]+)/);
84
+
if (uriMatch) return uriMatch[1];
85
+
86
+
// Extract from title if it contains gallery ID
87
+
if (value.title) {
88
+
const titleMatch = value.title.match(/gallery[:\-\s]+([^\s]+)/i);
89
+
if (titleMatch) return titleMatch[1];
90
+
}
91
+
92
+
// Use a hash of the collection and first part of URI as fallback
93
+
return `${item.collection}-${item.uri.split('/').pop()?.split('?')[0]}`;
94
+
}
95
+
96
+
// Extract image from gallery item
97
+
private extractImageFromItem(item: GrainGalleryItem): { alt?: string; url: string; caption?: string } | null {
98
+
const value = item.value;
99
+
100
+
// Try different image fields
101
+
const imageFields = ['image', 'photo', 'media'];
102
+
103
+
for (const field of imageFields) {
104
+
const imageData = value[field];
105
+
if (imageData && (imageData.url || imageData.src)) {
106
+
return {
107
+
alt: imageData.alt || imageData.caption || value.caption,
108
+
url: imageData.url || imageData.src,
109
+
caption: imageData.caption || value.caption
110
+
};
111
+
}
112
+
}
113
+
114
+
return null;
115
+
}
116
+
117
+
// Group gallery items into galleries
118
+
private groupGalleryItems(items: GrainGalleryItem[]): GrainGallery[] {
119
+
const galleryGroups = new Map<string, GrainGallery>();
120
+
121
+
for (const item of items) {
122
+
const galleryId = this.extractGalleryId(item);
123
+
if (!galleryId) continue;
124
+
125
+
if (!galleryGroups.has(galleryId)) {
126
+
// Create new gallery
127
+
galleryGroups.set(galleryId, {
128
+
id: galleryId,
129
+
title: item.value.title || `Gallery ${galleryId}`,
130
+
description: item.value.description || item.value.caption,
131
+
createdAt: item.value.createdAt || item.indexedAt,
132
+
items: [],
133
+
imageCount: 0,
134
+
collections: new Set()
135
+
});
136
+
}
137
+
138
+
const gallery = galleryGroups.get(galleryId)!;
139
+
gallery.items.push(item);
140
+
gallery.collections.add(item.collection);
141
+
142
+
// Update earliest creation date
143
+
const itemDate = new Date(item.value.createdAt || item.indexedAt);
144
+
const galleryDate = new Date(gallery.createdAt);
145
+
if (itemDate < galleryDate) {
146
+
gallery.createdAt = item.value.createdAt || item.indexedAt;
147
+
}
148
+
}
149
+
150
+
// Calculate image counts and convert collections to arrays
151
+
for (const gallery of galleryGroups.values()) {
152
+
gallery.imageCount = gallery.items.filter(item => this.extractImageFromItem(item)).length;
153
+
gallery.collections = Array.from(gallery.collections);
154
+
}
155
+
156
+
return Array.from(galleryGroups.values());
157
+
}
158
+
159
+
// Process gallery into display format
160
+
private processGalleryForDisplay(gallery: GrainGallery): ProcessedGrainGallery {
161
+
const images: Array<{ alt?: string; url: string; caption?: string }> = [];
162
+
163
+
// Extract images from all items
164
+
for (const item of gallery.items) {
165
+
const image = this.extractImageFromItem(item);
166
+
if (image) {
167
+
images.push(image);
168
+
}
169
+
}
170
+
171
+
return {
172
+
id: gallery.id,
173
+
title: gallery.title,
174
+
description: gallery.description,
175
+
createdAt: gallery.createdAt,
176
+
images,
177
+
itemCount: gallery.items.length,
178
+
collections: gallery.collections
179
+
};
180
+
}
181
+
182
+
// Fetch all gallery items from Grain.social collections
183
+
async getGalleryItems(identifier: string): Promise<GrainGalleryItem[]> {
184
+
try {
185
+
const repoInfo = await this.browser.getRepoInfo(identifier);
186
+
if (!repoInfo) {
187
+
throw new Error(`Could not get repository info for: ${identifier}`);
188
+
}
189
+
190
+
const items: GrainGalleryItem[] = [];
191
+
192
+
// Get all grain-related collections
193
+
const grainCollections = repoInfo.collections.filter(col =>
194
+
col.includes('grain') || col.includes('social.grain')
195
+
);
196
+
197
+
console.log('🔍 Found grain collections:', grainCollections);
198
+
199
+
for (const collection of grainCollections) {
200
+
const records = await this.browser.getCollectionRecords(identifier, collection, 200);
201
+
if (records && records.records) {
202
+
console.log(`📦 Collection ${collection}: ${records.records.length} records`);
203
+
204
+
for (const record of records.records) {
205
+
// Convert to GrainGalleryItem format
206
+
const item: GrainGalleryItem = {
207
+
uri: record.uri,
208
+
cid: record.cid,
209
+
value: record.value,
210
+
indexedAt: record.indexedAt,
211
+
collection: record.collection
212
+
};
213
+
214
+
items.push(item);
215
+
}
216
+
}
217
+
}
218
+
219
+
console.log(`🎯 Total gallery items found: ${items.length}`);
220
+
return items;
221
+
} catch (error) {
222
+
console.error('Error fetching gallery items:', error);
223
+
return [];
224
+
}
225
+
}
226
+
227
+
// Get grouped galleries
228
+
async getGalleries(identifier: string): Promise<ProcessedGrainGallery[]> {
229
+
try {
230
+
const items = await this.getGalleryItems(identifier);
231
+
232
+
if (items.length === 0) {
233
+
console.log('No gallery items found');
234
+
return [];
235
+
}
236
+
237
+
// Group items into galleries
238
+
const groupedGalleries = this.groupGalleryItems(items);
239
+
240
+
console.log(`🏛️ Grouped into ${groupedGalleries.length} galleries`);
241
+
242
+
// Process for display
243
+
const processedGalleries = groupedGalleries.map(gallery => this.processGalleryForDisplay(gallery));
244
+
245
+
// Sort by creation date (newest first)
246
+
processedGalleries.sort((a, b) => {
247
+
const dateA = new Date(a.createdAt);
248
+
const dateB = new Date(b.createdAt);
249
+
return dateB.getTime() - dateA.getTime();
250
+
});
251
+
252
+
return processedGalleries;
253
+
} catch (error) {
254
+
console.error('Error getting galleries:', error);
255
+
return [];
256
+
}
257
+
}
258
+
259
+
// Get a specific gallery by ID
260
+
async getGallery(identifier: string, galleryId: string): Promise<ProcessedGrainGallery | null> {
261
+
try {
262
+
const galleries = await this.getGalleries(identifier);
263
+
return galleries.find(gallery => gallery.id === galleryId) || null;
264
+
} catch (error) {
265
+
console.error('Error getting gallery:', error);
266
+
return null;
267
+
}
268
+
}
269
+
270
+
// Get gallery items for a specific gallery
271
+
async getGalleryItemsForGallery(identifier: string, galleryId: string): Promise<GrainGalleryItem[]> {
272
+
try {
273
+
const items = await this.getGalleryItems(identifier);
274
+
return items.filter(item => this.extractGalleryId(item) === galleryId);
275
+
} catch (error) {
276
+
console.error('Error getting gallery items:', error);
277
+
return [];
278
+
}
279
+
}
280
+
}
+195
src/pages/api-debug.astro
+195
src/pages/api-debug.astro
···
1
+
---
2
+
import Layout from '../layouts/Layout.astro';
3
+
import { AtprotoBrowser } from '../lib/atproto/atproto-browser';
4
+
import { loadConfig } from '../lib/config/site';
5
+
6
+
const config = loadConfig();
7
+
const browser = new AtprotoBrowser();
8
+
9
+
let debugInfo = {
10
+
config: null,
11
+
repoInfo: null,
12
+
collections: [],
13
+
testResults: [],
14
+
error: null
15
+
};
16
+
17
+
try {
18
+
debugInfo.config = {
19
+
handle: config.atproto.handle,
20
+
did: config.atproto.did,
21
+
pdsUrl: config.atproto.pdsUrl
22
+
};
23
+
24
+
if (config.atproto.handle && config.atproto.handle !== 'your-handle-here') {
25
+
// Test 1: Resolve handle
26
+
console.log('🔍 Testing handle resolution...');
27
+
const resolvedDid = await browser.resolveHandle(config.atproto.handle);
28
+
debugInfo.testResults.push({
29
+
test: 'Handle Resolution',
30
+
success: !!resolvedDid,
31
+
result: resolvedDid,
32
+
error: null
33
+
});
34
+
35
+
// Test 2: Get repo info
36
+
console.log('📊 Testing repo info...');
37
+
const repoInfo = await browser.getRepoInfo(config.atproto.handle);
38
+
debugInfo.repoInfo = repoInfo;
39
+
debugInfo.testResults.push({
40
+
test: 'Repository Info',
41
+
success: !!repoInfo,
42
+
result: repoInfo ? {
43
+
did: repoInfo.did,
44
+
handle: repoInfo.handle,
45
+
collections: repoInfo.collections.length,
46
+
recordCount: repoInfo.recordCount
47
+
} : null,
48
+
error: null
49
+
});
50
+
51
+
if (repoInfo) {
52
+
debugInfo.collections = repoInfo.collections;
53
+
54
+
// Test 3: Get records from first collection
55
+
if (repoInfo.collections.length > 0) {
56
+
const firstCollection = repoInfo.collections[0];
57
+
console.log(`📦 Testing collection: ${firstCollection}`);
58
+
const records = await browser.getCollectionRecords(config.atproto.handle, firstCollection, 5);
59
+
debugInfo.testResults.push({
60
+
test: `Collection Records (${firstCollection})`,
61
+
success: !!records,
62
+
result: records ? {
63
+
collection: records.collection,
64
+
recordCount: records.recordCount,
65
+
sampleRecords: records.records.slice(0, 2).map(r => ({
66
+
uri: r.uri,
67
+
$type: r.$type,
68
+
collection: r.collection
69
+
}))
70
+
} : null,
71
+
error: null
72
+
});
73
+
}
74
+
}
75
+
}
76
+
} catch (error) {
77
+
debugInfo.error = error;
78
+
console.error('API Debug Error:', error);
79
+
}
80
+
---
81
+
82
+
<Layout title="API Debug">
83
+
<div class="container mx-auto px-4 py-8">
84
+
<header class="text-center mb-12">
85
+
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
86
+
API Debug
87
+
</h1>
88
+
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
89
+
Testing ATProto API calls and configuration
90
+
</p>
91
+
</header>
92
+
93
+
<main class="max-w-6xl mx-auto space-y-8">
94
+
{debugInfo.error ? (
95
+
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8">
96
+
<h3 class="text-xl font-semibold text-red-800 dark:text-red-200 mb-4">
97
+
Error
98
+
</h3>
99
+
<p class="text-red-700 dark:text-red-300 mb-4">
100
+
{debugInfo.error instanceof Error ? debugInfo.error.message : String(debugInfo.error)}
101
+
</p>
102
+
<pre class="bg-red-100 dark:bg-red-800 p-3 rounded text-xs overflow-x-auto">
103
+
{JSON.stringify(debugInfo.error, null, 2)}
104
+
</pre>
105
+
</div>
106
+
) : (
107
+
<>
108
+
<!-- Configuration -->
109
+
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
110
+
<h3 class="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-2">
111
+
Configuration
112
+
</h3>
113
+
<div class="text-sm text-blue-700 dark:text-blue-300">
114
+
<pre class="bg-blue-100 dark:bg-blue-800 p-3 rounded text-xs overflow-x-auto">
115
+
{JSON.stringify(debugInfo.config, null, 2)}
116
+
</pre>
117
+
</div>
118
+
</div>
119
+
120
+
<!-- Test Results -->
121
+
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6">
122
+
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200 mb-2">
123
+
API Test Results
124
+
</h3>
125
+
<div class="space-y-4">
126
+
{debugInfo.testResults.map((test, index) => (
127
+
<div class="bg-green-100 dark:bg-green-800 p-4 rounded">
128
+
<div class="flex items-center justify-between mb-2">
129
+
<h4 class="font-semibold text-green-800 dark:text-green-200">
130
+
{test.test}
131
+
</h4>
132
+
<span class={`px-2 py-1 rounded text-xs ${
133
+
test.success
134
+
? 'bg-green-200 text-green-800 dark:bg-green-700 dark:text-green-200'
135
+
: 'bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200'
136
+
}`}>
137
+
{test.success ? '✅ Success' : '❌ Failed'}
138
+
</span>
139
+
</div>
140
+
{test.result && (
141
+
<pre class="text-xs bg-green-200 dark:bg-green-700 p-2 rounded overflow-x-auto">
142
+
{JSON.stringify(test.result, null, 2)}
143
+
</pre>
144
+
)}
145
+
{test.error && (
146
+
<p class="text-red-600 dark:text-red-400 text-xs mt-2">
147
+
Error: {test.error}
148
+
</p>
149
+
)}
150
+
</div>
151
+
))}
152
+
</div>
153
+
</div>
154
+
155
+
<!-- Repository Info -->
156
+
{debugInfo.repoInfo && (
157
+
<div class="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-6">
158
+
<h3 class="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-2">
159
+
Repository Information
160
+
</h3>
161
+
<div class="text-sm text-purple-700 dark:text-purple-300">
162
+
<p><strong>DID:</strong> {debugInfo.repoInfo.did}</p>
163
+
<p><strong>Handle:</strong> {debugInfo.repoInfo.handle}</p>
164
+
<p><strong>Record Count:</strong> {debugInfo.repoInfo.recordCount}</p>
165
+
<p><strong>Collections:</strong> {debugInfo.repoInfo.collections.length}</p>
166
+
<div class="mt-2">
167
+
<p class="font-semibold">Collections:</p>
168
+
<ul class="list-disc list-inside space-y-1">
169
+
{debugInfo.repoInfo.collections.map((collection) => (
170
+
<li class="bg-purple-100 dark:bg-purple-800 px-2 py-1 rounded text-xs">
171
+
{collection}
172
+
</li>
173
+
))}
174
+
</ul>
175
+
</div>
176
+
</div>
177
+
</div>
178
+
)}
179
+
180
+
<!-- Raw Debug Info -->
181
+
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-6">
182
+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
183
+
Raw Debug Information
184
+
</h3>
185
+
<div class="text-sm text-gray-600 dark:text-gray-400">
186
+
<pre class="bg-gray-100 dark:bg-gray-700 p-3 rounded text-xs overflow-x-auto">
187
+
{JSON.stringify(debugInfo, null, 2)}
188
+
</pre>
189
+
</div>
190
+
</div>
191
+
</>
192
+
)}
193
+
</main>
194
+
</div>
195
+
</Layout>
+218
src/pages/content-feed.astro
+218
src/pages/content-feed.astro
···
1
+
---
2
+
import Layout from '../layouts/Layout.astro';
3
+
import { ContentSystem } from '../lib/services/content-system';
4
+
import { loadConfig } from '../lib/config/site';
5
+
6
+
const config = loadConfig();
7
+
const contentSystem = new ContentSystem();
8
+
9
+
let contentFeed = null;
10
+
let error = null;
11
+
12
+
try {
13
+
if (config.atproto.handle && config.atproto.handle !== 'your-handle-here') {
14
+
// Initialize content system (build-time gathering + runtime streaming)
15
+
contentFeed = await contentSystem.initialize(config.atproto.handle, {
16
+
enableStreaming: true,
17
+
maxItems: 200
18
+
});
19
+
}
20
+
} catch (err) {
21
+
error = err;
22
+
console.error('Content feed: Error:', err);
23
+
}
24
+
25
+
// Helper function to format date
26
+
const formatDate = (dateString: string) => {
27
+
return new Date(dateString).toLocaleDateString('en-US', {
28
+
year: 'numeric',
29
+
month: 'short',
30
+
day: 'numeric',
31
+
hour: '2-digit',
32
+
minute: '2-digit'
33
+
});
34
+
};
35
+
36
+
// Helper function to get service icon
37
+
const getServiceIcon = (service: string) => {
38
+
const icons = {
39
+
'grain.social': '🌾',
40
+
'bsky.app': '🔵',
41
+
'sh.tangled': '🪢',
42
+
'unknown': '❓'
43
+
};
44
+
return icons[service] || icons.unknown;
45
+
};
46
+
47
+
// Helper function to truncate text
48
+
const truncateText = (text: string, maxLength: number = 100) => {
49
+
if (text.length <= maxLength) return text;
50
+
return text.substring(0, maxLength) + '...';
51
+
};
52
+
---
53
+
54
+
<Layout title="Content Feed">
55
+
<div class="container mx-auto px-4 py-8">
56
+
<header class="text-center mb-12">
57
+
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
58
+
Content Feed
59
+
</h1>
60
+
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
61
+
All your ATProto content with real-time updates
62
+
</p>
63
+
</header>
64
+
65
+
<main class="max-w-6xl mx-auto">
66
+
{error ? (
67
+
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8">
68
+
<h3 class="text-xl font-semibold text-red-800 dark:text-red-200 mb-4">
69
+
Error Loading Content
70
+
</h3>
71
+
<p class="text-red-700 dark:text-red-300 mb-4">
72
+
{error instanceof Error ? error.message : String(error)}
73
+
</p>
74
+
</div>
75
+
) : contentFeed ? (
76
+
<div class="space-y-8">
77
+
<!-- Content Feed Stats -->
78
+
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
79
+
<h3 class="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-2">
80
+
Content Feed Stats
81
+
</h3>
82
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-blue-700 dark:text-blue-300">
83
+
<div>
84
+
<p class="font-semibold">Total Items</p>
85
+
<p class="text-2xl">{contentFeed.totalItems}</p>
86
+
</div>
87
+
<div>
88
+
<p class="font-semibold">Collections</p>
89
+
<p class="text-2xl">{contentFeed.collections.length}</p>
90
+
</div>
91
+
<div>
92
+
<p class="font-semibold">Last Updated</p>
93
+
<p class="text-sm">{formatDate(contentFeed.lastUpdated)}</p>
94
+
</div>
95
+
<div>
96
+
<p class="font-semibold">Status</p>
97
+
<p class="text-sm" id="streaming-status">Loading...</p>
98
+
</div>
99
+
</div>
100
+
</div>
101
+
102
+
<!-- Content Items -->
103
+
<div class="space-y-4">
104
+
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
105
+
All Content ({contentFeed.items.length} items)
106
+
</h2>
107
+
108
+
{contentFeed.items.length > 0 ? (
109
+
<div class="space-y-4" id="content-feed">
110
+
{contentFeed.items.map((item, index) => (
111
+
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
112
+
<header class="flex items-start justify-between mb-4">
113
+
<div class="flex items-center gap-3">
114
+
<span class="text-2xl">{getServiceIcon(item.service)}</span>
115
+
<div>
116
+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
117
+
{item.$type}
118
+
</h3>
119
+
<p class="text-sm text-gray-500 dark:text-gray-400">
120
+
Collection: {item.collection}
121
+
</p>
122
+
</div>
123
+
</div>
124
+
<div class="text-right text-sm text-gray-500 dark:text-gray-400">
125
+
<p>{formatDate(item.createdAt)}</p>
126
+
<p class="text-xs">{item.operation || 'existing'}</p>
127
+
</div>
128
+
</header>
129
+
130
+
<div class="mb-4">
131
+
<div class="flex items-center gap-2 mb-2">
132
+
<span class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-1 rounded text-xs">
133
+
{item.service}
134
+
</span>
135
+
<span class="bg-blue-100 dark:bg-blue-700 text-blue-600 dark:text-blue-300 px-2 py-1 rounded text-xs">
136
+
{item.$type}
137
+
</span>
138
+
</div>
139
+
140
+
{item.value?.text && (
141
+
<p class="text-gray-900 dark:text-white mb-3">
142
+
{truncateText(item.value.text, 200)}
143
+
</p>
144
+
)}
145
+
146
+
{item.value?.title && (
147
+
<p class="font-semibold text-gray-900 dark:text-white mb-2">
148
+
{item.value.title}
149
+
</p>
150
+
)}
151
+
152
+
{item.value?.description && (
153
+
<p class="text-gray-600 dark:text-gray-400 mb-3">
154
+
{truncateText(item.value.description, 150)}
155
+
</p>
156
+
)}
157
+
</div>
158
+
159
+
<footer class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
160
+
<span>URI: {item.uri.substring(0, 50)}...</span>
161
+
<span>CID: {item.cid.substring(0, 10)}...</span>
162
+
</footer>
163
+
</article>
164
+
))}
165
+
</div>
166
+
) : (
167
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8">
168
+
<h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4">
169
+
No Content Found
170
+
</h3>
171
+
<p class="text-yellow-700 dark:text-yellow-300 mb-4">
172
+
No content was found for your account. This could mean:
173
+
</p>
174
+
<ul class="text-yellow-600 dark:text-yellow-400 text-sm list-disc list-inside space-y-1">
175
+
<li>You haven't posted any content yet</li>
176
+
<li>The content is in a different collection</li>
177
+
<li>There's an issue with the API connection</li>
178
+
<li>The content format isn't recognized</li>
179
+
</ul>
180
+
</div>
181
+
)}
182
+
</div>
183
+
</div>
184
+
) : (
185
+
<div class="text-center py-12">
186
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8">
187
+
<h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4">
188
+
Configuration Required
189
+
</h3>
190
+
<p class="text-yellow-700 dark:text-yellow-300 mb-4">
191
+
To view your content feed, please configure your Bluesky handle in the environment variables.
192
+
</p>
193
+
<div class="text-sm text-yellow-600 dark:text-yellow-400">
194
+
<p class="mb-2">Create a <code class="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">.env</code> file with:</p>
195
+
<pre class="bg-yellow-100 dark:bg-yellow-800 p-3 rounded text-xs overflow-x-auto">
196
+
ATPROTO_HANDLE=your-handle.bsky.social
197
+
SITE_TITLE=Your Site Title
198
+
SITE_AUTHOR=Your Name</pre>
199
+
</div>
200
+
</div>
201
+
</div>
202
+
)}
203
+
</main>
204
+
</div>
205
+
</Layout>
206
+
207
+
<script>
208
+
// Real-time updates (client-side)
209
+
if (typeof window !== 'undefined') {
210
+
// This would be implemented with WebSocket or Server-Sent Events
211
+
// For now, we'll just update the streaming status
212
+
const streamingStatus = document.getElementById('streaming-status');
213
+
if (streamingStatus) {
214
+
streamingStatus.textContent = 'Streaming';
215
+
streamingStatus.className = 'text-sm text-green-600 dark:text-green-400';
216
+
}
217
+
}
218
+
</script>
+163
src/pages/content-test.astro
+163
src/pages/content-test.astro
···
1
+
---
2
+
import Layout from '../layouts/Layout.astro';
3
+
import { ContentRenderer, type RenderedContent } from '../lib/services/content-renderer';
4
+
import { loadConfig } from '../lib/config/site';
5
+
6
+
const config = loadConfig();
7
+
const contentRenderer = new ContentRenderer();
8
+
9
+
let content: RenderedContent[] = [];
10
+
let contentTypes: string[] = [];
11
+
let error: unknown = null;
12
+
13
+
try {
14
+
if (config.atproto.handle && config.atproto.handle !== 'your-handle-here') {
15
+
// Get available content types
16
+
contentTypes = await contentRenderer.getContentTypes(config.atproto.handle);
17
+
18
+
// Render content with gallery filter
19
+
content = await contentRenderer.renderContent(config.atproto.handle, {
20
+
limit: 20,
21
+
filter: (record) => {
22
+
const $type = record.value?.$type;
23
+
return $type?.includes('gallery') ||
24
+
$type?.includes('grain') ||
25
+
$type?.includes('image');
26
+
}
27
+
});
28
+
}
29
+
} catch (err) {
30
+
error = err;
31
+
console.error('Content test: Error rendering content:', err);
32
+
}
33
+
---
34
+
35
+
<Layout title="Content Rendering Test">
36
+
<div class="container mx-auto px-4 py-8">
37
+
<header class="text-center mb-12">
38
+
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
39
+
Content Rendering Test
40
+
</h1>
41
+
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
42
+
Testing the content rendering system with type-safe components
43
+
</p>
44
+
</header>
45
+
46
+
<main class="max-w-6xl mx-auto">
47
+
{error ? (
48
+
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8">
49
+
<h3 class="text-xl font-semibold text-red-800 dark:text-red-200 mb-4">
50
+
Error Loading Content
51
+
</h3>
52
+
<p class="text-red-700 dark:text-red-300 mb-4">
53
+
{error instanceof Error ? error.message : String(error)}
54
+
</p>
55
+
<pre class="bg-red-100 dark:bg-red-800 p-3 rounded text-xs overflow-x-auto">
56
+
{JSON.stringify(error, null, 2)}
57
+
</pre>
58
+
</div>
59
+
) : config.atproto.handle && config.atproto.handle !== 'your-handle-here' ? (
60
+
<div class="space-y-8">
61
+
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
62
+
<h3 class="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-2">
63
+
Configuration
64
+
</h3>
65
+
<div class="text-sm text-blue-700 dark:text-blue-300">
66
+
<p><strong>Handle:</strong> {config.atproto.handle}</p>
67
+
<p><strong>DID:</strong> {config.atproto.did}</p>
68
+
<p><strong>PDS URL:</strong> {config.atproto.pdsUrl}</p>
69
+
</div>
70
+
</div>
71
+
72
+
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6">
73
+
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200 mb-2">
74
+
Content Types Found
75
+
</h3>
76
+
<div class="text-sm text-green-700 dark:text-green-300">
77
+
<p><strong>Total Types:</strong> {contentTypes.length}</p>
78
+
{contentTypes.length > 0 && (
79
+
<div class="mt-2">
80
+
<p class="font-semibold">Types:</p>
81
+
<ul class="list-disc list-inside space-y-1">
82
+
{contentTypes.map((type) => (
83
+
<li class="bg-green-100 dark:bg-green-800 px-2 py-1 rounded text-xs">
84
+
{type}
85
+
</li>
86
+
))}
87
+
</ul>
88
+
</div>
89
+
)}
90
+
</div>
91
+
</div>
92
+
93
+
<div class="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-6">
94
+
<h3 class="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-2">
95
+
Rendered Content
96
+
</h3>
97
+
<div class="text-sm text-purple-700 dark:text-purple-300">
98
+
<p><strong>Content Items:</strong> {content.length}</p>
99
+
{content.length > 0 && (
100
+
<div class="mt-2">
101
+
<p class="font-semibold">Items:</p>
102
+
<ul class="space-y-2">
103
+
{content.map((item, index) => (
104
+
<li class="bg-purple-100 dark:bg-purple-800 p-3 rounded">
105
+
<div class="flex justify-between items-start">
106
+
<div>
107
+
<p class="font-semibold">Item {index + 1}</p>
108
+
<p class="text-xs">Type: {item.type}</p>
109
+
<p class="text-xs">Component: {item.component}</p>
110
+
<p class="text-xs">$type: {item.metadata.$type}</p>
111
+
</div>
112
+
<div class="text-right text-xs">
113
+
<p>Collection: {item.metadata.collection}</p>
114
+
<p>Created: {new Date(item.metadata.createdAt).toLocaleDateString()}</p>
115
+
</div>
116
+
</div>
117
+
</li>
118
+
))}
119
+
</ul>
120
+
</div>
121
+
)}
122
+
</div>
123
+
</div>
124
+
125
+
{content.length === 0 && (
126
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8">
127
+
<h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4">
128
+
No Gallery Content Found
129
+
</h3>
130
+
<p class="text-yellow-700 dark:text-yellow-300 mb-4">
131
+
No gallery-related content was found for your account. This could mean:
132
+
</p>
133
+
<ul class="text-yellow-600 dark:text-yellow-400 text-sm list-disc list-inside space-y-1">
134
+
<li>You haven't created any galleries yet</li>
135
+
<li>The galleries are in a different collection</li>
136
+
<li>There's an issue with the API connection</li>
137
+
<li>The gallery format isn't recognized</li>
138
+
</ul>
139
+
</div>
140
+
)}
141
+
</div>
142
+
) : (
143
+
<div class="text-center py-12">
144
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8">
145
+
<h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4">
146
+
Configuration Required
147
+
</h3>
148
+
<p class="text-yellow-700 dark:text-yellow-300 mb-4">
149
+
To test the content rendering system, please configure your Bluesky handle in the environment variables.
150
+
</p>
151
+
<div class="text-sm text-yellow-600 dark:text-yellow-400">
152
+
<p class="mb-2">Create a <code class="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">.env</code> file with:</p>
153
+
<pre class="bg-yellow-100 dark:bg-yellow-800 p-3 rounded text-xs overflow-x-auto">
154
+
ATPROTO_HANDLE=your-handle.bsky.social
155
+
SITE_TITLE=Your Site Title
156
+
SITE_AUTHOR=Your Name</pre>
157
+
</div>
158
+
</div>
159
+
</div>
160
+
)}
161
+
</main>
162
+
</div>
163
+
</Layout>
+202
src/pages/discovery-test.astro
+202
src/pages/discovery-test.astro
···
1
+
---
2
+
import Layout from '../layouts/Layout.astro';
3
+
import { CollectionDiscovery } from '../lib/build/collection-discovery';
4
+
import { DiscoveredComponentRegistry } from '../lib/components/discovered-registry';
5
+
import { loadConfig } from '../lib/config/site';
6
+
7
+
const config = loadConfig();
8
+
const discovery = new CollectionDiscovery();
9
+
const registry = new DiscoveredComponentRegistry();
10
+
11
+
let discoveryResults = null;
12
+
let error = null;
13
+
14
+
try {
15
+
if (config.atproto.handle && config.atproto.handle !== 'your-handle-here') {
16
+
// Run collection discovery
17
+
discoveryResults = await discovery.discoverCollections(config.atproto.handle);
18
+
19
+
// Update component registry with discovered types
20
+
const allTypes = discoveryResults.collections.flatMap(c => c.$types);
21
+
registry.updateDiscoveredTypes(allTypes);
22
+
}
23
+
} catch (err) {
24
+
error = err;
25
+
console.error('Discovery test error:', err);
26
+
}
27
+
28
+
// Helper function to format date
29
+
const formatDate = (dateString: string) => {
30
+
return new Date(dateString).toLocaleDateString('en-US', {
31
+
year: 'numeric',
32
+
month: 'long',
33
+
day: 'numeric',
34
+
hour: '2-digit',
35
+
minute: '2-digit'
36
+
});
37
+
};
38
+
---
39
+
40
+
<Layout title="Discovery Test">
41
+
<div class="container mx-auto px-4 py-8">
42
+
<header class="text-center mb-12">
43
+
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
44
+
Build-Time Discovery Test
45
+
</h1>
46
+
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
47
+
Testing collection discovery and type generation
48
+
</p>
49
+
</header>
50
+
51
+
<main class="max-w-6xl mx-auto">
52
+
{error ? (
53
+
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8">
54
+
<h3 class="text-xl font-semibold text-red-800 dark:text-red-200 mb-4">
55
+
Discovery Error
56
+
</h3>
57
+
<p class="text-red-700 dark:text-red-300 mb-4">
58
+
{error instanceof Error ? error.message : String(error)}
59
+
</p>
60
+
<pre class="bg-red-100 dark:bg-red-800 p-3 rounded text-xs overflow-x-auto">
61
+
{JSON.stringify(error, null, 2)}
62
+
</pre>
63
+
</div>
64
+
) : discoveryResults ? (
65
+
<div class="space-y-8">
66
+
<!-- Discovery Summary -->
67
+
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
68
+
<h3 class="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-2">
69
+
Discovery Summary
70
+
</h3>
71
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-blue-700 dark:text-blue-300">
72
+
<div>
73
+
<p class="font-semibold">Collections</p>
74
+
<p class="text-2xl">{discoveryResults.totalCollections}</p>
75
+
</div>
76
+
<div>
77
+
<p class="font-semibold">Records</p>
78
+
<p class="text-2xl">{discoveryResults.totalRecords}</p>
79
+
</div>
80
+
<div>
81
+
<p class="font-semibold">Repository</p>
82
+
<p class="text-sm">{discoveryResults.repository.handle}</p>
83
+
</div>
84
+
<div>
85
+
<p class="font-semibold">Generated</p>
86
+
<p class="text-sm">{formatDate(discoveryResults.generatedAt)}</p>
87
+
</div>
88
+
</div>
89
+
</div>
90
+
91
+
<!-- Discovered Collections -->
92
+
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6">
93
+
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200 mb-2">
94
+
Discovered Collections
95
+
</h3>
96
+
<div class="space-y-4">
97
+
{discoveryResults.collections.map((collection, index) => (
98
+
<div class="bg-green-100 dark:bg-green-800 p-4 rounded">
99
+
<div class="flex items-start justify-between mb-2">
100
+
<div>
101
+
<h4 class="font-semibold text-green-800 dark:text-green-200">
102
+
{collection.name}
103
+
</h4>
104
+
<p class="text-sm text-green-600 dark:text-green-400">
105
+
{collection.description}
106
+
</p>
107
+
</div>
108
+
<span class="bg-green-200 dark:bg-green-700 text-green-800 dark:text-green-200 px-2 py-1 rounded text-xs">
109
+
{collection.service}
110
+
</span>
111
+
</div>
112
+
113
+
<div class="mb-3">
114
+
<p class="text-sm text-green-600 dark:text-green-400 mb-1">
115
+
Types: {collection.$types.length}
116
+
</p>
117
+
<div class="flex flex-wrap gap-1">
118
+
{collection.$types.map($type => (
119
+
<span class="bg-green-200 dark:bg-green-700 text-green-800 dark:text-green-200 px-2 py-1 rounded text-xs">
120
+
{$type}
121
+
</span>
122
+
))}
123
+
</div>
124
+
</div>
125
+
126
+
<details class="text-sm">
127
+
<summary class="cursor-pointer text-green-600 dark:text-green-400">
128
+
View Generated Types
129
+
</summary>
130
+
<pre class="bg-green-200 dark:bg-green-700 p-3 rounded text-xs overflow-x-auto mt-2">
131
+
{collection.generatedTypes}
132
+
</pre>
133
+
</details>
134
+
</div>
135
+
))}
136
+
</div>
137
+
</div>
138
+
139
+
<!-- Component Registry -->
140
+
<div class="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-6">
141
+
<h3 class="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-2">
142
+
Component Registry
143
+
</h3>
144
+
<div class="text-sm text-purple-700 dark:text-purple-300">
145
+
<p><strong>Registered Types:</strong> {registry.getRegisteredTypes().length}</p>
146
+
<div class="mt-2">
147
+
<p class="font-semibold">Component Mappings:</p>
148
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 mt-2">
149
+
{registry.getRegisteredTypes().map($type => {
150
+
const component = registry.getComponent($type);
151
+
return (
152
+
<div class="bg-purple-100 dark:bg-purple-800 p-2 rounded text-xs">
153
+
<span class="font-semibold">{$type}</span>
154
+
<br />
155
+
<span class="text-purple-600 dark:text-purple-400">
156
+
→ {component?.component || 'No component'}
157
+
</span>
158
+
</div>
159
+
);
160
+
})}
161
+
</div>
162
+
</div>
163
+
</div>
164
+
</div>
165
+
166
+
<!-- Raw Discovery Data -->
167
+
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-6">
168
+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
169
+
Raw Discovery Data
170
+
</h3>
171
+
<div class="text-sm text-gray-600 dark:text-gray-400">
172
+
<details>
173
+
<summary class="cursor-pointer">View Raw Data</summary>
174
+
<pre class="bg-gray-100 dark:bg-gray-700 p-3 rounded text-xs overflow-x-auto mt-2">
175
+
{JSON.stringify(discoveryResults, null, 2)}
176
+
</pre>
177
+
</details>
178
+
</div>
179
+
</div>
180
+
</div>
181
+
) : (
182
+
<div class="text-center py-12">
183
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8">
184
+
<h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4">
185
+
Configuration Required
186
+
</h3>
187
+
<p class="text-yellow-700 dark:text-yellow-300 mb-4">
188
+
To test collection discovery, please configure your Bluesky handle in the environment variables.
189
+
</p>
190
+
<div class="text-sm text-yellow-600 dark:text-yellow-400">
191
+
<p class="mb-2">Create a <code class="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">.env</code> file with:</p>
192
+
<pre class="bg-yellow-100 dark:bg-yellow-800 p-3 rounded text-xs overflow-x-auto">
193
+
ATPROTO_HANDLE=your-handle.bsky.social
194
+
SITE_TITLE=Your Site Title
195
+
SITE_AUTHOR=Your Name</pre>
196
+
</div>
197
+
</div>
198
+
</div>
199
+
)}
200
+
</main>
201
+
</div>
202
+
</Layout>
+165
src/pages/galleries-unified.astro
+165
src/pages/galleries-unified.astro
···
1
+
---
2
+
import Layout from '../layouts/Layout.astro';
3
+
import GrainGalleryDisplay from '../components/content/GrainGalleryDisplay.astro';
4
+
import { ContentSystem } from '../lib/services/content-system';
5
+
import { loadConfig } from '../lib/config/site';
6
+
7
+
const config = loadConfig();
8
+
const contentSystem = new ContentSystem();
9
+
10
+
let galleries = [];
11
+
let contentFeed = null;
12
+
let error = null;
13
+
14
+
try {
15
+
if (config.atproto.handle && config.atproto.handle !== 'your-handle-here') {
16
+
// Initialize content system
17
+
contentFeed = await contentSystem.initialize(config.atproto.handle, {
18
+
enableStreaming: true,
19
+
maxItems: 500
20
+
});
21
+
22
+
// Get galleries using the specialized service
23
+
galleries = await contentSystem.getGalleries(config.atproto.handle);
24
+
}
25
+
} catch (err) {
26
+
error = err;
27
+
console.error('Unified galleries: Error:', err);
28
+
}
29
+
30
+
// Helper function to format date
31
+
const formatDate = (dateString: string) => {
32
+
return new Date(dateString).toLocaleDateString('en-US', {
33
+
year: 'numeric',
34
+
month: 'long',
35
+
day: 'numeric',
36
+
});
37
+
};
38
+
---
39
+
40
+
<Layout title="Unified Galleries">
41
+
<div class="container mx-auto px-4 py-8">
42
+
<header class="text-center mb-12">
43
+
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
44
+
Unified Galleries
45
+
</h1>
46
+
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
47
+
Your Grain.social galleries with real-time updates
48
+
</p>
49
+
</header>
50
+
51
+
<main class="max-w-6xl mx-auto">
52
+
{error ? (
53
+
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8">
54
+
<h3 class="text-xl font-semibold text-red-800 dark:text-red-200 mb-4">
55
+
Error Loading Galleries
56
+
</h3>
57
+
<p class="text-red-700 dark:text-red-300 mb-4">
58
+
{error instanceof Error ? error.message : String(error)}
59
+
</p>
60
+
</div>
61
+
) : contentFeed ? (
62
+
<div class="space-y-8">
63
+
<!-- Content System Stats -->
64
+
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
65
+
<h3 class="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-2">
66
+
Content System Stats
67
+
</h3>
68
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-blue-700 dark:text-blue-300">
69
+
<div>
70
+
<p class="font-semibold">Total Content Items</p>
71
+
<p class="text-2xl">{contentFeed.totalItems}</p>
72
+
</div>
73
+
<div>
74
+
<p class="font-semibold">Collections</p>
75
+
<p class="text-2xl">{contentFeed.collections.length}</p>
76
+
</div>
77
+
<div>
78
+
<p class="font-semibold">Galleries Found</p>
79
+
<p class="text-2xl">{galleries.length}</p>
80
+
</div>
81
+
<div>
82
+
<p class="font-semibold">Last Updated</p>
83
+
<p class="text-sm">{formatDate(contentFeed.lastUpdated)}</p>
84
+
</div>
85
+
</div>
86
+
</div>
87
+
88
+
<!-- Gallery Content -->
89
+
<div class="space-y-4">
90
+
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
91
+
Your Galleries ({galleries.length} galleries)
92
+
</h2>
93
+
94
+
{galleries.length > 0 ? (
95
+
<div class="space-y-8">
96
+
{galleries.map((gallery) => (
97
+
<GrainGalleryDisplay
98
+
gallery={gallery}
99
+
showDescription={true}
100
+
showTimestamp={true}
101
+
showCollections={true}
102
+
columns={3}
103
+
/>
104
+
))}
105
+
</div>
106
+
) : (
107
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8">
108
+
<h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4">
109
+
No Galleries Found
110
+
</h3>
111
+
<p class="text-yellow-700 dark:text-yellow-300 mb-4">
112
+
No Grain.social galleries were found for your account. This could mean:
113
+
</p>
114
+
<ul class="text-yellow-600 dark:text-yellow-400 text-sm list-disc list-inside space-y-1">
115
+
<li>You haven't created any galleries yet</li>
116
+
<li>The galleries are in a different collection</li>
117
+
<li>There's an issue with the API connection</li>
118
+
<li>The gallery format isn't recognized</li>
119
+
<li>The gallery grouping logic needs adjustment</li>
120
+
</ul>
121
+
</div>
122
+
)}
123
+
</div>
124
+
125
+
<!-- Raw Content Debug -->
126
+
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-6">
127
+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
128
+
Raw Content Debug
129
+
</h3>
130
+
<div class="text-sm text-gray-600 dark:text-gray-400">
131
+
<p><strong>Total Content Items:</strong> {contentFeed.items.length}</p>
132
+
<p><strong>Collections:</strong> {contentFeed.collections.join(', ')}</p>
133
+
<p><strong>Content Types Found:</strong></p>
134
+
<ul class="list-disc list-inside mt-2">
135
+
{Array.from(new Set(contentFeed.items.map(item => item.$type))).map($type => (
136
+
<li class="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-xs">
137
+
{$type}
138
+
</li>
139
+
))}
140
+
</ul>
141
+
</div>
142
+
</div>
143
+
</div>
144
+
) : (
145
+
<div class="text-center py-12">
146
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8">
147
+
<h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4">
148
+
Configuration Required
149
+
</h3>
150
+
<p class="text-yellow-700 dark:text-yellow-300 mb-4">
151
+
To view your galleries, please configure your Bluesky handle in the environment variables.
152
+
</p>
153
+
<div class="text-sm text-yellow-600 dark:text-yellow-400">
154
+
<p class="mb-2">Create a <code class="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">.env</code> file with:</p>
155
+
<pre class="bg-yellow-100 dark:bg-yellow-800 p-3 rounded text-xs overflow-x-auto">
156
+
ATPROTO_HANDLE=your-handle.bsky.social
157
+
SITE_TITLE=Your Site Title
158
+
SITE_AUTHOR=Your Name</pre>
159
+
</div>
160
+
</div>
161
+
</div>
162
+
)}
163
+
</main>
164
+
</div>
165
+
</Layout>
+15
-74
src/pages/galleries.astro
+15
-74
src/pages/galleries.astro
···
1
1
---
2
2
import Layout from '../layouts/Layout.astro';
3
-
import GrainImageGallery from '../components/content/GrainImageGallery.astro';
4
-
import { ATprotoDiscovery } from '../lib/atproto/discovery';
3
+
import GrainGalleryDisplay from '../components/content/GrainGalleryDisplay.astro';
4
+
import { GrainGalleryService, type ProcessedGrainGallery } from '../lib/services/grain-gallery-service';
5
5
import { loadConfig } from '../lib/config/site';
6
-
import type { AtprotoRecord } from '../lib/types/atproto';
7
6
8
7
const config = loadConfig();
9
-
const discovery = new ATprotoDiscovery(config.atproto.pdsUrl);
8
+
const grainGalleryService = new GrainGalleryService();
10
9
11
-
// Fetch all records and filter for galleries
12
-
let galleries: AtprotoRecord[] = [];
10
+
// Fetch galleries using the Grain.social service
11
+
let galleries: ProcessedGrainGallery[] = [];
13
12
try {
14
13
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
-
});
14
+
galleries = await grainGalleryService.getGalleries(config.atproto.handle);
34
15
}
35
16
} catch (error) {
36
17
console.error('Galleries page: Error fetching galleries:', error);
···
53
34
{config.atproto.handle && config.atproto.handle !== 'your-handle-here' ? (
54
35
galleries.length > 0 ? (
55
36
<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
-
})}
37
+
{galleries.map((gallery) => (
38
+
<GrainGalleryDisplay
39
+
gallery={gallery}
40
+
showDescription={true}
41
+
showTimestamp={true}
42
+
showCollections={true}
43
+
columns={3}
44
+
/>
45
+
))}
105
46
</div>
106
47
) : (
107
48
<div class="text-center py-12">
+148
src/pages/gallery-debug.astro
+148
src/pages/gallery-debug.astro
···
1
+
---
2
+
import Layout from '../layouts/Layout.astro';
3
+
import { AtprotoBrowser } from '../lib/atproto/atproto-browser';
4
+
import { loadConfig } from '../lib/config/site';
5
+
6
+
const config = loadConfig();
7
+
const browser = new AtprotoBrowser();
8
+
9
+
let repoInfo = null;
10
+
let collections = [];
11
+
let grainRecords = [];
12
+
let error = null;
13
+
14
+
try {
15
+
if (config.atproto.handle && config.atproto.handle !== 'your-handle-here') {
16
+
// Get repository info
17
+
repoInfo = await browser.getRepoInfo(config.atproto.handle);
18
+
19
+
if (repoInfo) {
20
+
// Get all collections
21
+
collections = repoInfo.collections;
22
+
23
+
// Get records from grain-related collections
24
+
const grainCollections = collections.filter(col =>
25
+
col.includes('grain') || col.includes('social.grain')
26
+
);
27
+
28
+
for (const collection of grainCollections) {
29
+
const records = await browser.getCollectionRecords(config.atproto.handle, collection, 50);
30
+
if (records && records.records) {
31
+
grainRecords.push({
32
+
collection,
33
+
records: records.records
34
+
});
35
+
}
36
+
}
37
+
}
38
+
}
39
+
} catch (err) {
40
+
error = err;
41
+
console.error('Gallery debug: Error:', err);
42
+
}
43
+
---
44
+
45
+
<Layout title="Gallery Debug">
46
+
<div class="container mx-auto px-4 py-8">
47
+
<header class="text-center mb-12">
48
+
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
49
+
Gallery Structure Debug
50
+
</h1>
51
+
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
52
+
Examining Grain.social collection structure and relationships
53
+
</p>
54
+
</header>
55
+
56
+
<main class="max-w-6xl mx-auto space-y-8">
57
+
{error ? (
58
+
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8">
59
+
<h3 class="text-xl font-semibold text-red-800 dark:text-red-200 mb-4">
60
+
Error
61
+
</h3>
62
+
<p class="text-red-700 dark:text-red-300 mb-4">
63
+
{error instanceof Error ? error.message : String(error)}
64
+
</p>
65
+
</div>
66
+
) : repoInfo ? (
67
+
<>
68
+
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
69
+
<h3 class="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-2">
70
+
Repository Info
71
+
</h3>
72
+
<div class="text-sm text-blue-700 dark:text-blue-300">
73
+
<p><strong>Handle:</strong> {repoInfo.handle}</p>
74
+
<p><strong>DID:</strong> {repoInfo.did}</p>
75
+
<p><strong>Total Collections:</strong> {collections.length}</p>
76
+
<p><strong>Total Records:</strong> {repoInfo.recordCount}</p>
77
+
</div>
78
+
</div>
79
+
80
+
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6">
81
+
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200 mb-2">
82
+
All Collections
83
+
</h3>
84
+
<div class="text-sm text-green-700 dark:text-green-300">
85
+
<ul class="list-disc list-inside space-y-1">
86
+
{collections.map((collection) => (
87
+
<li class="bg-green-100 dark:bg-green-800 px-2 py-1 rounded">
88
+
{collection}
89
+
</li>
90
+
))}
91
+
</ul>
92
+
</div>
93
+
</div>
94
+
95
+
<div class="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-6">
96
+
<h3 class="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-2">
97
+
Grain.social Records
98
+
</h3>
99
+
<div class="text-sm text-purple-700 dark:text-purple-300">
100
+
{grainRecords.map(({ collection, records }) => (
101
+
<div class="mb-6">
102
+
<h4 class="font-semibold mb-2">{collection} ({records.length} records)</h4>
103
+
<div class="space-y-2">
104
+
{records.slice(0, 5).map((record, index) => (
105
+
<div class="bg-purple-100 dark:bg-purple-800 p-3 rounded">
106
+
<div class="flex justify-between items-start">
107
+
<div>
108
+
<p class="font-semibold">Record {index + 1}</p>
109
+
<p class="text-xs">URI: {record.uri}</p>
110
+
<p class="text-xs">$type: {record.value?.$type || 'unknown'}</p>
111
+
<p class="text-xs">Created: {record.value?.createdAt || record.indexedAt}</p>
112
+
</div>
113
+
<div class="text-right text-xs">
114
+
<p>CID: {record.cid.substring(0, 10)}...</p>
115
+
</div>
116
+
</div>
117
+
<details class="mt-2">
118
+
<summary class="cursor-pointer text-xs">View Record Data</summary>
119
+
<pre class="text-xs bg-purple-200 dark:bg-purple-700 p-2 rounded mt-1 overflow-x-auto">
120
+
{JSON.stringify(record.value, null, 2)}
121
+
</pre>
122
+
</details>
123
+
</div>
124
+
))}
125
+
{records.length > 5 && (
126
+
<p class="text-xs italic">... and {records.length - 5} more records</p>
127
+
)}
128
+
</div>
129
+
</div>
130
+
))}
131
+
</div>
132
+
</div>
133
+
</>
134
+
) : (
135
+
<div class="text-center py-12">
136
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8">
137
+
<h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4">
138
+
Configuration Required
139
+
</h3>
140
+
<p class="text-yellow-700 dark:text-yellow-300 mb-4">
141
+
To debug the gallery structure, please configure your Bluesky handle in the environment variables.
142
+
</p>
143
+
</div>
144
+
</div>
145
+
)}
146
+
</main>
147
+
</div>
148
+
</Layout>
+159
src/pages/grain-gallery-test.astro
+159
src/pages/grain-gallery-test.astro
···
1
+
---
2
+
import Layout from '../layouts/Layout.astro';
3
+
import GrainGalleryDisplay from '../components/content/GrainGalleryDisplay.astro';
4
+
import { GrainGalleryService, type ProcessedGrainGallery, type GrainGalleryItem } from '../lib/services/grain-gallery-service';
5
+
import { loadConfig } from '../lib/config/site';
6
+
7
+
const config = loadConfig();
8
+
const grainGalleryService = new GrainGalleryService();
9
+
10
+
let galleries: ProcessedGrainGallery[] = [];
11
+
let galleryItems: GrainGalleryItem[] = [];
12
+
let error: unknown = null;
13
+
14
+
try {
15
+
if (config.atproto.handle && config.atproto.handle !== 'your-handle-here') {
16
+
// Get both galleries and raw items for debugging
17
+
galleries = await grainGalleryService.getGalleries(config.atproto.handle);
18
+
galleryItems = await grainGalleryService.getGalleryItems(config.atproto.handle);
19
+
}
20
+
} catch (err) {
21
+
error = err;
22
+
console.error('Grain gallery test: Error:', err);
23
+
}
24
+
---
25
+
26
+
<Layout title="Grain Gallery Test">
27
+
<div class="container mx-auto px-4 py-8">
28
+
<header class="text-center mb-12">
29
+
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
30
+
Grain.social Gallery Test
31
+
</h1>
32
+
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
33
+
Testing the Grain.social gallery grouping and display system
34
+
</p>
35
+
</header>
36
+
37
+
<main class="max-w-6xl mx-auto">
38
+
{error ? (
39
+
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8">
40
+
<h3 class="text-xl font-semibold text-red-800 dark:text-red-200 mb-4">
41
+
Error Loading Galleries
42
+
</h3>
43
+
<p class="text-red-700 dark:text-red-300 mb-4">
44
+
{error instanceof Error ? error.message : String(error)}
45
+
</p>
46
+
<pre class="bg-red-100 dark:bg-red-800 p-3 rounded text-xs overflow-x-auto">
47
+
{JSON.stringify(error, null, 2)}
48
+
</pre>
49
+
</div>
50
+
) : config.atproto.handle && config.atproto.handle !== 'your-handle-here' ? (
51
+
<div class="space-y-8">
52
+
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
53
+
<h3 class="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-2">
54
+
Configuration
55
+
</h3>
56
+
<div class="text-sm text-blue-700 dark:text-blue-300">
57
+
<p><strong>Handle:</strong> {config.atproto.handle}</p>
58
+
<p><strong>DID:</strong> {config.atproto.did}</p>
59
+
<p><strong>PDS URL:</strong> {config.atproto.pdsUrl}</p>
60
+
</div>
61
+
</div>
62
+
63
+
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6">
64
+
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200 mb-2">
65
+
Results
66
+
</h3>
67
+
<div class="text-sm text-green-700 dark:text-green-300">
68
+
<p><strong>Raw Gallery Items:</strong> {galleryItems.length}</p>
69
+
<p><strong>Grouped Galleries:</strong> {galleries.length}</p>
70
+
</div>
71
+
</div>
72
+
73
+
{galleryItems.length > 0 && (
74
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
75
+
<h3 class="text-lg font-semibold text-yellow-800 dark:text-yellow-200 mb-2">
76
+
Raw Gallery Items (First 10)
77
+
</h3>
78
+
<div class="text-sm text-yellow-700 dark:text-yellow-300 space-y-2">
79
+
{galleryItems.slice(0, 10).map((item, index) => (
80
+
<div class="bg-yellow-100 dark:bg-yellow-800 p-3 rounded">
81
+
<div class="flex justify-between items-start">
82
+
<div>
83
+
<p class="font-semibold">Item {index + 1}</p>
84
+
<p class="text-xs">Collection: {item.collection}</p>
85
+
<p class="text-xs">$type: {item.value?.$type || 'unknown'}</p>
86
+
<p class="text-xs">Title: {item.value?.title || 'No title'}</p>
87
+
<p class="text-xs">Gallery ID: {item.value?.galleryId || item.value?.gallery_id || item.value?.id || 'None'}</p>
88
+
</div>
89
+
<div class="text-right text-xs">
90
+
<p>Created: {item.value?.createdAt || item.indexedAt}</p>
91
+
</div>
92
+
</div>
93
+
<details class="mt-2">
94
+
<summary class="cursor-pointer text-xs">View Item Data</summary>
95
+
<pre class="text-xs bg-yellow-200 dark:bg-yellow-700 p-2 rounded mt-1 overflow-x-auto">
96
+
{JSON.stringify(item.value, null, 2)}
97
+
</pre>
98
+
</details>
99
+
</div>
100
+
))}
101
+
</div>
102
+
</div>
103
+
)}
104
+
105
+
{galleries.length > 0 ? (
106
+
<div class="space-y-8">
107
+
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
108
+
Grouped Galleries
109
+
</h2>
110
+
{galleries.map((gallery) => (
111
+
<GrainGalleryDisplay
112
+
gallery={gallery}
113
+
showDescription={true}
114
+
showTimestamp={true}
115
+
showCollections={true}
116
+
columns={3}
117
+
/>
118
+
))}
119
+
</div>
120
+
) : (
121
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8">
122
+
<h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4">
123
+
No Galleries Found
124
+
</h3>
125
+
<p class="text-yellow-700 dark:text-yellow-300 mb-4">
126
+
No Grain.social galleries were found for your account. This could mean:
127
+
</p>
128
+
<ul class="text-yellow-600 dark:text-yellow-400 text-sm list-disc list-inside space-y-1">
129
+
<li>You haven't created any galleries yet</li>
130
+
<li>The galleries are in a different collection</li>
131
+
<li>There's an issue with the API connection</li>
132
+
<li>The gallery format isn't recognized</li>
133
+
<li>The gallery grouping logic needs adjustment</li>
134
+
</ul>
135
+
</div>
136
+
)}
137
+
</div>
138
+
) : (
139
+
<div class="text-center py-12">
140
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8">
141
+
<h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4">
142
+
Configuration Required
143
+
</h3>
144
+
<p class="text-yellow-700 dark:text-yellow-300 mb-4">
145
+
To test the Grain.social gallery system, please configure your Bluesky handle in the environment variables.
146
+
</p>
147
+
<div class="text-sm text-yellow-600 dark:text-yellow-400">
148
+
<p class="mb-2">Create a <code class="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">.env</code> file with:</p>
149
+
<pre class="bg-yellow-100 dark:bg-yellow-800 p-3 rounded text-xs overflow-x-auto">
150
+
ATPROTO_HANDLE=your-handle.bsky.social
151
+
SITE_TITLE=Your Site Title
152
+
SITE_AUTHOR=Your Name</pre>
153
+
</div>
154
+
</div>
155
+
</div>
156
+
)}
157
+
</main>
158
+
</div>
159
+
</Layout>
+72
src/pages/index.astro
+72
src/pages/index.astro
···
40
40
Explore More
41
41
</h2>
42
42
<div class="grid md:grid-cols-2 gap-6">
43
+
<a href="/content-feed" 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">
44
+
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
45
+
Content Feed
46
+
</h3>
47
+
<p class="text-gray-600 dark:text-gray-400">
48
+
All your ATProto content with real-time updates and streaming.
49
+
</p>
50
+
</a>
43
51
<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">
44
52
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
45
53
Image Galleries
46
54
</h3>
47
55
<p class="text-gray-600 dark:text-gray-400">
48
56
View my grain.social image galleries and photo collections.
57
+
</p>
58
+
</a>
59
+
<a href="/galleries-unified" 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">
60
+
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
61
+
Unified Galleries
62
+
</h3>
63
+
<p class="text-gray-600 dark:text-gray-400">
64
+
Galleries with real-time updates using the content system.
65
+
</p>
66
+
</a>
67
+
<a href="/gallery-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">
68
+
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
69
+
Gallery System Test
70
+
</h3>
71
+
<p class="text-gray-600 dark:text-gray-400">
72
+
Test the gallery service and display components with detailed debugging.
73
+
</p>
74
+
</a>
75
+
<a href="/content-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">
76
+
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
77
+
Content Rendering Test
78
+
</h3>
79
+
<p class="text-gray-600 dark:text-gray-400">
80
+
Test the content rendering system with type-safe components and filters.
81
+
</p>
82
+
</a>
83
+
<a href="/gallery-debug" 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">
84
+
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
85
+
Gallery Structure Debug
86
+
</h3>
87
+
<p class="text-gray-600 dark:text-gray-400">
88
+
Examine Grain.social collection structure and relationships.
89
+
</p>
90
+
</a>
91
+
<a href="/grain-gallery-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">
92
+
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
93
+
Grain Gallery Test
94
+
</h3>
95
+
<p class="text-gray-600 dark:text-gray-400">
96
+
Test the Grain.social gallery grouping and display system.
97
+
</p>
98
+
</a>
99
+
<a href="/api-debug" 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">
100
+
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
101
+
API Debug
102
+
</h3>
103
+
<p class="text-gray-600 dark:text-gray-400">
104
+
Debug ATProto API calls and configuration issues.
105
+
</p>
106
+
</a>
107
+
<a href="/simple-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">
108
+
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
109
+
Simple API Test
110
+
</h3>
111
+
<p class="text-gray-600 dark:text-gray-400">
112
+
Basic HTTP request test to ATProto APIs.
113
+
</p>
114
+
</a>
115
+
<a href="/discovery-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">
116
+
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
117
+
Discovery Test
118
+
</h3>
119
+
<p class="text-gray-600 dark:text-gray-400">
120
+
Build-time collection discovery and type generation.
49
121
</p>
50
122
</a>
51
123
<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">
+99
src/pages/simple-test.astro
+99
src/pages/simple-test.astro
···
1
+
---
2
+
import Layout from '../layouts/Layout.astro';
3
+
import { loadConfig } from '../lib/config/site';
4
+
5
+
const config = loadConfig();
6
+
7
+
// Simple test - just check if we can make a basic HTTP request
8
+
let testResults = {
9
+
config: config,
10
+
basicTest: null,
11
+
error: null
12
+
};
13
+
14
+
try {
15
+
// Test 1: Basic HTTP request to Bluesky API
16
+
const response = await fetch('https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=tynanpurdy.com');
17
+
const data = await response.json();
18
+
19
+
testResults.basicTest = {
20
+
success: response.ok,
21
+
status: response.status,
22
+
data: data
23
+
};
24
+
} catch (error) {
25
+
testResults.error = error;
26
+
console.error('Simple test error:', error);
27
+
}
28
+
---
29
+
30
+
<Layout title="Simple API Test">
31
+
<div class="container mx-auto px-4 py-8">
32
+
<header class="text-center mb-12">
33
+
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
34
+
Simple API Test
35
+
</h1>
36
+
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
37
+
Testing basic HTTP requests to ATProto APIs
38
+
</p>
39
+
</header>
40
+
41
+
<main class="max-w-6xl mx-auto space-y-8">
42
+
<!-- Configuration -->
43
+
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
44
+
<h3 class="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-2">
45
+
Configuration
46
+
</h3>
47
+
<div class="text-sm text-blue-700 dark:text-blue-300">
48
+
<pre class="bg-blue-100 dark:bg-blue-800 p-3 rounded text-xs overflow-x-auto">
49
+
{JSON.stringify(testResults.config, null, 2)}
50
+
</pre>
51
+
</div>
52
+
</div>
53
+
54
+
{testResults.error ? (
55
+
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8">
56
+
<h3 class="text-xl font-semibold text-red-800 dark:text-red-200 mb-4">
57
+
Error
58
+
</h3>
59
+
<p class="text-red-700 dark:text-red-300 mb-4">
60
+
{testResults.error instanceof Error ? testResults.error.message : String(testResults.error)}
61
+
</p>
62
+
<pre class="bg-red-100 dark:bg-red-800 p-3 rounded text-xs overflow-x-auto">
63
+
{JSON.stringify(testResults.error, null, 2)}
64
+
</pre>
65
+
</div>
66
+
) : testResults.basicTest ? (
67
+
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6">
68
+
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200 mb-2">
69
+
Basic API Test
70
+
</h3>
71
+
<div class="text-sm text-green-700 dark:text-green-300">
72
+
<div class="flex items-center gap-2 mb-2">
73
+
<span class={`px-2 py-1 rounded text-xs ${
74
+
testResults.basicTest.success
75
+
? 'bg-green-200 text-green-800 dark:bg-green-700 dark:text-green-200'
76
+
: 'bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200'
77
+
}`}>
78
+
{testResults.basicTest.success ? '✅ Success' : '❌ Failed'}
79
+
</span>
80
+
<span class="text-xs">Status: {testResults.basicTest.status}</span>
81
+
</div>
82
+
<pre class="bg-green-100 dark:bg-green-700 p-3 rounded text-xs overflow-x-auto">
83
+
{JSON.stringify(testResults.basicTest.data, null, 2)}
84
+
</pre>
85
+
</div>
86
+
</div>
87
+
) : (
88
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8">
89
+
<h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4">
90
+
No Test Results
91
+
</h3>
92
+
<p class="text-yellow-700 dark:text-yellow-300">
93
+
No test results available. Check the console for errors.
94
+
</p>
95
+
</div>
96
+
)}
97
+
</main>
98
+
</div>
99
+
</Layout>