+113
-5
src/components/content/ContentFeed.astro
+113
-5
src/components/content/ContentFeed.astro
···
4
4
import type { AtprotoRecord } from '../../lib/types/atproto';
5
5
import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob';
6
6
7
+
7
8
interface Props {
8
-
handle: string;
9
9
collection?: string;
10
10
limit?: number;
11
11
feedUri?: string;
12
12
showAuthor?: boolean;
13
13
showTimestamp?: boolean;
14
+
live?: boolean;
14
15
}
15
16
16
17
const {
17
-
handle,
18
18
collection = 'app.bsky.feed.post',
19
19
limit = 10,
20
20
feedUri,
21
21
showAuthor = true,
22
-
showTimestamp = true
22
+
showTimestamp = true,
23
+
live = false,
23
24
} = Astro.props;
24
25
25
26
const config = loadConfig();
27
+
const handle = config.atproto.handle;
26
28
const browser = new AtprotoBrowser();
27
29
28
30
// Helper function to get image URL from blob reference
···
61
63
62
64
<div class="space-y-6">
63
65
{records.length > 0 ? (
64
-
<div class="space-y-4">
66
+
<div id="feed-container" class="space-y-4" data-show-timestamp={String(showTimestamp)} data-initial-limit={String(limit)} data-did={config.atproto.did}>
65
67
{records.map((record) => {
66
68
if (record.value?.$type !== 'app.bsky.feed.post') return null;
67
69
···
136
138
<p class="text-sm mt-2">Debug: Handle = {handle}, Records fetched = {records.length}</p>
137
139
</div>
138
140
)}
139
-
</div>
141
+
</div>
142
+
143
+
{live && (
144
+
<script>
145
+
// @ts-nocheck
146
+
const container = document.getElementById('feed-container');
147
+
if (container) {
148
+
const SHOW_TIMESTAMP = container.getAttribute('data-show-timestamp') === 'true';
149
+
const INITIAL_LIMIT = Number(container.getAttribute('data-initial-limit') || '10');
150
+
const maxPrepend = 20;
151
+
const DID = container.getAttribute('data-did') || '';
152
+
153
+
function extractCid(ref) {
154
+
if (typeof ref === 'string') return ref;
155
+
if (ref && typeof ref === 'object') {
156
+
if (typeof ref.$link === 'string') return ref.$link;
157
+
if (typeof ref.toString === 'function') return ref.toString();
158
+
}
159
+
return null;
160
+
}
161
+
162
+
function buildImagesEl(post, did) {
163
+
if (post?.embed?.$type !== 'app.bsky.embed.images' || !post.embed.images) return null;
164
+
const grid = document.createElement('div');
165
+
const count = post.embed.images.length;
166
+
const cols = count === 1 ? 'grid-cols-1' : count === 2 ? 'grid-cols-2' : 'grid-cols-3';
167
+
grid.className = `grid gap-2 ${cols}`;
168
+
for (const img of post.embed.images) {
169
+
const cid = extractCid(img?.image?.ref);
170
+
if (!cid) continue;
171
+
const url = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
172
+
const wrapper = document.createElement('div');
173
+
wrapper.className = 'relative';
174
+
const image = document.createElement('img');
175
+
image.src = url;
176
+
image.alt = img?.alt || 'Post image';
177
+
image.className = 'rounded-lg w-full h-auto object-cover';
178
+
const arW = (img?.aspectRatio && img.aspectRatio.width) || 1;
179
+
const arH = (img?.aspectRatio && img.aspectRatio.height) || 1;
180
+
// @ts-ignore
181
+
image.style.aspectRatio = `${arW} / ${arH}`;
182
+
wrapper.appendChild(image);
183
+
grid.appendChild(wrapper);
184
+
}
185
+
return grid;
186
+
}
187
+
188
+
function buildPostEl(post, did) {
189
+
const article = document.createElement('article');
190
+
article.className = 'bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4';
191
+
192
+
const textDiv = document.createElement('div');
193
+
textDiv.className = 'text-gray-900 dark:text-white mb-3';
194
+
textDiv.textContent = post?.text ? String(post.text) : '';
195
+
article.appendChild(textDiv);
196
+
197
+
const imagesEl = buildImagesEl(post, did);
198
+
if (imagesEl) {
199
+
const imagesWrap = document.createElement('div');
200
+
imagesWrap.className = 'mb-3';
201
+
imagesWrap.appendChild(imagesEl);
202
+
article.appendChild(imagesWrap);
203
+
}
204
+
205
+
if (SHOW_TIMESTAMP && post?.createdAt) {
206
+
const timeDiv = document.createElement('div');
207
+
timeDiv.className = 'text-xs text-gray-500 dark:text-gray-400';
208
+
timeDiv.textContent = new Date(post.createdAt).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' });
209
+
article.appendChild(timeDiv);
210
+
}
211
+
212
+
return article;
213
+
}
214
+
215
+
try {
216
+
const endpoint = 'wss://jetstream1.us-east.bsky.network/subscribe';
217
+
const url = new URL(endpoint);
218
+
if (DID) url.searchParams.append('wantedDids', DID);
219
+
const ws = new WebSocket(url.toString());
220
+
221
+
ws.onmessage = (event) => {
222
+
try {
223
+
const data = JSON.parse(event.data);
224
+
if (data?.kind !== 'commit') return;
225
+
const commit = data.commit;
226
+
const record = commit?.record || {};
227
+
if (commit?.operation !== 'create') return;
228
+
if (record?.$type !== 'app.bsky.feed.post') return;
229
+
const el = buildPostEl(record, data.did);
230
+
// @ts-ignore
231
+
container.insertBefore(el, container.firstChild);
232
+
const posts = container.children;
233
+
if (posts.length > maxPrepend + INITIAL_LIMIT) {
234
+
if (container.lastElementChild) container.removeChild(container.lastElementChild);
235
+
}
236
+
} catch (e) {
237
+
console.error('jetstream msg error', e);
238
+
}
239
+
};
240
+
241
+
ws.onerror = (e) => console.error('jetstream ws error', e);
242
+
} catch (e) {
243
+
console.error('jetstream start error', e);
244
+
}
245
+
}
246
+
</script>
247
+
)}
+1
-105
src/pages/index.astro
+1
-105
src/pages/index.astro
···
26
26
My Posts
27
27
</h2>
28
28
<ContentFeed
29
-
handle={config.atproto.handle}
30
29
limit={10}
31
30
showAuthor={false}
32
31
showTimestamp={true}
32
+
live={true}
33
33
/>
34
34
</section>
35
35
···
40
40
Explore More
41
41
</h2>
42
42
<div class="grid md:grid-cols-2 gap-6">
43
-
<a href="/content-feed" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
44
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
45
-
Content Feed
46
-
</h3>
47
-
<p class="text-gray-600 dark:text-gray-400">
48
-
All your ATProto content with real-time updates and streaming.
49
-
</p>
50
-
</a>
51
43
<a href="/galleries" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
52
44
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
53
45
Image Galleries
54
46
</h3>
55
47
<p class="text-gray-600 dark:text-gray-400">
56
48
View my grain.social image galleries and photo collections.
57
-
</p>
58
-
</a>
59
-
<a href="/galleries-unified" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
60
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
61
-
Unified Galleries
62
-
</h3>
63
-
<p class="text-gray-600 dark:text-gray-400">
64
-
Galleries with real-time updates using the content system.
65
-
</p>
66
-
</a>
67
-
<a href="/gallery-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
68
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
69
-
Gallery System Test
70
-
</h3>
71
-
<p class="text-gray-600 dark:text-gray-400">
72
-
Test the gallery service and display components with detailed debugging.
73
-
</p>
74
-
</a>
75
-
<a href="/content-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
76
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
77
-
Content Rendering Test
78
-
</h3>
79
-
<p class="text-gray-600 dark:text-gray-400">
80
-
Test the content rendering system with type-safe components and filters.
81
-
</p>
82
-
</a>
83
-
<a href="/gallery-debug" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
84
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
85
-
Gallery Structure Debug
86
-
</h3>
87
-
<p class="text-gray-600 dark:text-gray-400">
88
-
Examine Grain.social collection structure and relationships.
89
-
</p>
90
-
</a>
91
-
<a href="/grain-gallery-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
92
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
93
-
Grain Gallery Test
94
-
</h3>
95
-
<p class="text-gray-600 dark:text-gray-400">
96
-
Test the Grain.social gallery grouping and display system.
97
-
</p>
98
-
</a>
99
-
<a href="/api-debug" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
100
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
101
-
API Debug
102
-
</h3>
103
-
<p class="text-gray-600 dark:text-gray-400">
104
-
Debug ATProto API calls and configuration issues.
105
-
</p>
106
-
</a>
107
-
<a href="/simple-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
108
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
109
-
Simple API Test
110
-
</h3>
111
-
<p class="text-gray-600 dark:text-gray-400">
112
-
Basic HTTP request test to ATProto APIs.
113
-
</p>
114
-
</a>
115
-
<a href="/discovery-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
116
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
117
-
Discovery Test
118
-
</h3>
119
-
<p class="text-gray-600 dark:text-gray-400">
120
-
Build-time collection discovery and type generation.
121
-
</p>
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>
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">
132
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
133
-
Jetstream Test
134
-
</h3>
135
-
<p class="text-gray-600 dark:text-gray-400">
136
-
ATProto sync API streaming with DID filtering (like atptools) - low latency, real-time.
137
-
</p>
138
-
</a>
139
-
<a href="/atproto-browser-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
140
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
141
-
ATProto Browser Test
142
-
</h3>
143
-
<p class="text-gray-600 dark:text-gray-400">
144
-
Browse ATProto accounts and records like atptools - explore collections and records.
145
-
</p>
146
-
</a>
147
-
<a href="/lexicon-generator-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">
148
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
149
-
Lexicon Generator Test
150
-
</h3>
151
-
<p class="text-gray-600 dark:text-gray-400">
152
-
Generate TypeScript types for all lexicons discovered in your configured repository.
153
49
</p>
154
50
</a>
155
51
</div>