tangled
alpha
login
or
join now
flo-bit.dev
/
blento
21
fork
atom
your personal website on atproto - mirror
blento.app
21
fork
atom
overview
issues
pulls
pipelines
blog stuff pt1
Florian
3 weeks ago
1cc74545
50224195
+531
4 changed files
expand all
collapse all
unified
split
src
routes
[[actor=actor]]
blog
+page.server.ts
+page.svelte
[rkey]
+page.server.ts
+page.svelte
+102
src/routes/[[actor=actor]]/blog/+page.server.ts
···
1
1
+
import { error } from '@sveltejs/kit';
2
2
+
import { getBlentoOrBskyProfile, getRecord, listRecords, parseUri } from '$lib/atproto/methods.js';
3
3
+
import { createCache, type CachedProfile } from '$lib/cache';
4
4
+
import type { Did } from '@atcute/lexicons';
5
5
+
import { getActor } from '$lib/actor.js';
6
6
+
7
7
+
export async function load({ params, platform, request }) {
8
8
+
const cache = createCache(platform);
9
9
+
10
10
+
const did = await getActor({ request, paramActor: params.actor, platform });
11
11
+
12
12
+
if (!did) {
13
13
+
throw error(404, 'Blog not found');
14
14
+
}
15
15
+
16
16
+
try {
17
17
+
const [records, hostProfile] = await Promise.all([
18
18
+
listRecords({
19
19
+
did: did as Did,
20
20
+
collection: 'site.standard.document',
21
21
+
limit: 100
22
22
+
}),
23
23
+
cache
24
24
+
? cache.getProfile(did as Did).catch(() => null)
25
25
+
: getBlentoOrBskyProfile({ did: did as Did })
26
26
+
.then(
27
27
+
(p): CachedProfile => ({
28
28
+
did: p.did as string,
29
29
+
handle: p.handle as string,
30
30
+
displayName: p.displayName as string | undefined,
31
31
+
avatar: p.avatar as string | undefined,
32
32
+
hasBlento: p.hasBlento,
33
33
+
url: p.url
34
34
+
})
35
35
+
)
36
36
+
.catch(() => null)
37
37
+
]);
38
38
+
39
39
+
// Resolve publication URLs for site fields
40
40
+
const publications: Record<string, string> = {};
41
41
+
42
42
+
for (const record of records) {
43
43
+
const site = record.value.site as string;
44
44
+
if (!site) continue;
45
45
+
46
46
+
if (site.startsWith('at://')) {
47
47
+
if (!publications[site]) {
48
48
+
const siteParts = parseUri(site);
49
49
+
if (!siteParts) continue;
50
50
+
51
51
+
try {
52
52
+
const publicationRecord = await getRecord({
53
53
+
did: siteParts.repo as Did,
54
54
+
collection: siteParts.collection!,
55
55
+
rkey: siteParts.rkey
56
56
+
});
57
57
+
58
58
+
if (publicationRecord.value?.url) {
59
59
+
publications[site] = publicationRecord.value.url as string;
60
60
+
}
61
61
+
} catch {
62
62
+
continue;
63
63
+
}
64
64
+
}
65
65
+
66
66
+
if (publications[site]) {
67
67
+
record.value.href = publications[site] + record.value.path;
68
68
+
}
69
69
+
} else {
70
70
+
record.value.href = site + record.value.path;
71
71
+
}
72
72
+
}
73
73
+
74
74
+
const posts = records
75
75
+
.filter((r) => r.value?.href)
76
76
+
.map((r) => {
77
77
+
const value = r.value as Record<string, unknown>;
78
78
+
return {
79
79
+
title: value.title as string,
80
80
+
description: value.description as string | undefined,
81
81
+
publishedAt: value.publishedAt as string | undefined,
82
82
+
href: value.href as string,
83
83
+
coverImage: value.coverImage as { $type: 'blob'; ref: { $link: string } } | undefined,
84
84
+
rkey: r.uri.split('/').pop() as string
85
85
+
};
86
86
+
})
87
87
+
.sort((a, b) => {
88
88
+
const dateA = a.publishedAt || '';
89
89
+
const dateB = b.publishedAt || '';
90
90
+
return dateB.localeCompare(dateA);
91
91
+
});
92
92
+
93
93
+
return {
94
94
+
posts,
95
95
+
did,
96
96
+
hostProfile: hostProfile ?? null
97
97
+
};
98
98
+
} catch (e) {
99
99
+
if (e && typeof e === 'object' && 'status' in e) throw e;
100
100
+
throw error(404, 'Blog not found');
101
101
+
}
102
102
+
}
+105
src/routes/[[actor=actor]]/blog/+page.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { getCDNImageBlobUrl } from '$lib/atproto';
3
3
+
import { Avatar as FoxAvatar } from '@foxui/core';
4
4
+
5
5
+
let { data } = $props();
6
6
+
7
7
+
let posts = $derived(data.posts);
8
8
+
let did: string = $derived(data.did);
9
9
+
let hostProfile = $derived(data.hostProfile);
10
10
+
11
11
+
let hostName = $derived(hostProfile?.displayName || hostProfile?.handle || did);
12
12
+
let hostUrl = $derived(
13
13
+
hostProfile?.url ?? `https://bsky.app/profile/${hostProfile?.handle || did}`
14
14
+
);
15
15
+
16
16
+
function formatDate(dateStr: string): string {
17
17
+
const date = new Date(dateStr);
18
18
+
const options: Intl.DateTimeFormatOptions = {
19
19
+
year: 'numeric',
20
20
+
month: 'long',
21
21
+
day: 'numeric'
22
22
+
};
23
23
+
return date.toLocaleDateString('en-US', options);
24
24
+
}
25
25
+
26
26
+
function getCoverUrl(
27
27
+
coverImage: { $type: 'blob'; ref: { $link: string } } | undefined
28
28
+
): string | undefined {
29
29
+
if (!coverImage) return undefined;
30
30
+
return getCDNImageBlobUrl({ did, blob: coverImage, type: 'jpeg' });
31
31
+
}
32
32
+
</script>
33
33
+
34
34
+
<svelte:head>
35
35
+
<title>{hostName} - Blog</title>
36
36
+
<meta name="description" content="Blog posts by {hostName}" />
37
37
+
<meta property="og:title" content="{hostName} - Blog" />
38
38
+
<meta property="og:description" content="Blog posts by {hostName}" />
39
39
+
<meta name="twitter:card" content="summary" />
40
40
+
<meta name="twitter:title" content="{hostName} - Blog" />
41
41
+
<meta name="twitter:description" content="Blog posts by {hostName}" />
42
42
+
</svelte:head>
43
43
+
44
44
+
<div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12">
45
45
+
<div class="mx-auto max-w-4xl">
46
46
+
<!-- Header -->
47
47
+
<div class="mb-8">
48
48
+
<h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl">Blog</h1>
49
49
+
<div class="mt-4 flex items-center gap-2">
50
50
+
<span class="text-base-500 dark:text-base-400 text-sm">Written by</span>
51
51
+
<a
52
52
+
href={hostUrl}
53
53
+
target={hostProfile?.hasBlento ? undefined : '_blank'}
54
54
+
rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'}
55
55
+
class="flex items-center gap-1.5 hover:underline"
56
56
+
>
57
57
+
<FoxAvatar src={hostProfile?.avatar} alt={hostName} class="size-5 shrink-0" />
58
58
+
<span class="text-base-900 dark:text-base-100 text-sm font-medium">{hostName}</span>
59
59
+
</a>
60
60
+
</div>
61
61
+
</div>
62
62
+
63
63
+
{#if posts.length === 0}
64
64
+
<p class="text-base-500 dark:text-base-400 py-12 text-center">No blog posts found.</p>
65
65
+
{:else}
66
66
+
<div class="divide-base-200 dark:divide-base-800 divide-y">
67
67
+
{#each posts as post (post.rkey)}
68
68
+
{@const coverUrl = getCoverUrl(post.coverImage)}
69
69
+
<a
70
70
+
href={post.href}
71
71
+
target="_blank"
72
72
+
rel="noopener noreferrer"
73
73
+
class="group flex items-start gap-4 py-6"
74
74
+
>
75
75
+
<div class="min-w-0 flex-1">
76
76
+
{#if post.publishedAt}
77
77
+
<p class="text-base-500 dark:text-base-400 mb-1 text-sm">
78
78
+
{formatDate(post.publishedAt)}
79
79
+
</p>
80
80
+
{/if}
81
81
+
<h2
82
82
+
class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mb-2 text-lg leading-snug font-semibold"
83
83
+
>
84
84
+
{post.title}
85
85
+
</h2>
86
86
+
{#if post.description}
87
87
+
<p class="text-base-600 dark:text-base-400 line-clamp-3 text-sm leading-relaxed">
88
88
+
{post.description}
89
89
+
</p>
90
90
+
{/if}
91
91
+
</div>
92
92
+
93
93
+
{#if coverUrl}
94
94
+
<img
95
95
+
src={coverUrl}
96
96
+
alt={post.title}
97
97
+
class="aspect-video w-32 shrink-0 rounded-lg object-cover"
98
98
+
/>
99
99
+
{/if}
100
100
+
</a>
101
101
+
{/each}
102
102
+
</div>
103
103
+
{/if}
104
104
+
</div>
105
105
+
</div>
+86
src/routes/[[actor=actor]]/blog/[rkey]/+page.server.ts
···
1
1
+
import { error } from '@sveltejs/kit';
2
2
+
import { getBlentoOrBskyProfile, getRecord, parseUri } from '$lib/atproto/methods.js';
3
3
+
import { createCache, type CachedProfile } from '$lib/cache';
4
4
+
import type { Did } from '@atcute/lexicons';
5
5
+
import { getActor } from '$lib/actor';
6
6
+
7
7
+
export async function load({ params, platform, request }) {
8
8
+
const { rkey } = params;
9
9
+
10
10
+
const cache = createCache(platform);
11
11
+
12
12
+
const did = await getActor({ request, paramActor: params.actor, platform });
13
13
+
14
14
+
if (!did || !rkey) {
15
15
+
throw error(404, 'Post not found');
16
16
+
}
17
17
+
18
18
+
try {
19
19
+
const [postRecord, hostProfile] = await Promise.all([
20
20
+
getRecord({
21
21
+
did: did as Did,
22
22
+
collection: 'site.standard.document',
23
23
+
rkey
24
24
+
}),
25
25
+
cache
26
26
+
? cache.getProfile(did as Did).catch(() => null)
27
27
+
: getBlentoOrBskyProfile({ did: did as Did })
28
28
+
.then(
29
29
+
(p): CachedProfile => ({
30
30
+
did: p.did as string,
31
31
+
handle: p.handle as string,
32
32
+
displayName: p.displayName as string | undefined,
33
33
+
avatar: p.avatar as string | undefined,
34
34
+
hasBlento: p.hasBlento,
35
35
+
url: p.url
36
36
+
})
37
37
+
)
38
38
+
.catch(() => null)
39
39
+
]);
40
40
+
41
41
+
if (!postRecord?.value) {
42
42
+
throw error(404, 'Post not found');
43
43
+
}
44
44
+
45
45
+
const post = postRecord.value as Record<string, unknown>;
46
46
+
47
47
+
// Resolve external URL
48
48
+
let externalUrl: string | null = null;
49
49
+
const site = post.site as string | undefined;
50
50
+
const path = post.path as string | undefined;
51
51
+
52
52
+
if (site && path) {
53
53
+
if (site.startsWith('at://')) {
54
54
+
const siteParts = parseUri(site);
55
55
+
if (siteParts) {
56
56
+
try {
57
57
+
const publicationRecord = await getRecord({
58
58
+
did: siteParts.repo as Did,
59
59
+
collection: siteParts.collection!,
60
60
+
rkey: siteParts.rkey
61
61
+
});
62
62
+
63
63
+
if (publicationRecord.value?.url) {
64
64
+
externalUrl = (publicationRecord.value.url as string) + path;
65
65
+
}
66
66
+
} catch {
67
67
+
// Could not resolve publication URL
68
68
+
}
69
69
+
}
70
70
+
} else {
71
71
+
externalUrl = site + path;
72
72
+
}
73
73
+
}
74
74
+
75
75
+
return {
76
76
+
post,
77
77
+
did,
78
78
+
rkey,
79
79
+
hostProfile: hostProfile ?? null,
80
80
+
externalUrl
81
81
+
};
82
82
+
} catch (e) {
83
83
+
if (e && typeof e === 'object' && 'status' in e) throw e;
84
84
+
throw error(404, 'Post not found');
85
85
+
}
86
86
+
}
+238
src/routes/[[actor=actor]]/blog/[rkey]/+page.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { getCDNImageBlobUrl } from '$lib/atproto';
3
3
+
import { Avatar as FoxAvatar } from '@foxui/core';
4
4
+
import { marked } from 'marked';
5
5
+
import { sanitize } from '$lib/sanitize';
6
6
+
7
7
+
let { data } = $props();
8
8
+
9
9
+
let post = $derived(data.post as Record<string, unknown>);
10
10
+
let did: string = $derived(data.did);
11
11
+
let hostProfile = $derived(data.hostProfile);
12
12
+
let externalUrl = $derived(data.externalUrl as string | null);
13
13
+
14
14
+
let title = $derived((post.title as string) || '');
15
15
+
let description = $derived((post.description as string) || '');
16
16
+
let publishedAt = $derived(post.publishedAt as string | undefined);
17
17
+
18
18
+
let hostName = $derived(hostProfile?.displayName || hostProfile?.handle || did);
19
19
+
let hostUrl = $derived(
20
20
+
hostProfile?.url ?? `https://bsky.app/profile/${hostProfile?.handle || did}`
21
21
+
);
22
22
+
23
23
+
let actorPrefix = $derived(hostProfile?.handle ? `/${hostProfile.handle}` : `/${did}`);
24
24
+
25
25
+
let coverUrl = $derived.by(() => {
26
26
+
const coverImage = post.coverImage as { $type: 'blob'; ref: { $link: string } } | undefined;
27
27
+
if (!coverImage) return undefined;
28
28
+
return getCDNImageBlobUrl({ did, blob: coverImage, type: 'jpeg' });
29
29
+
});
30
30
+
31
31
+
let content = $derived(post.content as { $type: string; value: string } | undefined);
32
32
+
let isMarkdown = $derived(content?.$type === 'app.blento.markdown');
33
33
+
34
34
+
let tags = $derived((post.tags as string[]) || []);
35
35
+
let bskyPostRef = $derived(post.bskyPostRef as { uri: string; cid: string } | undefined);
36
36
+
37
37
+
let bskyDiscussUrl = $derived.by(() => {
38
38
+
if (!bskyPostRef?.uri) return undefined;
39
39
+
const parts = bskyPostRef.uri.split('/');
40
40
+
const postDid = parts[2];
41
41
+
const postRkey = parts[parts.length - 1];
42
42
+
return `https://bsky.app/profile/${postDid}/post/${postRkey}`;
43
43
+
});
44
44
+
45
45
+
function formatDate(dateStr: string): string {
46
46
+
const date = new Date(dateStr);
47
47
+
return date.toLocaleDateString('en-US', {
48
48
+
year: 'numeric',
49
49
+
month: 'long',
50
50
+
day: 'numeric'
51
51
+
});
52
52
+
}
53
53
+
54
54
+
const renderer = new marked.Renderer();
55
55
+
renderer.link = ({ href, title, text }) =>
56
56
+
`<a target="_blank" rel="noopener noreferrer" href="${href}" title="${title ?? ''}">${text}</a>`;
57
57
+
</script>
58
58
+
59
59
+
<svelte:head>
60
60
+
<title>{title}</title>
61
61
+
<meta name="description" content={description || `Blog post: ${title}`} />
62
62
+
<meta property="og:title" content={title} />
63
63
+
<meta property="og:description" content={description || `Blog post: ${title}`} />
64
64
+
{#if coverUrl}
65
65
+
<meta property="og:image" content={coverUrl} />
66
66
+
{/if}
67
67
+
<meta name="twitter:card" content={coverUrl ? 'summary_large_image' : 'summary'} />
68
68
+
<meta name="twitter:title" content={title} />
69
69
+
<meta name="twitter:description" content={description || `Blog post: ${title}`} />
70
70
+
{#if coverUrl}
71
71
+
<meta name="twitter:image" content={coverUrl} />
72
72
+
{/if}
73
73
+
</svelte:head>
74
74
+
75
75
+
<div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12">
76
76
+
<div class="mx-auto max-w-3xl">
77
77
+
<!-- Cover image -->
78
78
+
{#if coverUrl}
79
79
+
<img src={coverUrl} alt={title} class="mb-8 aspect-video w-full rounded-2xl object-cover" />
80
80
+
{/if}
81
81
+
82
82
+
<!-- Title & meta -->
83
83
+
<header class="mb-8">
84
84
+
<h1 class="text-base-900 dark:text-base-50 mb-4 text-3xl leading-tight font-bold sm:text-4xl">
85
85
+
{title}
86
86
+
</h1>
87
87
+
88
88
+
<div class="flex flex-wrap items-center gap-4">
89
89
+
{#if publishedAt}
90
90
+
<span class="text-base-500 dark:text-base-400 text-sm">
91
91
+
{formatDate(publishedAt)}
92
92
+
</span>
93
93
+
{/if}
94
94
+
<a
95
95
+
href={hostUrl}
96
96
+
target={hostProfile?.hasBlento ? undefined : '_blank'}
97
97
+
rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'}
98
98
+
class="flex items-center gap-1.5 hover:underline"
99
99
+
>
100
100
+
<FoxAvatar src={hostProfile?.avatar} alt={hostName} class="size-5 shrink-0" />
101
101
+
<span class="text-base-900 dark:text-base-100 text-sm font-medium">{hostName}</span>
102
102
+
</a>
103
103
+
</div>
104
104
+
105
105
+
{#if tags.length > 0}
106
106
+
<div class="mt-4 flex flex-wrap gap-2">
107
107
+
{#each tags as tag (tag)}
108
108
+
<span
109
109
+
class="bg-base-100 dark:bg-base-800 text-base-600 dark:text-base-300 rounded-full px-3 py-1 text-xs font-medium"
110
110
+
>
111
111
+
{tag}
112
112
+
</span>
113
113
+
{/each}
114
114
+
</div>
115
115
+
{/if}
116
116
+
</header>
117
117
+
118
118
+
<!-- Content -->
119
119
+
{#if isMarkdown && content}
120
120
+
<article
121
121
+
class="prose dark:prose-invert prose-base prose-neutral prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-img:rounded-xl max-w-none"
122
122
+
>
123
123
+
{@html sanitize(marked.parse(content.value, { renderer }) as string, {
124
124
+
ADD_ATTR: ['target']
125
125
+
})}
126
126
+
</article>
127
127
+
{:else}
128
128
+
<div class="py-4">
129
129
+
{#if description}
130
130
+
<p class="text-base-700 dark:text-base-300 mb-6 text-lg leading-relaxed">
131
131
+
{description}
132
132
+
</p>
133
133
+
{/if}
134
134
+
135
135
+
{#if externalUrl}
136
136
+
<a
137
137
+
href={externalUrl}
138
138
+
target="_blank"
139
139
+
rel="noopener noreferrer"
140
140
+
class="bg-base-900 dark:bg-base-50 text-base-50 dark:text-base-900 hover:bg-base-800 dark:hover:bg-base-200 inline-flex items-center gap-2 rounded-lg px-5 py-3 text-sm font-medium transition-colors"
141
141
+
>
142
142
+
Read on original page
143
143
+
<svg
144
144
+
xmlns="http://www.w3.org/2000/svg"
145
145
+
fill="none"
146
146
+
viewBox="0 0 24 24"
147
147
+
stroke-width="2"
148
148
+
stroke="currentColor"
149
149
+
class="size-4"
150
150
+
>
151
151
+
<path
152
152
+
stroke-linecap="round"
153
153
+
stroke-linejoin="round"
154
154
+
d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25"
155
155
+
/>
156
156
+
</svg>
157
157
+
</a>
158
158
+
{/if}
159
159
+
</div>
160
160
+
{/if}
161
161
+
162
162
+
<!-- Footer -->
163
163
+
<footer
164
164
+
class="border-base-200 dark:border-base-800 mt-12 flex flex-wrap items-center gap-4 border-t pt-6"
165
165
+
>
166
166
+
<a
167
167
+
href="{actorPrefix}/blog"
168
168
+
class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 inline-flex items-center gap-1.5 text-sm transition-colors"
169
169
+
>
170
170
+
<svg
171
171
+
xmlns="http://www.w3.org/2000/svg"
172
172
+
fill="none"
173
173
+
viewBox="0 0 24 24"
174
174
+
stroke-width="2"
175
175
+
stroke="currentColor"
176
176
+
class="size-4"
177
177
+
>
178
178
+
<path
179
179
+
stroke-linecap="round"
180
180
+
stroke-linejoin="round"
181
181
+
d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"
182
182
+
/>
183
183
+
</svg>
184
184
+
Back to blog
185
185
+
</a>
186
186
+
187
187
+
{#if bskyDiscussUrl}
188
188
+
<a
189
189
+
href={bskyDiscussUrl}
190
190
+
target="_blank"
191
191
+
rel="noopener noreferrer"
192
192
+
class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 inline-flex items-center gap-1.5 text-sm transition-colors"
193
193
+
>
194
194
+
Discuss on Bluesky
195
195
+
<svg
196
196
+
xmlns="http://www.w3.org/2000/svg"
197
197
+
fill="none"
198
198
+
viewBox="0 0 24 24"
199
199
+
stroke-width="2"
200
200
+
stroke="currentColor"
201
201
+
class="size-3.5"
202
202
+
>
203
203
+
<path
204
204
+
stroke-linecap="round"
205
205
+
stroke-linejoin="round"
206
206
+
d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25"
207
207
+
/>
208
208
+
</svg>
209
209
+
</a>
210
210
+
{/if}
211
211
+
212
212
+
{#if externalUrl && isMarkdown}
213
213
+
<a
214
214
+
href={externalUrl}
215
215
+
target="_blank"
216
216
+
rel="noopener noreferrer"
217
217
+
class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 inline-flex items-center gap-1.5 text-sm transition-colors"
218
218
+
>
219
219
+
View on original page
220
220
+
<svg
221
221
+
xmlns="http://www.w3.org/2000/svg"
222
222
+
fill="none"
223
223
+
viewBox="0 0 24 24"
224
224
+
stroke-width="2"
225
225
+
stroke="currentColor"
226
226
+
class="size-3.5"
227
227
+
>
228
228
+
<path
229
229
+
stroke-linecap="round"
230
230
+
stroke-linejoin="round"
231
231
+
d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25"
232
232
+
/>
233
233
+
</svg>
234
234
+
</a>
235
235
+
{/if}
236
236
+
</footer>
237
237
+
</div>
238
238
+
</div>