+2
-3
scripts/discover-collections.ts
+2
-3
scripts/discover-collections.ts
+5
-7
src/components/content/BlueskyFeed.astro
+5
-7
src/components/content/BlueskyFeed.astro
···
1
1
---
2
-
import { AtprotoClient } from '../../lib/atproto/client';
2
+
import { AtprotoBrowser } from '../../lib/atproto/atproto-browser';
3
3
import { loadConfig } from '../../lib/config/site';
4
4
import BlueskyPost from './BlueskyPost.astro';
5
5
···
13
13
const { feedUri, limit = 10, showAuthor = true, showTimestamp = true } = Astro.props;
14
14
15
15
const config = loadConfig();
16
-
const client = new AtprotoClient(config.atproto.pdsUrl);
16
+
const browser = new AtprotoBrowser();
17
17
18
18
// Fetch feed data with error handling
19
19
let blueskyPosts: any[] = [];
20
20
try {
21
-
const records = await client.getFeed(feedUri, limit);
22
-
const filteredRecords = client.filterSupportedRecords(records);
21
+
const records = await browser.getFeed(feedUri, limit);
22
+
const filteredRecords = records.filter(r => r.value?.$type === 'app.bsky.feed.post');
23
23
24
24
// Only render Bluesky posts
25
-
blueskyPosts = filteredRecords.filter(record =>
26
-
record.value && record.value.$type === 'app.bsky.feed.post'
27
-
);
25
+
blueskyPosts = filteredRecords;
28
26
} catch (error) {
29
27
console.error('Error fetching feed:', error);
30
28
blueskyPosts = [];
+9
-4
src/components/content/BlueskyPost.astro
+9
-4
src/components/content/BlueskyPost.astro
···
1
1
---
2
2
import type { BlueskyPost } from '../../lib/types/atproto';
3
+
import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob-url';
4
+
import { loadConfig } from '../../lib/config/site';
3
5
4
6
interface Props {
5
7
post: BlueskyPost;
···
22
24
});
23
25
};
24
26
25
-
// Helper function to get image URL from blob reference
26
-
const getImageUrl = (imageRef: string) => {
27
-
return `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=did:plc:6ayddqghxhciedbaofoxkcbs&cid=${imageRef}`;
27
+
const getImageUrl = (ref: unknown) => {
28
+
const cid = extractCidFromBlobRef(ref);
29
+
if (!cid) return '';
30
+
const did = loadConfig().atproto.did;
31
+
if (!did) return '';
32
+
return blobCdnUrl(did, cid);
28
33
};
29
34
30
35
// Helper function to render images
···
34
39
return (
35
40
<div class={`grid gap-2 ${images.length === 1 ? 'grid-cols-1' : images.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>
36
41
{images.map((image: any) => {
37
-
const imageUrl = getImageUrl(image.image.ref.$link);
42
+
const imageUrl = getImageUrl(image.image?.ref);
38
43
return (
39
44
<div class="relative">
40
45
<img
+13
-18
src/components/content/ContentFeed.astro
+13
-18
src/components/content/ContentFeed.astro
···
1
1
---
2
-
import { AtprotoClient } from '../../lib/atproto/client';
2
+
import { AtprotoBrowser } from '../../lib/atproto/atproto-browser';
3
3
import { loadConfig } from '../../lib/config/site';
4
4
import type { AtprotoRecord } from '../../lib/types/atproto';
5
+
import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob';
5
6
6
7
interface Props {
7
8
handle: string;
···
22
23
} = Astro.props;
23
24
24
25
const config = loadConfig();
25
-
const client = new AtprotoClient(config.atproto.pdsUrl);
26
+
const browser = new AtprotoBrowser();
26
27
27
28
// Helper function to get image URL from blob reference
28
-
const getImageUrl = (imageRef: string) => {
29
-
return `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=did:plc:6ayddqghxhciedbaofoxkcbs&cid=${imageRef}`;
29
+
const getImageUrl = (imageRef: unknown) => {
30
+
const cid = extractCidFromBlobRef(imageRef);
31
+
if (!cid) return '';
32
+
const did = config.atproto.did;
33
+
if (!did) return '';
34
+
return blobCdnUrl(did, cid);
30
35
};
31
36
32
37
// Helper function to format date
···
42
47
let records: AtprotoRecord[] = [];
43
48
try {
44
49
if (feedUri) {
45
-
records = await client.getFeed(feedUri, limit);
50
+
records = await browser.getFeed(feedUri, limit);
46
51
} else {
47
-
records = await client.getRecords(handle, collection, limit);
52
+
const res = await browser.getCollectionRecords(handle, collection, limit);
53
+
records = res?.records ?? [];
48
54
}
49
55
50
56
} catch (error) {
···
72
78
{post.embed.$type === 'app.bsky.embed.images' && post.embed.images && (
73
79
<div class={`grid gap-2 ${post.embed.images.length === 1 ? 'grid-cols-1' : post.embed.images.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>
74
80
{post.embed.images.map((image: any) => {
75
-
// Handle both string and BlobRef object formats
76
-
let imageRef;
77
-
if (typeof image.image?.ref === 'string') {
78
-
imageRef = image.image.ref;
79
-
} else if (image.image?.ref?.$link) {
80
-
imageRef = image.image.ref.$link;
81
-
} else if (image.image?.ref?.toString) {
82
-
// Handle BlobRef object
83
-
imageRef = image.image.ref.toString();
84
-
}
85
-
86
-
const imageUrl = imageRef ? getImageUrl(imageRef) : '';
81
+
const imageUrl = getImageUrl(image.image?.ref);
87
82
return (
88
83
<div class="relative">
89
84
<img
+30
-3
src/components/content/GrainGalleryDisplay.astro
+30
-3
src/components/content/GrainGalleryDisplay.astro
···
105
105
class="w-full h-48 object-cover rounded-lg transition-transform duration-200 group-hover:scale-105"
106
106
loading="lazy"
107
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}
108
+
{(image.alt || image.caption || image.exif) && (
109
+
<div class="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs p-2 rounded-b-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 space-y-1">
110
+
{image.alt || image.caption ? (
111
+
<div class="font-medium">{image.alt || image.caption}</div>
112
+
) : null}
113
+
{image.exif && (
114
+
<div class="grid grid-cols-2 gap-x-2 gap-y-0.5">
115
+
{image.exif.make && image.exif.model && (
116
+
<div class="col-span-2">{image.exif.make} {image.exif.model}</div>
117
+
)}
118
+
{image.exif.lensMake && image.exif.lensModel && (
119
+
<div class="col-span-2">{image.exif.lensMake} {image.exif.lensModel}</div>
120
+
)}
121
+
{image.exif.fNumber && (
122
+
<div>ƒ/{image.exif.fNumber}</div>
123
+
)}
124
+
{image.exif.exposureTime && (
125
+
<div>{image.exif.exposureTime}s</div>
126
+
)}
127
+
{image.exif.iSO && (
128
+
<div>ISO {image.exif.iSO}</div>
129
+
)}
130
+
{image.exif.focalLengthIn35mmFormat && (
131
+
<div>{image.exif.focalLengthIn35mmFormat}mm</div>
132
+
)}
133
+
{image.exif.dateTimeOriginal && (
134
+
<div class="col-span-2">{new Date(image.exif.dateTimeOriginal).toLocaleDateString()}</div>
135
+
)}
136
+
</div>
137
+
)}
111
138
</div>
112
139
)}
113
140
</div>
+24
src/lib/atproto/atproto-browser.ts
+24
src/lib/atproto/atproto-browser.ts
···
202
202
return [];
203
203
}
204
204
}
205
+
206
+
// Get posts from an appview feed URI
207
+
async getFeed(feedUri: string, limit: number = 20): Promise<AtprotoRecord[]> {
208
+
try {
209
+
const response = await this.agent.api.app.bsky.feed.getFeed({
210
+
feed: feedUri,
211
+
limit,
212
+
});
213
+
214
+
const records: AtprotoRecord[] = response.data.feed.map((item: any) => ({
215
+
uri: item.post.uri,
216
+
cid: item.post.cid,
217
+
value: item.post.record,
218
+
indexedAt: item.post.indexedAt,
219
+
collection: item.post.uri.split('/')[2] || 'unknown',
220
+
$type: (item.post.record?.$type as string) || 'unknown',
221
+
}));
222
+
223
+
return records;
224
+
} catch (error) {
225
+
console.error('Error fetching feed:', error);
226
+
return [];
227
+
}
228
+
}
205
229
}
-233
src/lib/atproto/client.ts
-233
src/lib/atproto/client.ts
···
1
-
import { AtpAgent } from '@atproto/api';
2
-
import type { AtprotoRecord } from '../types/atproto';
3
-
4
-
// Simple in-memory cache with TTL
5
-
class AtprotoCache {
6
-
private cache = new Map<string, { data: any; timestamp: number }>();
7
-
private ttl = 5 * 60 * 1000; // 5 minutes
8
-
9
-
set(key: string, data: any): void {
10
-
this.cache.set(key, { data, timestamp: Date.now() });
11
-
}
12
-
13
-
get(key: string): any | null {
14
-
const item = this.cache.get(key);
15
-
if (!item) return null;
16
-
17
-
if (Date.now() - item.timestamp > this.ttl) {
18
-
this.cache.delete(key);
19
-
return null;
20
-
}
21
-
22
-
return item.data;
23
-
}
24
-
25
-
clear(): void {
26
-
this.cache.clear();
27
-
}
28
-
}
29
-
30
-
export class AtprotoClient {
31
-
private agent: AtpAgent;
32
-
private cache: AtprotoCache;
33
-
34
-
constructor(pdsUrl: string = 'https://bsky.social') {
35
-
this.agent = new AtpAgent({ service: pdsUrl });
36
-
this.cache = new AtprotoCache();
37
-
}
38
-
39
-
async resolveHandle(handle: string): Promise<string | null> {
40
-
const cacheKey = `handle:${handle}`;
41
-
const cached = this.cache.get(cacheKey);
42
-
if (cached) return cached;
43
-
44
-
try {
45
-
const response = await this.agent.api.com.atproto.identity.resolveHandle({
46
-
handle: handle,
47
-
});
48
-
49
-
const did = response.data.did;
50
-
this.cache.set(cacheKey, did);
51
-
return did;
52
-
} catch (error) {
53
-
console.error('Error resolving handle:', error);
54
-
return null;
55
-
}
56
-
}
57
-
58
-
async getRecords(identifier: string, collection: string, limit: number = 50): Promise<AtprotoRecord[]> {
59
-
// Check if identifier is a handle (contains @) or DID
60
-
let did = identifier;
61
-
if (identifier.includes('@')) {
62
-
console.log('AtprotoClient: Resolving handle to DID:', identifier);
63
-
const resolvedDid = await this.resolveHandle(identifier);
64
-
if (!resolvedDid) {
65
-
console.error('AtprotoClient: Failed to resolve handle:', identifier);
66
-
return [];
67
-
}
68
-
did = resolvedDid;
69
-
console.log('AtprotoClient: Resolved handle to DID:', did);
70
-
}
71
-
72
-
const cacheKey = `records:${did}:${collection}:${limit}`;
73
-
const cached = this.cache.get(cacheKey);
74
-
if (cached) return cached;
75
-
76
-
console.log('AtprotoClient: Fetching records for DID:', did);
77
-
console.log('AtprotoClient: Collection:', collection);
78
-
console.log('AtprotoClient: Limit:', limit);
79
-
80
-
try {
81
-
const response = await this.agent.api.com.atproto.repo.listRecords({
82
-
repo: did,
83
-
collection,
84
-
limit,
85
-
});
86
-
87
-
console.log('AtprotoClient: API response received');
88
-
console.log('AtprotoClient: Records count:', response.data.records.length);
89
-
90
-
const records = response.data.records.map((record: any) => ({
91
-
uri: record.uri,
92
-
cid: record.cid,
93
-
value: record.value,
94
-
indexedAt: record.indexedAt,
95
-
}));
96
-
97
-
this.cache.set(cacheKey, records);
98
-
return records;
99
-
} catch (error) {
100
-
console.error('AtprotoClient: Error fetching records:', error);
101
-
console.error('AtprotoClient: Error details:', {
102
-
did,
103
-
collection,
104
-
limit,
105
-
error: error instanceof Error ? error.message : String(error)
106
-
});
107
-
return [];
108
-
}
109
-
}
110
-
111
-
async getFeed(feedUri: string, limit: number = 20): Promise<AtprotoRecord[]> {
112
-
const cacheKey = `feed:${feedUri}:${limit}`;
113
-
const cached = this.cache.get(cacheKey);
114
-
if (cached) return cached;
115
-
116
-
try {
117
-
const response = await this.agent.api.app.bsky.feed.getFeed({
118
-
feed: feedUri,
119
-
limit,
120
-
});
121
-
122
-
const records = response.data.feed.map((item: any) => ({
123
-
uri: item.post.uri,
124
-
cid: item.post.cid,
125
-
value: item.post.record,
126
-
indexedAt: item.post.indexedAt,
127
-
}));
128
-
129
-
this.cache.set(cacheKey, records);
130
-
return records;
131
-
} catch (error) {
132
-
console.error('Error fetching feed:', error);
133
-
return [];
134
-
}
135
-
}
136
-
137
-
async getProfile(did: string): Promise<any> {
138
-
const cacheKey = `profile:${did}`;
139
-
const cached = this.cache.get(cacheKey);
140
-
if (cached) return cached;
141
-
142
-
try {
143
-
const response = await this.agent.api.app.bsky.actor.getProfile({
144
-
actor: did,
145
-
});
146
-
147
-
this.cache.set(cacheKey, response.data);
148
-
return response.data;
149
-
} catch (error) {
150
-
console.error('Error fetching profile:', error);
151
-
return null;
152
-
}
153
-
}
154
-
155
-
// Filter records by supported content types
156
-
filterSupportedRecords(records: AtprotoRecord[]): AtprotoRecord[] {
157
-
return records.filter(record => {
158
-
const type = record.value?.$type;
159
-
return type && (
160
-
type === 'app.bsky.feed.post' ||
161
-
type === 'app.bsky.actor.profile#whitewindBlogPost' ||
162
-
type === 'app.bsky.actor.profile#leafletPublication' ||
163
-
type === 'app.bsky.actor.profile#grainImageGallery'
164
-
);
165
-
});
166
-
}
167
-
168
-
// Get all records from a repository (using existing API)
169
-
async getAllRecords(handle: string, limit: number = 100): Promise<AtprotoRecord[]> {
170
-
const cacheKey = `all-records:${handle}:${limit}`;
171
-
const cached = this.cache.get(cacheKey);
172
-
if (cached) return cached;
173
-
174
-
try {
175
-
console.log('getAllRecords: Starting with handle:', handle);
176
-
177
-
// Resolve handle to DID
178
-
const did = await this.resolveHandle(handle);
179
-
console.log('getAllRecords: Resolved DID:', did);
180
-
181
-
if (!did) {
182
-
console.error('getAllRecords: Failed to resolve handle to DID');
183
-
return [];
184
-
}
185
-
186
-
// Try to get records from common collections
187
-
console.log('getAllRecords: Trying common collections...');
188
-
const collections = ['app.bsky.feed.post', 'app.bsky.actor.profile'];
189
-
let allRecords: AtprotoRecord[] = [];
190
-
191
-
for (const collection of collections) {
192
-
try {
193
-
console.log(`getAllRecords: Trying collection: ${collection}`);
194
-
const response = await this.agent.api.com.atproto.repo.listRecords({
195
-
repo: did,
196
-
collection,
197
-
limit: Math.floor(limit / collections.length),
198
-
});
199
-
200
-
console.log(`getAllRecords: Got ${response.data.records.length} records from ${collection}`);
201
-
202
-
const records = response.data.records.map((record: any) => ({
203
-
uri: record.uri,
204
-
cid: record.cid,
205
-
value: record.value,
206
-
indexedAt: record.indexedAt,
207
-
}));
208
-
209
-
allRecords = allRecords.concat(records);
210
-
} catch (error) {
211
-
console.log(`getAllRecords: No records in collection ${collection}:`, error);
212
-
}
213
-
}
214
-
215
-
console.log('getAllRecords: Total records found:', allRecords.length);
216
-
this.cache.set(cacheKey, allRecords);
217
-
return allRecords;
218
-
} catch (error) {
219
-
console.error('getAllRecords: Error fetching all records:', error);
220
-
console.error('getAllRecords: Error details:', {
221
-
handle,
222
-
limit,
223
-
error: error instanceof Error ? error.message : String(error)
224
-
});
225
-
return [];
226
-
}
227
-
}
228
-
229
-
// Clear cache (useful for development)
230
-
clearCache(): void {
231
-
this.cache.clear();
232
-
}
233
-
}
+8
-12
src/lib/components/discovered-registry.ts
+8
-12
src/lib/components/discovered-registry.ts
···
1
1
import type { DiscoveredTypes } from '../generated/discovered-types';
2
+
import type { AnyRecordByType } from '../atproto/record-types';
2
3
3
-
export interface DiscoveredComponent {
4
-
$type: DiscoveredTypes;
4
+
export type DiscoveredComponent<T extends string = DiscoveredTypes> = {
5
+
$type: T;
5
6
component: string;
6
-
props: Record<string, any>;
7
+
props: Record<string, unknown>;
7
8
}
8
9
9
-
export interface ComponentRegistry {
10
-
[key: string]: {
11
-
component: string;
12
-
props?: Record<string, any>;
13
-
};
14
-
}
10
+
export type ComponentRegistry = Record<string, { component: string; props?: Record<string, unknown> }>
15
11
16
12
export class DiscoveredComponentRegistry {
17
13
private registry: ComponentRegistry = {};
···
46
42
}
47
43
48
44
// Register a component for a specific $type
49
-
registerComponent($type: DiscoveredTypes, component: string, props?: Record<string, any>): void {
45
+
registerComponent($type: DiscoveredTypes, component: string, props?: Record<string, unknown>): void {
50
46
this.registry[$type] = {
51
47
component,
52
48
props
···
54
50
}
55
51
56
52
// Get component info for a $type
57
-
getComponent($type: DiscoveredTypes): { component: string; props?: Record<string, any> } | null {
53
+
getComponent($type: DiscoveredTypes): { component: string; props?: Record<string, unknown> } | null {
58
54
return this.registry[$type] || null;
59
55
}
60
56
···
104
100
}
105
101
106
102
// Get component info for rendering
107
-
getComponentInfo($type: DiscoveredTypes): DiscoveredComponent | null {
103
+
getComponentInfo<T extends DiscoveredTypes>($type: T): DiscoveredComponent<T> | null {
108
104
const componentInfo = this.getComponent($type);
109
105
if (!componentInfo) return null;
110
106
+124
-120
src/lib/services/grain-gallery-service.ts
+124
-120
src/lib/services/grain-gallery-service.ts
···
1
1
import { AtprotoBrowser } from '../atproto/atproto-browser';
2
2
import { loadConfig } from '../config/site';
3
3
import type { AtprotoRecord } from '../types/atproto';
4
+
import { extractCidFromBlobRef, blobCdnUrl } from '../atproto/blob-url';
4
5
5
6
export interface GrainGalleryItem {
6
7
uri: string;
···
56
57
alt?: string;
57
58
url: string;
58
59
caption?: string;
60
+
exif?: {
61
+
make?: string;
62
+
model?: string;
63
+
lensMake?: string;
64
+
lensModel?: string;
65
+
iSO?: number;
66
+
fNumber?: number;
67
+
exposureTime?: number;
68
+
focalLengthIn35mmFormat?: number;
69
+
dateTimeOriginal?: string;
70
+
};
59
71
}>;
60
72
itemCount: number;
61
73
collections: string[];
···
70
82
this.browser = new AtprotoBrowser();
71
83
}
72
84
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]}`;
85
+
// Resolve gallery URI directly from the item record if present
86
+
private extractGalleryUriFromItem(item: any): string | null {
87
+
const value = item?.value ?? item;
88
+
if (typeof value?.gallery === 'string') return value.gallery;
89
+
// Some variants might use a nested key
90
+
if (typeof value?.galleryUri === 'string') return value.galleryUri;
91
+
return null;
94
92
}
95
93
96
94
// Extract image from gallery item
···
114
112
return null;
115
113
}
116
114
117
-
// Group gallery items into galleries
118
-
private groupGalleryItems(items: GrainGalleryItem[]): GrainGallery[] {
119
-
const galleryGroups = new Map<string, GrainGallery>();
120
-
115
+
// Build processed galleries using the authoritative gallery records and item mappings
116
+
private buildProcessedGalleries(
117
+
galleries: AtprotoRecord[],
118
+
items: AtprotoRecord[],
119
+
photosByUri: Map<string, AtprotoRecord>,
120
+
exifByPhotoUri: Map<string, any>
121
+
): ProcessedGrainGallery[] {
122
+
// Index items by gallery URI
123
+
const itemsByGallery = new Map<string, AtprotoRecord[]>();
121
124
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()
125
+
const galleryUri = this.extractGalleryUriFromItem(item);
126
+
if (!galleryUri) continue;
127
+
const arr = itemsByGallery.get(galleryUri) ?? [];
128
+
arr.push(item);
129
+
itemsByGallery.set(galleryUri, arr);
130
+
}
131
+
132
+
const processed: ProcessedGrainGallery[] = [];
133
+
const did = this.config.atproto.did;
134
+
135
+
for (const gallery of galleries) {
136
+
const galleryUri = gallery.uri;
137
+
const galleryItems = itemsByGallery.get(galleryUri) ?? [];
138
+
// Sort by position if available
139
+
galleryItems.sort((a, b) => {
140
+
const pa = Number(a.value?.position ?? 0);
141
+
const pb = Number(b.value?.position ?? 0);
142
+
return pa - pb;
143
+
});
144
+
145
+
const images: Array<{ alt?: string; url: string; caption?: string; exif?: any }> = [];
146
+
for (const item of galleryItems) {
147
+
const photoUri = typeof item.value?.item === 'string' ? item.value.item : null;
148
+
if (!photoUri) continue;
149
+
const photo = photosByUri.get(photoUri);
150
+
if (!photo) continue;
151
+
152
+
// Extract blob CID
153
+
const cid = extractCidFromBlobRef(photo.value?.photo?.ref ?? photo.value?.photo);
154
+
if (!cid || !did) continue;
155
+
const url = blobCdnUrl(did, cid);
156
+
157
+
const exif = exifByPhotoUri.get(photoUri);
158
+
images.push({
159
+
url,
160
+
alt: photo.value?.alt,
161
+
caption: photo.value?.caption,
162
+
exif: exif ? {
163
+
make: exif.make,
164
+
model: exif.model,
165
+
lensMake: exif.lensMake,
166
+
lensModel: exif.lensModel,
167
+
iSO: exif.iSO,
168
+
fNumber: exif.fNumber,
169
+
exposureTime: exif.exposureTime,
170
+
focalLengthIn35mmFormat: exif.focalLengthIn35mmFormat,
171
+
dateTimeOriginal: exif.dateTimeOriginal,
172
+
} : undefined,
135
173
});
136
174
}
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
-
}
175
+
176
+
processed.push({
177
+
id: galleryUri,
178
+
title: gallery.value?.title || 'Untitled Gallery',
179
+
description: gallery.value?.description,
180
+
createdAt: gallery.value?.createdAt || gallery.indexedAt,
181
+
images,
182
+
itemCount: galleryItems.length,
183
+
collections: [gallery.collection],
184
+
});
148
185
}
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());
186
+
187
+
// Sort galleries by createdAt desc
188
+
processed.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
189
+
return processed;
157
190
}
158
191
159
192
// Process gallery into display format
···
179
212
};
180
213
}
181
214
182
-
// Fetch all gallery items from Grain.social collections
183
-
async getGalleryItems(identifier: string): Promise<GrainGalleryItem[]> {
215
+
// Fetch galleries with items, photos, and exif metadata
216
+
async getGalleries(identifier: string): Promise<ProcessedGrainGallery[]> {
184
217
try {
185
218
const repoInfo = await this.browser.getRepoInfo(identifier);
186
219
if (!repoInfo) {
187
220
throw new Error(`Could not get repository info for: ${identifier}`);
188
221
}
189
222
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
-
}
223
+
// Fetch the four relevant collections
224
+
const [galleries, items, photos, exifs] = await Promise.all([
225
+
this.browser.getCollectionRecords(identifier, 'social.grain.gallery', 100),
226
+
this.browser.getCollectionRecords(identifier, 'social.grain.gallery.item', 100),
227
+
this.browser.getCollectionRecords(identifier, 'social.grain.photo', 100),
228
+
this.browser.getCollectionRecords(identifier, 'social.grain.photo.exif', 100),
229
+
]);
230
+
231
+
const galleryRecords = galleries?.records ?? [];
232
+
const itemRecords = items?.records ?? [];
233
+
const photoRecords = photos?.records ?? [];
234
+
const exifRecords = exifs?.records ?? [];
226
235
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 [];
236
+
// Build maps for fast lookup
237
+
const photosByUri = new Map<string, AtprotoRecord>();
238
+
for (const p of photoRecords) {
239
+
photosByUri.set(p.uri, p);
240
+
}
241
+
const exifByPhotoUri = new Map<string, any>();
242
+
for (const e of exifRecords) {
243
+
const photoUri = typeof e.value?.photo === 'string' ? e.value.photo : undefined;
244
+
if (photoUri) exifByPhotoUri.set(photoUri, e.value);
235
245
}
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;
246
+
247
+
const processed = this.buildProcessedGalleries(
248
+
galleryRecords,
249
+
itemRecords,
250
+
photosByUri,
251
+
exifByPhotoUri,
252
+
);
253
+
254
+
return processed;
253
255
} catch (error) {
254
256
console.error('Error getting galleries:', error);
255
257
return [];
256
258
}
257
259
}
260
+
261
+
// Deprecated older flow kept for compatibility; prefer getGalleries()
258
262
259
263
// Get a specific gallery by ID
260
264
async getGallery(identifier: string, galleryId: string): Promise<ProcessedGrainGallery | null> {
-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>
-176
src/pages/atproto-browser-test.astro
-176
src/pages/atproto-browser-test.astro
···
1
-
---
2
-
import Layout from '../layouts/Layout.astro';
3
-
import { loadConfig } from '../lib/config/site';
4
-
5
-
const config = loadConfig();
6
-
---
7
-
8
-
<Layout title="ATProto Browser Test">
9
-
<div class="container mx-auto px-4 py-8">
10
-
<h1 class="text-4xl font-bold mb-8">ATProto Browser Test</h1>
11
-
12
-
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
13
-
<h2 class="text-2xl font-semibold mb-4">Configuration</h2>
14
-
<p><strong>Handle:</strong> {config.atproto.handle}</p>
15
-
<p><strong>DID:</strong> {config.atproto.did}</p>
16
-
<p class="text-sm text-gray-600 mt-2">Browse ATProto accounts and records like atptools.</p>
17
-
</div>
18
-
19
-
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
20
-
<div class="bg-white border border-gray-200 rounded-lg p-6">
21
-
<h2 class="text-2xl font-semibold mb-4">Browse Account</h2>
22
-
<div class="space-y-4">
23
-
<div>
24
-
<label class="block text-sm font-medium text-gray-700 mb-2">Account (handle or DID)</label>
25
-
<input id="account-input" type="text" value={config.atproto.handle} class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
26
-
</div>
27
-
<button id="browse-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
28
-
Browse Account
29
-
</button>
30
-
</div>
31
-
</div>
32
-
33
-
<div class="bg-white border border-gray-200 rounded-lg p-6">
34
-
<h2 class="text-2xl font-semibold mb-4">Account Info</h2>
35
-
<div id="account-info" class="space-y-2">
36
-
<p class="text-gray-500">Enter an account to browse...</p>
37
-
</div>
38
-
</div>
39
-
</div>
40
-
41
-
<div class="mt-8">
42
-
<h2 class="text-2xl font-semibold mb-4">Collections</h2>
43
-
<div id="collections-container" class="space-y-4">
44
-
<p class="text-gray-500 text-center py-8">No collections loaded...</p>
45
-
</div>
46
-
</div>
47
-
48
-
<div class="mt-8">
49
-
<h2 class="text-2xl font-semibold mb-4">Records</h2>
50
-
<div id="records-container" class="space-y-4">
51
-
<p class="text-gray-500 text-center py-8">No records loaded...</p>
52
-
</div>
53
-
</div>
54
-
</div>
55
-
</Layout>
56
-
57
-
<script>
58
-
import { AtprotoBrowser } from '../lib/atproto/atproto-browser';
59
-
60
-
const browser = new AtprotoBrowser();
61
-
let currentAccount: string | null = null;
62
-
63
-
// DOM elements
64
-
const accountInput = document.getElementById('account-input') as HTMLInputElement;
65
-
const browseBtn = document.getElementById('browse-btn') as HTMLButtonElement;
66
-
const accountInfo = document.getElementById('account-info') as HTMLDivElement;
67
-
const collectionsContainer = document.getElementById('collections-container') as HTMLDivElement;
68
-
const recordsContainer = document.getElementById('records-container') as HTMLDivElement;
69
-
70
-
function displayAccountInfo(repoInfo: any) {
71
-
accountInfo.innerHTML = `
72
-
<div class="space-y-2">
73
-
<div><strong>DID:</strong> <span class="font-mono text-sm">${repoInfo.did}</span></div>
74
-
<div><strong>Handle:</strong> ${repoInfo.handle}</div>
75
-
<div><strong>Collections:</strong> ${repoInfo.collections.length}</div>
76
-
<div><strong>Total Records:</strong> ${repoInfo.recordCount}</div>
77
-
${repoInfo.profile ? `<div><strong>Display Name:</strong> ${repoInfo.profile.displayName || 'N/A'}</div>` : ''}
78
-
</div>
79
-
`;
80
-
}
81
-
82
-
function displayCollections(collections: string[]) {
83
-
if (collections.length === 0) {
84
-
collectionsContainer.innerHTML = '<p class="text-gray-500 text-center py-8">No collections found</p>';
85
-
return;
86
-
}
87
-
88
-
collectionsContainer.innerHTML = `
89
-
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
90
-
${collections.map(collection => `
91
-
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 cursor-pointer hover:bg-gray-100 transition-colors" onclick="loadCollection('${collection}')">
92
-
<h3 class="font-semibold text-gray-900">${collection}</h3>
93
-
<p class="text-sm text-gray-600">Click to view records</p>
94
-
</div>
95
-
`).join('')}
96
-
</div>
97
-
`;
98
-
}
99
-
100
-
async function loadCollection(collection: string) {
101
-
if (!currentAccount) return;
102
-
103
-
try {
104
-
const collectionInfo = await browser.getCollectionRecords(currentAccount, collection, 50);
105
-
if (collectionInfo) {
106
-
displayRecords(collectionInfo.records, collection);
107
-
}
108
-
} catch (error) {
109
-
console.error('Error loading collection:', error);
110
-
recordsContainer.innerHTML = '<p class="text-red-500 text-center py-8">Error loading collection</p>';
111
-
}
112
-
}
113
-
114
-
function displayRecords(records: any[], collection: string) {
115
-
if (records.length === 0) {
116
-
recordsContainer.innerHTML = '<p class="text-gray-500 text-center py-8">No records found in this collection</p>';
117
-
return;
118
-
}
119
-
120
-
recordsContainer.innerHTML = `
121
-
<div class="mb-4">
122
-
<h3 class="text-lg font-semibold">${collection} (${records.length} records)</h3>
123
-
</div>
124
-
<div class="space-y-4">
125
-
${records.map(record => `
126
-
<div class="bg-white border border-gray-200 rounded-lg p-4">
127
-
<div class="flex items-center space-x-2 mb-2">
128
-
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs font-medium">${record.collection}</span>
129
-
<span class="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs font-medium">${record.$type}</span>
130
-
</div>
131
-
${record.value?.text ? `<p class="text-sm text-gray-600 mb-2">${record.value.text}</p>` : ''}
132
-
<p class="text-xs text-gray-500">${new Date(record.indexedAt).toLocaleString()}</p>
133
-
<p class="text-xs font-mono text-gray-400 mt-1 break-all">${record.uri}</p>
134
-
</div>
135
-
`).join('')}
136
-
</div>
137
-
`;
138
-
}
139
-
140
-
browseBtn.addEventListener('click', async () => {
141
-
const account = accountInput.value.trim();
142
-
if (!account) {
143
-
alert('Please enter an account handle or DID');
144
-
return;
145
-
}
146
-
147
-
try {
148
-
browseBtn.disabled = true;
149
-
browseBtn.textContent = 'Loading...';
150
-
151
-
// Get account info
152
-
const repoInfo = await browser.getRepoInfo(account);
153
-
if (!repoInfo) {
154
-
alert('Could not load account information');
155
-
return;
156
-
}
157
-
158
-
currentAccount = account;
159
-
displayAccountInfo(repoInfo);
160
-
displayCollections(repoInfo.collections);
161
-
162
-
// Clear previous records
163
-
recordsContainer.innerHTML = '<p class="text-gray-500 text-center py-8">Select a collection to view records</p>';
164
-
165
-
} catch (error) {
166
-
console.error('Error browsing account:', error);
167
-
alert('Error browsing account. Check the console for details.');
168
-
} finally {
169
-
browseBtn.disabled = false;
170
-
browseBtn.textContent = 'Browse Account';
171
-
}
172
-
});
173
-
174
-
// Make loadCollection available globally
175
-
(window as any).loadCollection = loadCollection;
176
-
</script>
-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>
-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>
+8
src/pages/index.astro
+8
src/pages/index.astro
···
120
120
Build-time collection discovery and type generation.
121
121
</p>
122
122
</a>
123
+
<a href="/simplified-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">
124
+
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
125
+
Simplified Content Test
126
+
</h3>
127
+
<p class="text-gray-600 dark:text-gray-400">
128
+
Test the simplified content service approach based on reference repository patterns.
129
+
</p>
130
+
</a>
123
131
<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">
124
132
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
125
133
Jetstream Test
-164
src/pages/jetstream-test.astro
-164
src/pages/jetstream-test.astro
···
1
-
---
2
-
import Layout from '../layouts/Layout.astro';
3
-
import { loadConfig } from '../lib/config/site';
4
-
5
-
const config = loadConfig();
6
-
---
7
-
8
-
<Layout title="Jetstream Test">
9
-
<div class="container mx-auto px-4 py-8">
10
-
<h1 class="text-4xl font-bold mb-8">Jetstream Repository Test</h1>
11
-
12
-
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
13
-
<h2 class="text-2xl font-semibold mb-4">Configuration</h2>
14
-
<p><strong>Handle:</strong> {config.atproto.handle}</p>
15
-
<p><strong>DID:</strong> {config.atproto.did}</p>
16
-
<p class="text-sm text-gray-600 mt-2">This uses ATProto sync API to stream all repository activity with DID filtering, similar to atptools.</p>
17
-
</div>
18
-
19
-
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
20
-
<div class="bg-white border border-gray-200 rounded-lg p-6">
21
-
<h2 class="text-2xl font-semibold mb-4">Connection</h2>
22
-
<button id="start-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
23
-
Start Jetstream
24
-
</button>
25
-
<button id="stop-btn" class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 ml-2" disabled>
26
-
Stop Stream
27
-
</button>
28
-
<div id="status" class="mt-4 p-2 rounded bg-gray-100">
29
-
Status: <span id="status-text">Stopped</span>
30
-
</div>
31
-
</div>
32
-
33
-
<div class="bg-white border border-gray-200 rounded-lg p-6">
34
-
<h2 class="text-2xl font-semibold mb-4">Statistics</h2>
35
-
<div class="space-y-2">
36
-
<div>Records received: <span id="records-count" class="font-bold">0</span></div>
37
-
<div>Streaming status: <span id="streaming-status" class="font-bold">Stopped</span></div>
38
-
<div>Last sync: <span id="last-sync" class="font-bold">Never</span></div>
39
-
</div>
40
-
</div>
41
-
</div>
42
-
43
-
<div class="mt-8">
44
-
<h2 class="text-2xl font-semibold mb-4">Live Records</h2>
45
-
<div id="records-container" class="space-y-4 max-h-96 overflow-y-auto">
46
-
<p class="text-gray-500 text-center py-8">No records received yet...</p>
47
-
</div>
48
-
</div>
49
-
</div>
50
-
</Layout>
51
-
52
-
<script>
53
-
import { JetstreamClient } from '../lib/atproto/jetstream-client';
54
-
55
-
let client: JetstreamClient | null = null;
56
-
let recordsCount = 0;
57
-
58
-
// DOM elements
59
-
const startBtn = document.getElementById('start-btn') as HTMLButtonElement;
60
-
const stopBtn = document.getElementById('stop-btn') as HTMLButtonElement;
61
-
const statusText = document.getElementById('status-text') as HTMLSpanElement;
62
-
const recordsCountEl = document.getElementById('records-count') as HTMLSpanElement;
63
-
const streamingStatusEl = document.getElementById('streaming-status') as HTMLSpanElement;
64
-
const lastSyncEl = document.getElementById('last-sync') as HTMLSpanElement;
65
-
const recordsContainer = document.getElementById('records-container') as HTMLDivElement;
66
-
67
-
function updateStatus(status: string) {
68
-
statusText.textContent = status;
69
-
streamingStatusEl.textContent = status;
70
-
71
-
if (status === 'Streaming') {
72
-
startBtn.disabled = true;
73
-
stopBtn.disabled = false;
74
-
} else {
75
-
startBtn.disabled = false;
76
-
stopBtn.disabled = true;
77
-
}
78
-
}
79
-
80
-
function updateLastSync() {
81
-
lastSyncEl.textContent = new Date().toLocaleTimeString();
82
-
}
83
-
84
-
function addRecord(record: any) {
85
-
recordsCount++;
86
-
recordsCountEl.textContent = recordsCount.toString();
87
-
88
-
const recordEl = document.createElement('div');
89
-
recordEl.className = 'bg-gray-50 border border-gray-200 rounded-lg p-4';
90
-
91
-
const time = new Date(record.time_us / 1000).toLocaleString();
92
-
const collection = record.collection;
93
-
const $type = record.$type;
94
-
const service = record.service;
95
-
const text = record.value?.text || 'No text content';
96
-
97
-
recordEl.innerHTML = `
98
-
<div class="flex items-start space-x-3">
99
-
<div class="flex-1 min-w-0">
100
-
<div class="flex items-center space-x-2 mb-2">
101
-
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs font-medium">${service}</span>
102
-
<span class="bg-green-100 text-green-800 px-2 py-1 rounded text-xs font-medium">${collection}</span>
103
-
<span class="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs font-medium">${$type}</span>
104
-
<span class="bg-${record.operation === 'create' ? 'green' : record.operation === 'update' ? 'yellow' : 'red'}-100 text-${record.operation === 'create' ? 'green' : record.operation === 'update' ? 'yellow' : 'red'}-800 px-2 py-1 rounded text-xs font-medium">${record.operation}</span>
105
-
</div>
106
-
${text ? `<p class="text-sm text-gray-600 mb-2">${text}</p>` : ''}
107
-
<p class="text-xs text-gray-500">${time}</p>
108
-
<p class="text-xs font-mono text-gray-400 mt-1 break-all">${record.uri}</p>
109
-
</div>
110
-
</div>
111
-
`;
112
-
113
-
recordsContainer.insertBefore(recordEl, recordsContainer.firstChild);
114
-
115
-
// Keep only the last 20 records
116
-
const records = recordsContainer.querySelectorAll('div');
117
-
if (records.length > 20) {
118
-
records[records.length - 1].remove();
119
-
}
120
-
}
121
-
122
-
startBtn.addEventListener('click', async () => {
123
-
try {
124
-
updateStatus('Starting...');
125
-
126
-
client = new JetstreamClient();
127
-
128
-
client.onConnect(() => {
129
-
updateStatus('Streaming');
130
-
console.log('Jetstream connected');
131
-
});
132
-
133
-
client.onDisconnect(() => {
134
-
updateStatus('Stopped');
135
-
console.log('Jetstream disconnected');
136
-
});
137
-
138
-
client.onError((error) => {
139
-
console.error('Jetstream error:', error);
140
-
updateStatus('Error');
141
-
});
142
-
143
-
client.onRecord((record) => {
144
-
console.log('Record received:', record);
145
-
addRecord(record);
146
-
updateLastSync();
147
-
});
148
-
149
-
await client.startStreaming();
150
-
151
-
} catch (error) {
152
-
console.error('Failed to start jetstream:', error);
153
-
updateStatus('Error');
154
-
alert('Failed to start jetstream. Check the console for details.');
155
-
}
156
-
});
157
-
158
-
stopBtn.addEventListener('click', () => {
159
-
if (client) {
160
-
client.stopStreaming();
161
-
client = null;
162
-
}
163
-
});
164
-
</script>
-371
src/pages/lexicon-generator-test.astro
-371
src/pages/lexicon-generator-test.astro
···
1
-
---
2
-
import Layout from '../layouts/Layout.astro';
3
-
import { loadConfig } from '../lib/config/site';
4
-
5
-
const config = loadConfig();
6
-
---
7
-
8
-
<Layout title="Lexicon Generator Test">
9
-
<div class="container mx-auto px-4 py-8">
10
-
<h1 class="text-4xl font-bold mb-8">Lexicon Generator Test</h1>
11
-
12
-
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
13
-
<h2 class="text-2xl font-semibold mb-4">Configuration</h2>
14
-
<p><strong>Handle:</strong> {config.atproto.handle}</p>
15
-
<p><strong>DID:</strong> {config.atproto.did}</p>
16
-
<p class="text-sm text-gray-600 mt-2">Generate TypeScript types for all lexicons discovered in your configured repository.</p>
17
-
</div>
18
-
19
-
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
20
-
<div class="bg-white border border-gray-200 rounded-lg p-6">
21
-
<h2 class="text-2xl font-semibold mb-4">Generate Types</h2>
22
-
<div class="space-y-4">
23
-
<p class="text-sm text-gray-600">Generating types for: <strong>{config.atproto.handle}</strong></p>
24
-
<button id="generate-btn" class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700">
25
-
Generate Types for My Repository
26
-
</button>
27
-
</div>
28
-
</div>
29
-
30
-
<div class="bg-white border border-gray-200 rounded-lg p-6">
31
-
<h2 class="text-2xl font-semibold mb-4">Generation Status</h2>
32
-
<div id="status" class="space-y-2">
33
-
<p class="text-gray-500">Click generate to analyze your repository...</p>
34
-
</div>
35
-
</div>
36
-
</div>
37
-
38
-
<div class="mt-8">
39
-
<h2 class="text-2xl font-semibold mb-4">Discovered Lexicons</h2>
40
-
<div id="lexicons-container" class="space-y-4">
41
-
<p class="text-gray-500 text-center py-8">No lexicons discovered yet...</p>
42
-
</div>
43
-
</div>
44
-
45
-
<div class="mt-8">
46
-
<h2 class="text-2xl font-semibold mb-4">Generated TypeScript Types</h2>
47
-
<div class="bg-gray-900 text-green-400 p-4 rounded-lg">
48
-
<pre id="types-output" class="text-sm overflow-x-auto">// Generated types will appear here...</pre>
49
-
</div>
50
-
<div class="mt-4">
51
-
<button id="copy-btn" class="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700" disabled>
52
-
Copy to Clipboard
53
-
</button>
54
-
<button id="download-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 ml-2" disabled>
55
-
Download Types File
56
-
</button>
57
-
</div>
58
-
</div>
59
-
</div>
60
-
</Layout>
61
-
62
-
<script>
63
-
// Simple lexicon generator for your configured account
64
-
class SimpleLexiconGenerator {
65
-
constructor() {
66
-
this.config = {
67
-
handle: 'tynanpurdy.com',
68
-
did: 'did:plc:6ayddqghxhciedbaofoxkcbs',
69
-
pdsUrl: 'https://bsky.social'
70
-
};
71
-
}
72
-
73
-
async generateTypesForRepository() {
74
-
console.log('🔍 Generating types for repository:', this.config.handle);
75
-
76
-
const lexicons = [];
77
-
const collectionTypes = {};
78
-
79
-
try {
80
-
// Get all collections in the repository
81
-
const collections = await this.getAllCollections();
82
-
console.log(`📊 Found ${collections.length} collections:`, collections);
83
-
84
-
// Analyze each collection
85
-
for (const collection of collections) {
86
-
console.log(`🔍 Analyzing collection: ${collection}`);
87
-
88
-
try {
89
-
const collectionInfo = await this.getCollectionRecords(collection, 100);
90
-
if (collectionInfo && collectionInfo.records.length > 0) {
91
-
// Group records by type
92
-
const typeGroups = new Map();
93
-
94
-
collectionInfo.records.forEach(record => {
95
-
const $type = record.$type;
96
-
if (!typeGroups.has($type)) {
97
-
typeGroups.set($type, []);
98
-
}
99
-
typeGroups.get($type).push(record);
100
-
});
101
-
102
-
// Create lexicon definitions for each type
103
-
typeGroups.forEach((records, $type) => {
104
-
const sampleRecord = records[0];
105
-
const properties = this.extractProperties(sampleRecord.value);
106
-
107
-
const lexicon = {
108
-
$type,
109
-
collection,
110
-
properties,
111
-
sampleRecord,
112
-
description: `Discovered in collection ${collection}`
113
-
};
114
-
115
-
lexicons.push(lexicon);
116
-
117
-
// Track collection types
118
-
if (!collectionTypes[collection]) {
119
-
collectionTypes[collection] = [];
120
-
}
121
-
collectionTypes[collection].push($type);
122
-
123
-
console.log(`✅ Generated lexicon for ${$type} in ${collection}`);
124
-
});
125
-
}
126
-
} catch (error) {
127
-
console.error(`❌ Error analyzing collection ${collection}:`, error);
128
-
}
129
-
}
130
-
131
-
// Generate TypeScript type definitions
132
-
const typeDefinitions = this.generateTypeScriptTypes(lexicons, collectionTypes);
133
-
134
-
console.log(`🎉 Generated ${lexicons.length} lexicon definitions`);
135
-
136
-
return {
137
-
lexicons,
138
-
typeDefinitions,
139
-
collectionTypes
140
-
};
141
-
142
-
} catch (error) {
143
-
console.error('Error generating types:', error);
144
-
throw error;
145
-
}
146
-
}
147
-
148
-
async getAllCollections() {
149
-
try {
150
-
const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=${this.config.did}`);
151
-
const data = await response.json();
152
-
return data.collections || [];
153
-
} catch (error) {
154
-
console.error('Error getting collections:', error);
155
-
return [];
156
-
}
157
-
}
158
-
159
-
async getCollectionRecords(collection, limit = 100) {
160
-
try {
161
-
const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${this.config.did}&collection=${collection}&limit=${limit}`);
162
-
const data = await response.json();
163
-
164
-
const records = data.records.map(record => ({
165
-
uri: record.uri,
166
-
cid: record.cid,
167
-
value: record.value,
168
-
indexedAt: record.indexedAt,
169
-
collection: collection,
170
-
$type: record.value?.$type || 'unknown',
171
-
}));
172
-
173
-
return {
174
-
collection: collection,
175
-
recordCount: records.length,
176
-
records: records,
177
-
cursor: data.cursor,
178
-
};
179
-
} catch (error) {
180
-
console.error('Error getting collection records:', error);
181
-
return null;
182
-
}
183
-
}
184
-
185
-
extractProperties(value) {
186
-
const properties = {};
187
-
188
-
if (value && typeof value === 'object') {
189
-
Object.keys(value).forEach(key => {
190
-
if (key !== '$type') {
191
-
properties[key] = {
192
-
type: typeof value[key],
193
-
value: value[key]
194
-
};
195
-
}
196
-
});
197
-
}
198
-
199
-
return properties;
200
-
}
201
-
202
-
generateTypeScriptTypes(lexicons, collectionTypes) {
203
-
let types = '// Auto-generated TypeScript types for discovered lexicons\n';
204
-
types += '// Generated from ATProto repository analysis\n\n';
205
-
206
-
// Generate interfaces for each lexicon
207
-
lexicons.forEach(lexicon => {
208
-
const interfaceName = this.generateInterfaceName(lexicon.$type);
209
-
types += `export interface ${interfaceName} {\n`;
210
-
types += ` $type: '${lexicon.$type}';\n`;
211
-
212
-
Object.entries(lexicon.properties).forEach(([key, prop]) => {
213
-
const type = this.getTypeScriptType(prop.type, prop.value);
214
-
types += ` ${key}: ${type};\n`;
215
-
});
216
-
217
-
types += '}\n\n';
218
-
});
219
-
220
-
// Generate collection type mappings
221
-
types += '// Collection type mappings\n';
222
-
types += 'export interface CollectionTypes {\n';
223
-
Object.entries(collectionTypes).forEach(([collection, types]) => {
224
-
types += ` '${collection}': ${types.map(t => `'${t}'`).join(' | ')};\n`;
225
-
});
226
-
types += '}\n\n';
227
-
228
-
// Generate union types for all lexicons
229
-
const allTypes = lexicons.map(l => this.generateInterfaceName(l.$type));
230
-
types += `export type AllLexicons = ${allTypes.join(' | ')};\n\n`;
231
-
232
-
// Generate helper functions
233
-
types += '// Helper functions\n';
234
-
types += 'export function isLexiconType(record: any, type: string): boolean {\n';
235
-
types += ' return record?.$type === type;\n';
236
-
types += '}\n\n';
237
-
238
-
types += 'export function getCollectionTypes(collection: string): string[] {\n';
239
-
types += ' const collectionTypes: Record<string, string[]> = {\n';
240
-
Object.entries(collectionTypes).forEach(([collection, types]) => {
241
-
types += ` '${collection}': [${types.map(t => `'${t}'`).join(', ')}],\n`;
242
-
});
243
-
types += ' };\n';
244
-
types += ' return collectionTypes[collection] || [];\n';
245
-
types += '}\n';
246
-
247
-
return types;
248
-
}
249
-
250
-
generateInterfaceName($type) {
251
-
return $type
252
-
.split('.')
253
-
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
254
-
.join('');
255
-
}
256
-
257
-
getTypeScriptType(jsType, value) {
258
-
switch (jsType) {
259
-
case 'string':
260
-
return 'string';
261
-
case 'number':
262
-
return typeof value === 'number' && Number.isInteger(value) ? 'number' : 'number';
263
-
case 'boolean':
264
-
return 'boolean';
265
-
case 'object':
266
-
if (Array.isArray(value)) {
267
-
return 'any[]';
268
-
}
269
-
return 'Record<string, any>';
270
-
default:
271
-
return 'any';
272
-
}
273
-
}
274
-
}
275
-
276
-
const generator = new SimpleLexiconGenerator();
277
-
let generatedTypes = '';
278
-
279
-
// DOM elements
280
-
const generateBtn = document.getElementById('generate-btn') as HTMLButtonElement;
281
-
const status = document.getElementById('status') as HTMLDivElement;
282
-
const lexiconsContainer = document.getElementById('lexicons-container') as HTMLDivElement;
283
-
const typesOutput = document.getElementById('types-output') as HTMLPreElement;
284
-
const copyBtn = document.getElementById('copy-btn') as HTMLButtonElement;
285
-
const downloadBtn = document.getElementById('download-btn') as HTMLButtonElement;
286
-
287
-
function updateStatus(message: string, isError = false) {
288
-
status.innerHTML = `
289
-
<p class="${isError ? 'text-red-600' : 'text-green-600'}">${message}</p>
290
-
`;
291
-
}
292
-
293
-
function displayLexicons(lexicons: any[]) {
294
-
if (lexicons.length === 0) {
295
-
lexiconsContainer.innerHTML = '<p class="text-gray-500 text-center py-8">No lexicons discovered</p>';
296
-
return;
297
-
}
298
-
299
-
lexiconsContainer.innerHTML = `
300
-
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
301
-
${lexicons.map(lexicon => `
302
-
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
303
-
<h3 class="font-semibold text-gray-900 mb-2">${lexicon.$type}</h3>
304
-
<p class="text-sm text-gray-600 mb-2">Collection: ${lexicon.collection}</p>
305
-
<p class="text-sm text-gray-600 mb-2">Properties: ${Object.keys(lexicon.properties).length}</p>
306
-
<p class="text-xs text-gray-500">${lexicon.description}</p>
307
-
</div>
308
-
`).join('')}
309
-
</div>
310
-
`;
311
-
}
312
-
313
-
function displayTypes(types: string) {
314
-
generatedTypes = types;
315
-
typesOutput.textContent = types;
316
-
copyBtn.disabled = false;
317
-
downloadBtn.disabled = false;
318
-
}
319
-
320
-
generateBtn.addEventListener('click', async () => {
321
-
try {
322
-
generateBtn.disabled = true;
323
-
generateBtn.textContent = 'Generating...';
324
-
updateStatus('Starting type generation...');
325
-
326
-
// Generate types
327
-
const result = await generator.generateTypesForRepository();
328
-
329
-
updateStatus(`Generated ${result.lexicons.length} lexicon types successfully!`);
330
-
displayLexicons(result.lexicons);
331
-
displayTypes(result.typeDefinitions);
332
-
333
-
} catch (error) {
334
-
console.error('Error generating types:', error);
335
-
updateStatus('Error generating types. Check the console for details.', true);
336
-
} finally {
337
-
generateBtn.disabled = false;
338
-
generateBtn.textContent = 'Generate Types for My Repository';
339
-
}
340
-
});
341
-
342
-
copyBtn.addEventListener('click', async () => {
343
-
try {
344
-
await navigator.clipboard.writeText(generatedTypes);
345
-
copyBtn.textContent = 'Copied!';
346
-
setTimeout(() => {
347
-
copyBtn.textContent = 'Copy to Clipboard';
348
-
}, 2000);
349
-
} catch (error) {
350
-
console.error('Error copying to clipboard:', error);
351
-
alert('Error copying to clipboard');
352
-
}
353
-
});
354
-
355
-
downloadBtn.addEventListener('click', () => {
356
-
try {
357
-
const blob = new Blob([generatedTypes], { type: 'text/typescript' });
358
-
const url = URL.createObjectURL(blob);
359
-
const a = document.createElement('a');
360
-
a.href = url;
361
-
a.download = 'generated-lexicons.ts';
362
-
document.body.appendChild(a);
363
-
a.click();
364
-
document.body.removeChild(a);
365
-
URL.revokeObjectURL(url);
366
-
} catch (error) {
367
-
console.error('Error downloading file:', error);
368
-
alert('Error downloading file');
369
-
}
370
-
});
371
-
</script>
-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>