tangled
alpha
login
or
join now
finxol.io
/
blog
0
fork
atom
Personal blog
finxol.io
blog
0
fork
atom
overview
issues
pulls
pipelines
feat: show bsky posts on home page
finxol.io
1 month ago
90800401
5e653791
verified
This commit was signed with the committer's
known signature
.
finxol.io
SSH Key Fingerprint:
SHA256:olFE3asYdoBMScuJOt60UxXdJ0RFdGv5kVKrdOtIcPI=
1/1
deploy.yaml
success
2m 3s
+963
-94
9 changed files
expand all
collapse all
unified
split
.zed
settings.json
app
components
Country.vue
page-elements
BskyFeedPost.vue
BskyPosts.vue
BskyPostsSkeleton.vue
PostsList.vue
pages
index.vue
util
atproto.ts
deno.jsonc
+1
-1
.zed/settings.json
reviewed
···
6
6
},
7
7
"Vue.js": {
8
8
"formatter": { "language_server": { "name": "biome" } },
9
9
-
"language_servers": ["!deno", "biome", "..."]
9
9
+
"language_servers": ["!deno", "biome", "...", "vtsls"]
10
10
},
11
11
12
12
"JavaScript": {
+20
-14
app/components/Country.vue
reviewed
···
1
1
<script setup lang="ts">
2
2
-
const data = await $fetch("https://hook.finxol.io/sensors/country")
3
3
-
.then((res) => {
4
4
-
return res as {
5
5
-
country: string;
6
6
-
country_code: string;
7
7
-
country_flag: string;
8
8
-
};
9
9
-
})
10
10
-
.catch((e) => {
11
11
-
console.error(e);
12
12
-
});
2
2
+
let country: string | undefined;
3
3
+
let emoji: string | undefined;
13
4
14
14
-
const country = data?.country;
15
15
-
const emoji = data?.country_flag;
5
5
+
try {
6
6
+
const data = await $fetch("https://hook.finxol.io/sensors/country")
7
7
+
.then((res) => {
8
8
+
return res as {
9
9
+
country: string;
10
10
+
country_code: string;
11
11
+
country_flag: string;
12
12
+
};
13
13
+
})
14
14
+
.catch((e) => {
15
15
+
console.error(e);
16
16
+
});
17
17
+
country = data?.country;
18
18
+
emoji = data?.country_flag;
19
19
+
} catch (error) {
20
20
+
console.error(error);
21
21
+
}
16
22
</script>
17
23
18
24
<template>
19
19
-
<div v-if="data" class="hidden sm:flex flex-row items-center gap-2">
25
25
+
<div v-if="country" class="hidden sm:flex flex-row items-center gap-2">
20
26
<div class="tooltip-target flex flex-row items-center gap-2 bg-stone-200/50 dark:bg-stone-700/60 py-1 px-2 rounded-lg">
21
27
I'm in
22
28
<span class="flex flex-row items-center gap-2 font-bold">
+547
app/components/page-elements/BskyFeedPost.vue
reviewed
···
1
1
+
<script setup lang="ts">
2
2
+
import type {
3
3
+
AppBskyEmbedImages,
4
4
+
AppBskyEmbedRecord,
5
5
+
AppBskyFeedDefs
6
6
+
} from "@atcute/bluesky";
7
7
+
import { extractPostId } from "~/util/atproto";
8
8
+
9
9
+
const props = defineProps<{
10
10
+
post: AppBskyFeedDefs.FeedViewPost;
11
11
+
}>();
12
12
+
13
13
+
const { post } = toRefs(props);
14
14
+
15
15
+
// Get the actual post data
16
16
+
const postView = computed(() => post.value.post);
17
17
+
const author = computed(() => postView.value.author);
18
18
+
const record = computed(
19
19
+
() => postView.value.record as { text: string; createdAt: string }
20
20
+
);
21
21
+
22
22
+
// Check for image embeds
23
23
+
const images = computed((): AppBskyEmbedImages.ViewImage[] => {
24
24
+
const embed = postView.value.embed;
25
25
+
if (embed?.$type === "app.bsky.embed.images#view") {
26
26
+
return (embed as AppBskyEmbedImages.View).images;
27
27
+
}
28
28
+
// Also check for images in recordWithMedia embeds (quote post with images)
29
29
+
if (embed?.$type === "app.bsky.embed.recordWithMedia#view") {
30
30
+
const media = (
31
31
+
embed as {
32
32
+
media?: {
33
33
+
$type: string;
34
34
+
images?: AppBskyEmbedImages.ViewImage[];
35
35
+
};
36
36
+
}
37
37
+
).media;
38
38
+
if (media?.$type === "app.bsky.embed.images#view" && media.images) {
39
39
+
return media.images;
40
40
+
}
41
41
+
}
42
42
+
return [];
43
43
+
});
44
44
+
45
45
+
const hasImages = computed(() => images.value.length > 0);
46
46
+
47
47
+
// Check for quote post embed
48
48
+
const quotedPost = computed(() => {
49
49
+
const embed = postView.value.embed;
50
50
+
if (embed?.$type === "app.bsky.embed.record#view") {
51
51
+
const recordEmbed = embed as AppBskyEmbedRecord.View;
52
52
+
if (recordEmbed.record.$type === "app.bsky.embed.record#viewRecord") {
53
53
+
return recordEmbed.record as AppBskyEmbedRecord.ViewRecord;
54
54
+
}
55
55
+
}
56
56
+
// Also check for record in recordWithMedia embeds
57
57
+
if (embed?.$type === "app.bsky.embed.recordWithMedia#view") {
58
58
+
const record = (embed as { record?: AppBskyEmbedRecord.View }).record;
59
59
+
if (record?.record.$type === "app.bsky.embed.record#viewRecord") {
60
60
+
return record.record as AppBskyEmbedRecord.ViewRecord;
61
61
+
}
62
62
+
}
63
63
+
return null;
64
64
+
});
65
65
+
66
66
+
// Get quoted post text
67
67
+
const quotedPostText = computed(() => {
68
68
+
if (!quotedPost.value) return "";
69
69
+
const value = quotedPost.value.value as { text?: string };
70
70
+
return value.text || "";
71
71
+
});
72
72
+
73
73
+
// Format the date
74
74
+
const formattedDate = computed(() => {
75
75
+
const date = new Date(record.value.createdAt);
76
76
+
return date.toLocaleDateString("en-US", {
77
77
+
year: "numeric",
78
78
+
month: "short",
79
79
+
day: "numeric"
80
80
+
});
81
81
+
});
82
82
+
83
83
+
// Build the Bluesky post URL
84
84
+
const postUrl = computed(() => {
85
85
+
const postId = extractPostId(postView.value.uri);
86
86
+
return `https://bsky.app/profile/${author.value.handle}/post/${postId}`;
87
87
+
});
88
88
+
89
89
+
// Build quoted post URL
90
90
+
const quotedPostUrl = computed(() => {
91
91
+
if (!quotedPost.value) return "";
92
92
+
const postId = extractPostId(quotedPost.value.uri);
93
93
+
return `https://bsky.app/profile/${quotedPost.value.author.handle}/post/${postId}`;
94
94
+
});
95
95
+
96
96
+
// Modal refs
97
97
+
const quoteDialog = ref<HTMLDialogElement | null>(null);
98
98
+
const imageDialog = ref<HTMLDialogElement | null>(null);
99
99
+
const selectedImage = ref<AppBskyEmbedImages.ViewImage | null>(null);
100
100
+
101
101
+
function openQuoteModal() {
102
102
+
quoteDialog.value?.showModal();
103
103
+
}
104
104
+
105
105
+
function openImageModal(image: AppBskyEmbedImages.ViewImage) {
106
106
+
selectedImage.value = image;
107
107
+
imageDialog.value?.showModal();
108
108
+
}
109
109
+
110
110
+
function closeOnBackdropClick(
111
111
+
event: MouseEvent,
112
112
+
dialog: HTMLDialogElement | null
113
113
+
) {
114
114
+
if (event.target === dialog) {
115
115
+
dialog?.close();
116
116
+
}
117
117
+
}
118
118
+
</script>
119
119
+
120
120
+
<template>
121
121
+
<article class="bsky-feed-post" :class="{ 'has-images': hasImages }">
122
122
+
<div class="main">
123
123
+
<a :href="`https://bsky.app/profile/${author.handle}`" class="avatar-link">
124
124
+
<img
125
125
+
:src="author.avatar"
126
126
+
:alt="author.displayName || author.handle"
127
127
+
class="size-8 rounded-full"
128
128
+
/>
129
129
+
</a>
130
130
+
<div class="content">
131
131
+
<div class="header">
132
132
+
<a :href="`https://bsky.app/profile/${author.handle}`" class="author-name">
133
133
+
{{ author.displayName || author.handle }}
134
134
+
</a>
135
135
+
<span class="separator">·</span>
136
136
+
<a :href="postUrl" class="date">
137
137
+
{{ formattedDate }}
138
138
+
</a>
139
139
+
</div>
140
140
+
<div class="text" :class="{ 'has-quote': quotedPost }">
141
141
+
{{ record.text }}
142
142
+
</div>
143
143
+
144
144
+
<!-- Quote post button -->
145
145
+
<button v-if="quotedPost" type="button" class="quote-button" @click="openQuoteModal">
146
146
+
<Icon name="ri:chat-quote-line" />
147
147
+
<img
148
148
+
:src="quotedPost.author.avatar"
149
149
+
:alt="quotedPost.author.displayName || quotedPost.author.handle"
150
150
+
class="size-4 rounded-full"
151
151
+
/>
152
152
+
<span>{{ quotedPost.author.displayName || quotedPost.author.handle }}</span>
153
153
+
</button>
154
154
+
155
155
+
<div class="stats">
156
156
+
<span title="Replies">
157
157
+
<Icon name="ri:reply-line" />
158
158
+
{{ postView.replyCount }}
159
159
+
</span>
160
160
+
<span title="Reposts">
161
161
+
<Icon name="ri:repeat-line" />
162
162
+
{{ postView.repostCount }}
163
163
+
</span>
164
164
+
<span title="Likes">
165
165
+
<Icon name="ri:heart-3-line" />
166
166
+
{{ postView.likeCount }}
167
167
+
</span>
168
168
+
</div>
169
169
+
</div>
170
170
+
</div>
171
171
+
172
172
+
<!-- Image embeds -->
173
173
+
<div v-if="hasImages" class="images">
174
174
+
<button
175
175
+
v-for="image in images"
176
176
+
:key="image.thumb"
177
177
+
type="button"
178
178
+
class="image-button"
179
179
+
@click="openImageModal(image)"
180
180
+
>
181
181
+
<img
182
182
+
:src="image.thumb"
183
183
+
:alt="image.alt"
184
184
+
class="post-image"
185
185
+
/>
186
186
+
</button>
187
187
+
</div>
188
188
+
</article>
189
189
+
190
190
+
<!-- Quote modal -->
191
191
+
<dialog
192
192
+
v-if="quotedPost"
193
193
+
ref="quoteDialog"
194
194
+
class="quote-dialog"
195
195
+
@click="closeOnBackdropClick($event, quoteDialog)"
196
196
+
>
197
197
+
<div class="dialog-content">
198
198
+
<div class="dialog-header">
199
199
+
<a :href="`https://bsky.app/profile/${quotedPost.author.handle}`" class="quoted-author">
200
200
+
<img
201
201
+
:src="quotedPost.author.avatar"
202
202
+
:alt="quotedPost.author.displayName || quotedPost.author.handle"
203
203
+
class="size-10 rounded-full"
204
204
+
/>
205
205
+
<div>
206
206
+
<div class="quoted-author-name">
207
207
+
{{ quotedPost.author.displayName || quotedPost.author.handle }}
208
208
+
</div>
209
209
+
<div class="quoted-author-handle">
210
210
+
@{{ quotedPost.author.handle }}
211
211
+
</div>
212
212
+
</div>
213
213
+
</a>
214
214
+
<button type="button" class="close-button" @click="quoteDialog?.close()">
215
215
+
<Icon name="ri:close-line" />
216
216
+
</button>
217
217
+
</div>
218
218
+
<div class="dialog-body">
219
219
+
{{ quotedPostText }}
220
220
+
</div>
221
221
+
<a :href="quotedPostUrl" class="view-on-bsky">
222
222
+
View on Bluesky
223
223
+
<Icon name="ri:external-link-line" />
224
224
+
</a>
225
225
+
</div>
226
226
+
</dialog>
227
227
+
228
228
+
<!-- Image modal -->
229
229
+
<dialog
230
230
+
ref="imageDialog"
231
231
+
class="image-dialog"
232
232
+
@click="closeOnBackdropClick($event, imageDialog)"
233
233
+
>
234
234
+
<button type="button" class="close-button image-close" @click="imageDialog?.close()">
235
235
+
<Icon name="ri:close-line" />
236
236
+
</button>
237
237
+
<img
238
238
+
v-if="selectedImage"
239
239
+
:src="selectedImage.fullsize"
240
240
+
:alt="selectedImage.alt"
241
241
+
class="fullsize-image"
242
242
+
/>
243
243
+
</dialog>
244
244
+
</template>
245
245
+
246
246
+
<style scoped>
247
247
+
.bsky-feed-post {
248
248
+
--post-z-index: 1;
249
249
+
250
250
+
position: relative;
251
251
+
display: flex;
252
252
+
padding: 0.75rem;
253
253
+
border: 1px solid #e5e7eb;
254
254
+
border-radius: 0.5rem;
255
255
+
width: 19rem;
256
256
+
height: 13rem;
257
257
+
flex-shrink: 0;
258
258
+
scroll-snap-align: start;
259
259
+
overflow: hidden;
260
260
+
261
261
+
&.has-images {
262
262
+
width: 28rem;
263
263
+
}
264
264
+
265
265
+
:where(
266
266
+
.list-item-secondary-action,
267
267
+
a,
268
268
+
button,
269
269
+
input,
270
270
+
textarea,
271
271
+
label,
272
272
+
select,
273
273
+
details,
274
274
+
audio,
275
275
+
video,
276
276
+
object,
277
277
+
[contenteditable],
278
278
+
[tabindex]
279
279
+
) {
280
280
+
/* Helps ensures secondary actions always float above the primary action's clickable area */
281
281
+
z-index: calc(var(--post-z-index) + 1);
282
282
+
}
283
283
+
}
284
284
+
285
285
+
.main {
286
286
+
display: flex;
287
287
+
gap: 0.5rem;
288
288
+
flex: 1;
289
289
+
min-width: 0;
290
290
+
}
291
291
+
292
292
+
.avatar-link {
293
293
+
flex-shrink: 0;
294
294
+
opacity: 1;
295
295
+
transition: opacity 200ms;
296
296
+
height: fit-content;
297
297
+
z-index: calc(var(--post-z-index) + 1);
298
298
+
299
299
+
&:hover {
300
300
+
opacity: 0.8;
301
301
+
}
302
302
+
}
303
303
+
304
304
+
.content {
305
305
+
display: flex;
306
306
+
flex-direction: column;
307
307
+
gap: 0.25rem;
308
308
+
min-width: 0;
309
309
+
flex: 1;
310
310
+
}
311
311
+
312
312
+
.header {
313
313
+
display: flex;
314
314
+
align-items: center;
315
315
+
gap: 0.25rem;
316
316
+
font-size: 0.75rem;
317
317
+
}
318
318
+
319
319
+
.author-name {
320
320
+
font-weight: 600;
321
321
+
white-space: nowrap;
322
322
+
overflow: hidden;
323
323
+
text-overflow: ellipsis;
324
324
+
z-index: calc(var(--post-z-index) + 1);
325
325
+
326
326
+
&:hover {
327
327
+
text-decoration: underline;
328
328
+
}
329
329
+
330
330
+
}
331
331
+
332
332
+
.separator {
333
333
+
color: #6b7280;
334
334
+
flex-shrink: 0;
335
335
+
}
336
336
+
337
337
+
.date {
338
338
+
color: #6b7280;
339
339
+
white-space: nowrap;
340
340
+
flex-shrink: 0;
341
341
+
342
342
+
&:hover {
343
343
+
text-decoration: underline;
344
344
+
}
345
345
+
346
346
+
&::before {
347
347
+
content: '';
348
348
+
display: block;
349
349
+
position: absolute;
350
350
+
inset: 0;
351
351
+
z-index: var(--post-z-index);
352
352
+
}
353
353
+
}
354
354
+
355
355
+
.text {
356
356
+
font-size: 0.8125rem;
357
357
+
white-space: pre-wrap;
358
358
+
overflow: hidden;
359
359
+
display: -webkit-box;
360
360
+
line-clamp: 7;
361
361
+
-webkit-line-clamp: 7;
362
362
+
-webkit-box-orient: vertical;
363
363
+
364
364
+
&.has-quote {
365
365
+
line-clamp: 5;
366
366
+
-webkit-line-clamp: 5;
367
367
+
}
368
368
+
}
369
369
+
370
370
+
.quote-button {
371
371
+
position: relative;
372
372
+
display: flex;
373
373
+
align-items: center;
374
374
+
gap: 0.375rem;
375
375
+
padding: 0.25rem 0.5rem;
376
376
+
border: 1px solid #e5e7eb;
377
377
+
border-radius: 0.375rem;
378
378
+
background: none;
379
379
+
font-size: 0.6875rem;
380
380
+
color: #6b7280;
381
381
+
cursor: pointer;
382
382
+
transition: background-color 200ms;
383
383
+
width: fit-content;
384
384
+
385
385
+
&:hover {
386
386
+
background-color: #f9fafb;
387
387
+
}
388
388
+
389
389
+
& span {
390
390
+
max-width: 8rem;
391
391
+
white-space: nowrap;
392
392
+
overflow: hidden;
393
393
+
text-overflow: ellipsis;
394
394
+
}
395
395
+
}
396
396
+
397
397
+
.images {
398
398
+
display: flex;
399
399
+
gap: 0.25rem;
400
400
+
margin-inline-start: 0.75rem;
401
401
+
flex-shrink: 0;
402
402
+
}
403
403
+
404
404
+
.image-button {
405
405
+
padding: 0;
406
406
+
border: none;
407
407
+
background: none;
408
408
+
cursor: pointer;
409
409
+
height: 100%;
410
410
+
411
411
+
&:hover .post-image {
412
412
+
opacity: 0.9;
413
413
+
}
414
414
+
}
415
415
+
416
416
+
.post-image {
417
417
+
height: 100%;
418
418
+
width: auto;
419
419
+
max-width: 8rem;
420
420
+
object-fit: cover;
421
421
+
border-radius: 0.375rem;
422
422
+
transition: opacity 200ms;
423
423
+
}
424
424
+
425
425
+
/* Dialog styles */
426
426
+
.quote-dialog,
427
427
+
.image-dialog {
428
428
+
border: none;
429
429
+
border-radius: 0.5rem;
430
430
+
padding: 0;
431
431
+
max-width: 90svw;
432
432
+
max-height: 90svh;
433
433
+
background: transparent;
434
434
+
435
435
+
&::backdrop {
436
436
+
background: rgba(0, 0, 0, 0.7);
437
437
+
}
438
438
+
}
439
439
+
440
440
+
.quote-dialog {
441
441
+
width: min(100%, 30rem);
442
442
+
background: white;
443
443
+
}
444
444
+
445
445
+
.dialog-content {
446
446
+
padding: 1rem;
447
447
+
}
448
448
+
449
449
+
.dialog-header {
450
450
+
display: flex;
451
451
+
justify-content: space-between;
452
452
+
align-items: start;
453
453
+
margin-bottom: 0.75rem;
454
454
+
}
455
455
+
456
456
+
.quoted-author {
457
457
+
display: flex;
458
458
+
align-items: center;
459
459
+
gap: 0.5rem;
460
460
+
461
461
+
&:hover .quoted-author-name {
462
462
+
text-decoration: underline;
463
463
+
}
464
464
+
}
465
465
+
466
466
+
.quoted-author-name {
467
467
+
font-weight: 600;
468
468
+
font-size: 0.875rem;
469
469
+
}
470
470
+
471
471
+
.quoted-author-handle {
472
472
+
color: #6b7280;
473
473
+
font-size: 0.75rem;
474
474
+
}
475
475
+
476
476
+
.close-button {
477
477
+
padding: 0.25rem;
478
478
+
border: none;
479
479
+
background: none;
480
480
+
cursor: pointer;
481
481
+
color: #6b7280;
482
482
+
font-size: 1.25rem;
483
483
+
line-height: 1;
484
484
+
border-radius: 0.25rem;
485
485
+
transition: background-color 200ms;
486
486
+
487
487
+
&:hover {
488
488
+
background-color: #f3f4f6;
489
489
+
}
490
490
+
}
491
491
+
492
492
+
.dialog-body {
493
493
+
white-space: pre-wrap;
494
494
+
font-size: 0.9375rem;
495
495
+
line-height: 1.5;
496
496
+
margin-bottom: 1rem;
497
497
+
}
498
498
+
499
499
+
.view-on-bsky {
500
500
+
display: inline-flex;
501
501
+
align-items: center;
502
502
+
gap: 0.25rem;
503
503
+
font-size: 0.8125rem;
504
504
+
color: #6b7280;
505
505
+
506
506
+
&:hover {
507
507
+
text-decoration: underline;
508
508
+
}
509
509
+
}
510
510
+
511
511
+
.image-dialog {
512
512
+
background: transparent;
513
513
+
overflow: visible;
514
514
+
}
515
515
+
516
516
+
.image-close {
517
517
+
position: absolute;
518
518
+
top: -2.5rem;
519
519
+
right: 0;
520
520
+
color: white;
521
521
+
522
522
+
&:hover {
523
523
+
background-color: rgba(255, 255, 255, 0.1);
524
524
+
}
525
525
+
}
526
526
+
527
527
+
.fullsize-image {
528
528
+
max-width: 90vw;
529
529
+
max-height: 85vh;
530
530
+
object-fit: contain;
531
531
+
border-radius: 0.5rem;
532
532
+
}
533
533
+
534
534
+
.stats {
535
535
+
display: flex;
536
536
+
gap: 1rem;
537
537
+
color: #6b7280;
538
538
+
font-size: 0.6875rem;
539
539
+
margin-top: auto;
540
540
+
541
541
+
& > span {
542
542
+
display: flex;
543
543
+
align-items: center;
544
544
+
gap: 0.25rem;
545
545
+
}
546
546
+
}
547
547
+
</style>
+58
app/components/page-elements/BskyPosts.vue
reviewed
···
1
1
+
<script setup lang="ts">
2
2
+
import config from "@/../blog.config";
3
3
+
import { getBskyPosts } from "~/util/atproto";
4
4
+
5
5
+
const posts = ref(await getBskyPosts());
6
6
+
const profileUrl = config.links?.bluesky || "https://bsky.app";
7
7
+
</script>
8
8
+
9
9
+
<template>
10
10
+
<div v-if="posts.length === 0" class="text-gray-500">
11
11
+
No posts found.
12
12
+
</div>
13
13
+
14
14
+
<div v-else class="posts-scroll">
15
15
+
<PageElementsBskyFeedPost
16
16
+
v-for="feedPost in posts"
17
17
+
:key="feedPost.post.uri"
18
18
+
:post="feedPost"
19
19
+
/>
20
20
+
<a :href="profileUrl" class="view-more">
21
21
+
<span>View more on Bluesky</span>
22
22
+
<Icon name="ri:arrow-right-line" />
23
23
+
</a>
24
24
+
</div>
25
25
+
</template>
26
26
+
27
27
+
<style scoped>
28
28
+
.posts-scroll {
29
29
+
display: flex;
30
30
+
gap: 1rem;
31
31
+
overflow-x: auto;
32
32
+
padding-bottom: 1rem;
33
33
+
scroll-snap-type: x mandatory;
34
34
+
align-items: start;
35
35
+
}
36
36
+
37
37
+
.view-more {
38
38
+
display: flex;
39
39
+
flex-direction: column;
40
40
+
align-items: center;
41
41
+
justify-content: center;
42
42
+
gap: 0.5rem;
43
43
+
min-width: 10rem;
44
44
+
padding: 1rem;
45
45
+
border: 1px solid #e5e7eb;
46
46
+
border-radius: 0.5rem;
47
47
+
color: #6b7280;
48
48
+
flex-shrink: 0;
49
49
+
scroll-snap-align: start;
50
50
+
align-self: stretch;
51
51
+
transition: background-color 200ms, color 200ms;
52
52
+
53
53
+
&:hover {
54
54
+
background-color: #f9fafb;
55
55
+
color: #374151;
56
56
+
}
57
57
+
}
58
58
+
</style>
+199
app/components/page-elements/BskyPostsSkeleton.vue
reviewed
···
1
1
+
<script setup lang="ts">
2
2
+
// Show 2 skeleton cards by default
3
3
+
const skeletonCount = 2;
4
4
+
</script>
5
5
+
6
6
+
<template>
7
7
+
<div class="posts-scroll">
8
8
+
<div
9
9
+
v-for="index in skeletonCount"
10
10
+
:key="`skeleton-${index}`"
11
11
+
class="bsky-feed-post skeleton"
12
12
+
>
13
13
+
<div class="main">
14
14
+
<!-- Avatar skeleton -->
15
15
+
<div class="avatar-skeleton"></div>
16
16
+
<div class="content">
17
17
+
<!-- Header skeleton -->
18
18
+
<div class="header">
19
19
+
<div class="name-skeleton"></div>
20
20
+
<span class="separator">·</span>
21
21
+
<div class="date-skeleton"></div>
22
22
+
</div>
23
23
+
<!-- Text skeleton -->
24
24
+
<div class="text-skeleton">
25
25
+
<div class="skeleton-line"></div>
26
26
+
<div class="skeleton-line"></div>
27
27
+
<div class="skeleton-line short"></div>
28
28
+
</div>
29
29
+
<!-- Stats skeleton -->
30
30
+
<div class="stats-skeleton">
31
31
+
<div class="stat-skeleton"></div>
32
32
+
<div class="stat-skeleton"></div>
33
33
+
<div class="stat-skeleton"></div>
34
34
+
</div>
35
35
+
</div>
36
36
+
</div>
37
37
+
</div>
38
38
+
<div class="view-more-skeleton">
39
39
+
<div class="skeleton-circle"></div>
40
40
+
<div class="skeleton-text"></div>
41
41
+
</div>
42
42
+
</div>
43
43
+
</template>
44
44
+
45
45
+
<style scoped>
46
46
+
.posts-scroll {
47
47
+
display: flex;
48
48
+
gap: 1rem;
49
49
+
overflow-x: auto;
50
50
+
padding-bottom: 1rem;
51
51
+
scroll-snap-type: x mandatory;
52
52
+
align-items: start;
53
53
+
}
54
54
+
55
55
+
.bsky-feed-post {
56
56
+
position: relative;
57
57
+
display: flex;
58
58
+
padding: 0.75rem;
59
59
+
border: 1px solid #e5e7eb;
60
60
+
border-radius: 0.5rem;
61
61
+
width: 19rem;
62
62
+
height: 13rem;
63
63
+
flex-shrink: 0;
64
64
+
scroll-snap-align: start;
65
65
+
}
66
66
+
67
67
+
.main {
68
68
+
display: flex;
69
69
+
gap: 0.5rem;
70
70
+
flex: 1;
71
71
+
min-width: 0;
72
72
+
}
73
73
+
74
74
+
.avatar-skeleton {
75
75
+
width: 2rem;
76
76
+
height: 2rem;
77
77
+
border-radius: 9999px;
78
78
+
flex-shrink: 0;
79
79
+
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
80
80
+
background-size: 200% 100%;
81
81
+
animation: loading 1.5s infinite;
82
82
+
}
83
83
+
84
84
+
.content {
85
85
+
display: flex;
86
86
+
flex-direction: column;
87
87
+
gap: 0.25rem;
88
88
+
min-width: 0;
89
89
+
flex: 1;
90
90
+
}
91
91
+
92
92
+
.header {
93
93
+
display: flex;
94
94
+
align-items: center;
95
95
+
gap: 0.25rem;
96
96
+
font-size: 0.75rem;
97
97
+
}
98
98
+
99
99
+
.name-skeleton {
100
100
+
width: 6rem;
101
101
+
height: 0.75rem;
102
102
+
border-radius: 0.25rem;
103
103
+
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
104
104
+
background-size: 200% 100%;
105
105
+
animation: loading 1.5s infinite;
106
106
+
}
107
107
+
108
108
+
.separator {
109
109
+
color: #d1d5db;
110
110
+
flex-shrink: 0;
111
111
+
}
112
112
+
113
113
+
.date-skeleton {
114
114
+
width: 4rem;
115
115
+
height: 0.75rem;
116
116
+
border-radius: 0.25rem;
117
117
+
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
118
118
+
background-size: 200% 100%;
119
119
+
animation: loading 1.5s infinite;
120
120
+
}
121
121
+
122
122
+
.text-skeleton {
123
123
+
display: flex;
124
124
+
flex-direction: column;
125
125
+
gap: 0.375rem;
126
126
+
margin-top: 0.5rem;
127
127
+
flex: 1;
128
128
+
}
129
129
+
130
130
+
.skeleton-line {
131
131
+
height: 0.625rem;
132
132
+
border-radius: 0.25rem;
133
133
+
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
134
134
+
background-size: 200% 100%;
135
135
+
animation: loading 1.5s infinite;
136
136
+
137
137
+
&.short {
138
138
+
width: 60%;
139
139
+
}
140
140
+
}
141
141
+
142
142
+
.stats-skeleton {
143
143
+
display: flex;
144
144
+
gap: 1rem;
145
145
+
margin-top: auto;
146
146
+
}
147
147
+
148
148
+
.stat-skeleton {
149
149
+
width: 3rem;
150
150
+
height: 0.625rem;
151
151
+
border-radius: 0.25rem;
152
152
+
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
153
153
+
background-size: 200% 100%;
154
154
+
animation: loading 1.5s infinite;
155
155
+
}
156
156
+
157
157
+
.view-more-skeleton {
158
158
+
display: flex;
159
159
+
flex-direction: column;
160
160
+
align-items: center;
161
161
+
justify-content: center;
162
162
+
gap: 0.5rem;
163
163
+
min-width: 10rem;
164
164
+
padding: 1rem;
165
165
+
border: 1px solid #e5e7eb;
166
166
+
border-radius: 0.5rem;
167
167
+
flex-shrink: 0;
168
168
+
scroll-snap-align: start;
169
169
+
align-self: stretch;
170
170
+
}
171
171
+
172
172
+
.skeleton-circle {
173
173
+
width: 1.5rem;
174
174
+
height: 1.5rem;
175
175
+
border-radius: 9999px;
176
176
+
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
177
177
+
background-size: 200% 100%;
178
178
+
animation: loading 1.5s infinite;
179
179
+
}
180
180
+
181
181
+
.skeleton-text {
182
182
+
width: 6rem;
183
183
+
height: 0.75rem;
184
184
+
border-radius: 0.25rem;
185
185
+
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
186
186
+
background-size: 200% 100%;
187
187
+
animation: loading 1.5s infinite;
188
188
+
}
189
189
+
190
190
+
@keyframes loading {
191
191
+
0% {
192
192
+
background-position: 200% 0;
193
193
+
}
194
194
+
195
195
+
100% {
196
196
+
background-position: -200% 0;
197
197
+
}
198
198
+
}
199
199
+
</style>
+80
app/components/page-elements/PostsList.vue
reviewed
···
1
1
+
<script setup lang="ts">
2
2
+
const config = useRuntimeConfig().public;
3
3
+
4
4
+
const { data } = await useAsyncData("postList", () => {
5
5
+
return queryCollectionNavigation("posts", [
6
6
+
"path",
7
7
+
"title",
8
8
+
"date",
9
9
+
"description",
10
10
+
"authors",
11
11
+
"tags"
12
12
+
])
13
13
+
.where("published", "<>", false)
14
14
+
.order("date", "DESC");
15
15
+
});
16
16
+
17
17
+
const posts = data.value ? data.value[0]?.children : [];
18
18
+
19
19
+
const tags =
20
20
+
posts?.reduce((acc, post) => {
21
21
+
for (const tag of post.tags as string[]) {
22
22
+
if (!acc.includes(tag)) {
23
23
+
acc.push(tag);
24
24
+
}
25
25
+
}
26
26
+
return acc;
27
27
+
}, [] as string[]) ?? [];
28
28
+
29
29
+
const filter = ref<string | undefined>(undefined);
30
30
+
31
31
+
const filteredPosts = computed(() => {
32
32
+
if (!filter.value) return posts;
33
33
+
return posts?.filter(
34
34
+
(post) =>
35
35
+
post.tags && (post.tags as string[]).includes(filter.value ?? "")
36
36
+
);
37
37
+
});
38
38
+
</script>
39
39
+
40
40
+
<template>
41
41
+
<section class=" overflow-x-scroll my-6 flex flex-row justify-between items-start gap-4">
42
42
+
<p class="text-sm text-gray-500 dark:text-gray-400 w-max text-nowrap mt-1">
43
43
+
Filter by tag:
44
44
+
</p>
45
45
+
<div class="flex flex-row items-center">
46
46
+
<button
47
47
+
v-for="tag in tags"
48
48
+
:key="tag"
49
49
+
:class="[
50
50
+
'flex px-3 py-1 mr-2 mb-2 w-max flex-row items-center gap-1',
51
51
+
`${tag === filter ? 'bg-stone-500 dark:bg-stone-500 text-stone-100 dark:text-stone-100' : 'bg-stone-200 dark:bg-stone-700 text-stone-600 dark:text-stone-400'}`,
52
52
+
'rounded-full text-sm font-medium lowercase text-nowrap'
53
53
+
]"
54
54
+
@click="filter = filter === tag ? undefined : tag"
55
55
+
>
56
56
+
<Icon
57
57
+
v-if="tag === filter"
58
58
+
name="ri:close-line"
59
59
+
size="1rem"
60
60
+
mode="svg"
61
61
+
class="-ms-1"
62
62
+
/>
63
63
+
{{ tag }}
64
64
+
</button>
65
65
+
</div>
66
66
+
</section>
67
67
+
68
68
+
<PostPreviewAccent
69
69
+
v-if="filteredPosts"
70
70
+
:post="filteredPosts[0]"
71
71
+
/>
72
72
+
73
73
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 mb-8">
74
74
+
<PostPreview
75
75
+
v-for="post in filteredPosts?.slice(1)"
76
76
+
:key="post.path"
77
77
+
:post="post"
78
78
+
/>
79
79
+
</div>
80
80
+
</template>
+26
-75
app/pages/index.vue
reviewed
···
5
5
queryCollection("pages").path("/pages").first()
6
6
);
7
7
8
8
-
const { data } = await useAsyncData("postList", () => {
9
9
-
return queryCollectionNavigation("posts", [
10
10
-
"path",
11
11
-
"title",
12
12
-
"date",
13
13
-
"description",
14
14
-
"authors",
15
15
-
"tags"
16
16
-
])
17
17
-
.where("published", "<>", false)
18
18
-
.order("date", "DESC");
19
19
-
});
20
20
-
21
21
-
const posts = data.value ? data.value[0]?.children : [];
22
22
-
23
8
defineOgImageComponent("Page", {
24
24
-
description: `This is ${config.title}. Read all ${posts?.length || 0} posts published so far, and stay tuned for more!`
25
25
-
});
26
26
-
27
27
-
const tags =
28
28
-
posts?.reduce((acc, post) => {
29
29
-
for (const tag of post.tags as string[]) {
30
30
-
if (!acc.includes(tag)) {
31
31
-
acc.push(tag);
32
32
-
}
33
33
-
}
34
34
-
return acc;
35
35
-
}, [] as string[]) ?? [];
36
36
-
37
37
-
const filter = ref<string | undefined>(undefined);
38
38
-
39
39
-
const filteredPosts = computed(() => {
40
40
-
if (!filter.value) return posts;
41
41
-
return posts?.filter(
42
42
-
(post) =>
43
43
-
post.tags && (post.tags as string[]).includes(filter.value ?? "")
44
44
-
);
9
9
+
description: `This is ${config.title}.`
45
10
});
46
11
</script>
47
12
···
55
20
</p>
56
21
</header>
57
22
58
58
-
<h2 class="text-2xl font-bold my-6">
59
59
-
Posts
60
60
-
</h2>
23
23
+
<div class="flex flex-col my-6">
24
24
+
<h2 class="text-2xl font-bold">
25
25
+
Bluesky Posts
26
26
+
</h2>
27
27
+
<p class="text-gray-500">
28
28
+
My latest short-form
29
29
+
<a href="https://bsky.app" class="underline">Bluesky</a>
30
30
+
posts
31
31
+
</p>
32
32
+
</div>
61
33
62
62
-
<section class=" overflow-x-scroll my-6 flex flex-row justify-between items-start gap-4">
63
63
-
<p class="text-sm text-gray-500 dark:text-gray-400 w-max text-nowrap mt-1">
64
64
-
Filter by tag:
65
65
-
</p>
66
66
-
<div class="flex flex-row items-center">
67
67
-
<button
68
68
-
v-for="tag in tags"
69
69
-
:key="tag"
70
70
-
:class="[
71
71
-
'flex px-3 py-1 mr-2 mb-2 w-max flex-row items-center gap-1',
72
72
-
`${tag === filter ? 'bg-stone-500 dark:bg-stone-500 text-stone-100 dark:text-stone-100' : 'bg-stone-200 dark:bg-stone-700 text-stone-600 dark:text-stone-400'}`,
73
73
-
'rounded-full text-sm font-medium lowercase text-nowrap'
74
74
-
]"
75
75
-
@click="filter = filter === tag ? undefined : tag"
76
76
-
>
77
77
-
<Icon
78
78
-
v-if="tag === filter"
79
79
-
name="ri:close-line"
80
80
-
size="1rem"
81
81
-
mode="svg"
82
82
-
class="-ms-1"
83
83
-
/>
84
84
-
{{ tag }}
85
85
-
</button>
86
86
-
</div>
87
87
-
</section>
34
34
+
<Suspense>
35
35
+
<PageElementsBskyPosts />
88
36
89
89
-
<PostPreviewAccent
90
90
-
v-if="filteredPosts"
91
91
-
:post="filteredPosts[0]"
92
92
-
/>
37
37
+
<template #fallback>
38
38
+
<PageElementsBskyPostsSkeleton />
39
39
+
</template>
40
40
+
</Suspense>
93
41
94
94
-
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 mb-8">
95
95
-
<PostPreview
96
96
-
v-for="post in filteredPosts?.slice(1)"
97
97
-
:key="post.path"
98
98
-
:post="post"
99
99
-
/>
42
42
+
<div class="flex flex-col my-6">
43
43
+
<h2 class="text-2xl font-bold">
44
44
+
Blog Posts
45
45
+
</h2>
46
46
+
<p class="text-gray-500">
47
47
+
All my long-form blog posts about various, filterable topics
48
48
+
</p>
100
49
</div>
50
50
+
51
51
+
<PageElementsPostsList />
101
52
</template>
+30
-2
app/util/atproto.ts
reviewed
···
1
1
-
import { Client, simpleFetchHandler } from "@atcute/client";
2
1
import type { AppBskyFeedDefs } from "@atcute/bluesky";
2
2
+
import { Client, simpleFetchHandler } from "@atcute/client";
3
3
import type { ResourceUri } from "@atcute/lexicons";
4
4
5
5
import config from "@/../blog.config";
6
6
+
import type { BlogConfig } from "~~/globals";
7
7
+
8
8
+
const authorDid = config.authorDid as BlogConfig["authorDid"];
6
9
7
10
const handler = simpleFetchHandler({
8
11
// Simply hit up the Bluesky API
9
9
-
service: "https://public.api.bsky.app"
12
12
+
service: "https://api.pop1.bsky.app"
10
13
});
11
14
const rpc = new Client({ handler });
12
15
···
56
59
}
57
60
return "";
58
61
}
62
62
+
63
63
+
/**
64
64
+
* Fetch posts from the configured author's feed (excludes reposts)
65
65
+
* @param limit Number of posts to fetch (default 50, max 100)
66
66
+
* @param cursor Pagination cursor for fetching more posts
67
67
+
* @returns Array of feed view posts with author info included
68
68
+
*/
69
69
+
export async function getBskyPosts(limit = 50, cursor?: string) {
70
70
+
const { ok, data } = await rpc.get("app.bsky.feed.getAuthorFeed", {
71
71
+
params: {
72
72
+
actor: authorDid,
73
73
+
limit,
74
74
+
cursor,
75
75
+
filter: "posts_and_author_threads" // Only get author's own posts, no reposts
76
76
+
}
77
77
+
});
78
78
+
79
79
+
if (!ok) {
80
80
+
console.error("Error fetching author feed:", data.error);
81
81
+
return [];
82
82
+
}
83
83
+
84
84
+
// Filter out any reposts just in case
85
85
+
return data.feed.filter((item) => !item.reason);
86
86
+
}
+2
-2
deno.jsonc
reviewed
···
1
1
{
2
2
"deploy": {
3
3
-
"org": "finxol",
4
4
-
"app": "blogging"
3
3
+
"org": "finxol",
4
4
+
"app": "blogging"
5
5
}
6
6
}