tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
guestbook card
unbedenklich
1 week ago
f356d837
cda2c9ee
+466
-7
10 changed files
expand all
collapse all
unified
split
.claude
settings.local.json
src
lib
atproto
index.ts
methods.ts
settings.ts
cards
GuestbookCard
CreateGuestbookCardModal.svelte
GuestbookCard.svelte
index.ts
index.ts
components
bluesky-post
BlueskyPost.svelte
post
Post.svelte
+2
-1
.claude/settings.local.json
···
24
24
"Bash(pnpm dev)",
25
25
"Bash(pnpm exec svelte-kit:*)",
26
26
"Bash(pnpm build:*)",
27
27
-
"Bash(pnpm remove:*)"
27
27
+
"Bash(pnpm remove:*)",
28
28
+
"Bash(grep:*)"
28
29
]
29
30
}
30
31
}
+3
-1
src/lib/atproto/index.ts
···
16
16
getBlobURL,
17
17
getCDNImageBlobUrl,
18
18
searchActorsTypeahead,
19
19
-
getAuthorFeed
19
19
+
getAuthorFeed,
20
20
+
getPostThread,
21
21
+
createPost
20
22
} from './methods';
+69
src/lib/atproto/methods.ts
···
465
465
return profile.did;
466
466
}
467
467
}
468
468
+
469
469
+
/**
470
470
+
* Fetches a post's thread including replies.
471
471
+
* @param uri - The AT URI of the post
472
472
+
* @param depth - How many levels of replies to fetch (default 1)
473
473
+
* @param client - The client to use (defaults to public Bluesky API)
474
474
+
* @returns The thread data or undefined on failure
475
475
+
*/
476
476
+
export async function getPostThread({
477
477
+
uri,
478
478
+
depth = 1,
479
479
+
client
480
480
+
}: {
481
481
+
uri: string;
482
482
+
depth?: number;
483
483
+
client?: Client;
484
484
+
}) {
485
485
+
client ??= new Client({
486
486
+
handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
487
487
+
});
488
488
+
489
489
+
const response = await client.get('app.bsky.feed.getPostThread', {
490
490
+
params: { uri: uri as ResourceUri, depth }
491
491
+
});
492
492
+
493
493
+
if (!response.ok) return;
494
494
+
495
495
+
return response.data.thread;
496
496
+
}
497
497
+
498
498
+
/**
499
499
+
* Creates a Bluesky post on the authenticated user's account.
500
500
+
* @param text - The post text
501
501
+
* @param facets - Optional rich text facets (links, mentions, etc.)
502
502
+
* @returns The response containing the post's URI and CID
503
503
+
* @throws If the user is not logged in
504
504
+
*/
505
505
+
export async function createPost({
506
506
+
text,
507
507
+
facets
508
508
+
}: {
509
509
+
text: string;
510
510
+
facets?: Array<{
511
511
+
index: { byteStart: number; byteEnd: number };
512
512
+
features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>;
513
513
+
}>;
514
514
+
}) {
515
515
+
if (!user.client || !user.did) throw new Error('No client or did');
516
516
+
517
517
+
const record: Record<string, unknown> = {
518
518
+
$type: 'app.bsky.feed.post',
519
519
+
text,
520
520
+
createdAt: new Date().toISOString()
521
521
+
};
522
522
+
523
523
+
if (facets) {
524
524
+
record.facets = facets;
525
525
+
}
526
526
+
527
527
+
const response = await user.client.post('com.atproto.repo.createRecord', {
528
528
+
input: {
529
529
+
collection: 'app.bsky.feed.post',
530
530
+
repo: user.did,
531
531
+
record
532
532
+
}
533
533
+
});
534
534
+
535
535
+
return response;
536
536
+
}
+1
src/lib/atproto/settings.ts
···
20
20
'app.blento.settings',
21
21
'app.blento.comment',
22
22
'app.blento.guestbook.entry',
23
23
+
'app.bsky.feed.post',
23
24
'site.standard.publication',
24
25
'site.standard.document',
25
26
'xyz.statusphere.status'
+166
src/lib/cards/GuestbookCard/CreateGuestbookCardModal.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { Alert, Button, Input, Modal, Subheading } from '@foxui/core';
3
3
+
import type { CreationModalComponentProps } from '../types';
4
4
+
import { createPost } from '$lib/atproto/methods';
5
5
+
import { user } from '$lib/atproto/auth.svelte';
6
6
+
import { parseBlueskyPostUrl } from '../BlueskyPostCard/utils';
7
7
+
8
8
+
let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props();
9
9
+
10
10
+
let mode = $state<'create' | 'existing'>('create');
11
11
+
12
12
+
const profileUrl = `https://blento.app/${user.profile?.handle ?? ''}`;
13
13
+
let postText = $state(`Comment on this post to appear on my Blento! ${profileUrl}`);
14
14
+
let postUrl = $state('');
15
15
+
let isPosting = $state(false);
16
16
+
let errorMessage = $state('');
17
17
+
18
18
+
function buildFacets(text: string, url: string) {
19
19
+
const encoder = new TextEncoder();
20
20
+
const encoded = encoder.encode(text);
21
21
+
const urlBytes = encoder.encode(url);
22
22
+
23
23
+
let byteStart = -1;
24
24
+
for (let i = 0; i <= encoded.length - urlBytes.length; i++) {
25
25
+
let match = true;
26
26
+
for (let j = 0; j < urlBytes.length; j++) {
27
27
+
if (encoded[i + j] !== urlBytes[j]) {
28
28
+
match = false;
29
29
+
break;
30
30
+
}
31
31
+
}
32
32
+
if (match) {
33
33
+
byteStart = i;
34
34
+
break;
35
35
+
}
36
36
+
}
37
37
+
38
38
+
if (byteStart === -1) return undefined;
39
39
+
40
40
+
return [
41
41
+
{
42
42
+
index: { byteStart, byteEnd: byteStart + urlBytes.length },
43
43
+
features: [{ $type: 'app.bsky.richtext.facet#link', uri: url }]
44
44
+
}
45
45
+
];
46
46
+
}
47
47
+
48
48
+
async function handleCreateNew() {
49
49
+
if (!postText.trim()) {
50
50
+
errorMessage = 'Post text cannot be empty.';
51
51
+
return;
52
52
+
}
53
53
+
54
54
+
isPosting = true;
55
55
+
errorMessage = '';
56
56
+
57
57
+
try {
58
58
+
const facets = buildFacets(postText, profileUrl);
59
59
+
const response = await createPost({ text: postText, facets });
60
60
+
61
61
+
if (!response.ok) {
62
62
+
throw new Error('Failed to create post');
63
63
+
}
64
64
+
65
65
+
item.cardData.uri = response.data.uri;
66
66
+
67
67
+
const rkey = response.data.uri.split('/').pop();
68
68
+
item.cardData.href = `https://bsky.app/profile/${user.profile?.handle}/post/${rkey}`;
69
69
+
70
70
+
oncreate();
71
71
+
} catch (err) {
72
72
+
errorMessage =
73
73
+
err instanceof Error ? err.message : 'Failed to create post. Please try again.';
74
74
+
} finally {
75
75
+
isPosting = false;
76
76
+
}
77
77
+
}
78
78
+
79
79
+
function handleExisting() {
80
80
+
errorMessage = '';
81
81
+
const parsed = parseBlueskyPostUrl(postUrl.trim());
82
82
+
83
83
+
if (!parsed) {
84
84
+
errorMessage =
85
85
+
'Invalid URL. Please enter a valid Bluesky post URL (e.g., https://bsky.app/profile/handle/post/...)';
86
86
+
return;
87
87
+
}
88
88
+
89
89
+
item.cardData.uri = `at://${parsed.handle}/app.bsky.feed.post/${parsed.rkey}`;
90
90
+
item.cardData.href = postUrl.trim();
91
91
+
92
92
+
oncreate();
93
93
+
}
94
94
+
95
95
+
async function handleSubmit() {
96
96
+
if (mode === 'create') {
97
97
+
await handleCreateNew();
98
98
+
} else {
99
99
+
handleExisting();
100
100
+
}
101
101
+
}
102
102
+
</script>
103
103
+
104
104
+
<Modal open={true} closeButton={false}>
105
105
+
<form
106
106
+
onsubmit={(e) => {
107
107
+
e.preventDefault();
108
108
+
handleSubmit();
109
109
+
}}
110
110
+
class="flex flex-col gap-2"
111
111
+
>
112
112
+
<Subheading>Guestbook</Subheading>
113
113
+
114
114
+
<div class="flex gap-2">
115
115
+
<Button
116
116
+
size="sm"
117
117
+
variant="ghost"
118
118
+
class={mode === 'create' ? 'bg-base-200 dark:bg-base-700' : ''}
119
119
+
onclick={() => (mode = 'create')}
120
120
+
>
121
121
+
Create new post
122
122
+
</Button>
123
123
+
<Button
124
124
+
size="sm"
125
125
+
variant="ghost"
126
126
+
class={mode === 'existing' ? 'bg-base-200 dark:bg-base-700' : ''}
127
127
+
onclick={() => (mode = 'existing')}
128
128
+
>
129
129
+
Use existing post
130
130
+
</Button>
131
131
+
</div>
132
132
+
133
133
+
{#if mode === 'create'}
134
134
+
<p class="text-base-500 dark:text-base-400 text-sm">
135
135
+
This will create a post on your Bluesky account. Replies to that post will appear on your
136
136
+
guestbook card.
137
137
+
</p>
138
138
+
<textarea
139
139
+
bind:value={postText}
140
140
+
rows="4"
141
141
+
class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-600 mt-2 w-full rounded-lg border p-3 text-sm focus:outline-none"
142
142
+
></textarea>
143
143
+
{:else}
144
144
+
<p class="text-base-500 dark:text-base-400 text-sm">
145
145
+
Paste a Bluesky post URL to use as your guestbook. Replies to that post will appear on your
146
146
+
card.
147
147
+
</p>
148
148
+
<Input bind:value={postUrl} placeholder="https://bsky.app/profile/handle/post/..." />
149
149
+
{/if}
150
150
+
151
151
+
{#if errorMessage}
152
152
+
<Alert type="error" title="Error"><span>{errorMessage}</span></Alert>
153
153
+
{/if}
154
154
+
155
155
+
<div class="mt-4 flex justify-end gap-2">
156
156
+
<Button onclick={oncancel} variant="ghost">Cancel</Button>
157
157
+
{#if mode === 'create'}
158
158
+
<Button type="submit" disabled={isPosting || !postText.trim()}>
159
159
+
{isPosting ? 'Posting...' : 'Post to Bluesky & Create'}
160
160
+
</Button>
161
161
+
{:else}
162
162
+
<Button type="submit" disabled={!postUrl.trim()}>Create</Button>
163
163
+
{/if}
164
164
+
</div>
165
165
+
</form>
166
166
+
</Modal>
+126
src/lib/cards/GuestbookCard/GuestbookCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context';
4
4
+
import { CardDefinitionsByType } from '..';
5
5
+
import type { ContentComponentProps } from '../types';
6
6
+
import { Button } from '@foxui/core';
7
7
+
import { BlueskyPost } from '$lib/components/bluesky-post';
8
8
+
import type { PostView } from '@atcute/bluesky/types/app/feed/defs';
9
9
+
10
10
+
let { item }: ContentComponentProps = $props();
11
11
+
12
12
+
const data = getAdditionalUserData();
13
13
+
const did = getDidContext();
14
14
+
const handle = getHandleContext();
15
15
+
16
16
+
type Reply = {
17
17
+
$type: string;
18
18
+
post: PostView;
19
19
+
};
20
20
+
21
21
+
let isLoaded = $state(false);
22
22
+
23
23
+
let cardUri = $derived(item.cardData.uri as string);
24
24
+
25
25
+
// svelte-ignore state_referenced_locally
26
26
+
let replies = $state<Reply[]>(
27
27
+
((data['guestbook'] as Record<string, Reply[]>)?.[item.cardData.uri as string] ?? []) as Reply[]
28
28
+
);
29
29
+
30
30
+
onMount(async () => {
31
31
+
if (!cardUri) {
32
32
+
isLoaded = true;
33
33
+
return;
34
34
+
}
35
35
+
36
36
+
try {
37
37
+
const loaded = await CardDefinitionsByType[item.cardType]?.loadData?.([item], {
38
38
+
did,
39
39
+
handle
40
40
+
});
41
41
+
const result = loaded as Record<string, Reply[]> | undefined;
42
42
+
const freshReplies = result?.[cardUri] ?? [];
43
43
+
44
44
+
if (freshReplies.length > 0) {
45
45
+
replies = freshReplies;
46
46
+
}
47
47
+
48
48
+
if (!data['guestbook']) {
49
49
+
data['guestbook'] = {};
50
50
+
}
51
51
+
(data['guestbook'] as Record<string, Reply[]>)[cardUri] = replies;
52
52
+
} catch (e) {
53
53
+
console.error('Failed to load guestbook replies', e);
54
54
+
}
55
55
+
56
56
+
isLoaded = true;
57
57
+
});
58
58
+
</script>
59
59
+
60
60
+
<div class="flex h-full flex-col overflow-hidden p-4">
61
61
+
{#if item.cardData.href}
62
62
+
<div class="mb-2 flex justify-end">
63
63
+
<a href={item.cardData.href} target="_blank" rel="noopener noreferrer">
64
64
+
<Button size="sm">Add a comment on Bluesky</Button>
65
65
+
</a>
66
66
+
</div>
67
67
+
{/if}
68
68
+
69
69
+
<div class="flex-1 overflow-y-auto">
70
70
+
{#if replies.length > 0}
71
71
+
<div class="replies">
72
72
+
{#each replies as reply (reply.post.uri)}
73
73
+
<div class="reply">
74
74
+
<BlueskyPost feedViewPost={reply.post} showAvatar compact showLogo={false} />
75
75
+
</div>
76
76
+
{/each}
77
77
+
</div>
78
78
+
{:else if isLoaded}
79
79
+
<div
80
80
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm"
81
81
+
>
82
82
+
No comments yet — share your Bluesky post to get started!
83
83
+
</div>
84
84
+
{:else}
85
85
+
<div
86
86
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm"
87
87
+
>
88
88
+
Loading comments...
89
89
+
</div>
90
90
+
{/if}
91
91
+
</div>
92
92
+
</div>
93
93
+
94
94
+
<style>
95
95
+
.reply {
96
96
+
padding-bottom: 1rem;
97
97
+
margin-bottom: 1rem;
98
98
+
border-bottom: 1px solid oklch(0.5 0 0 / 0.1);
99
99
+
}
100
100
+
101
101
+
.reply:last-child {
102
102
+
border-bottom: none;
103
103
+
margin-bottom: 0;
104
104
+
padding-bottom: 0;
105
105
+
}
106
106
+
107
107
+
.reply :global(img:not([class*='rounded-full'])) {
108
108
+
max-height: 10rem;
109
109
+
}
110
110
+
111
111
+
.reply :global(article) {
112
112
+
max-height: 10rem;
113
113
+
}
114
114
+
115
115
+
@container card (width >= 30rem) {
116
116
+
.replies {
117
117
+
columns: 2;
118
118
+
column-gap: 1.5rem;
119
119
+
column-rule: 1px solid oklch(0.5 0 0 / 0.15);
120
120
+
}
121
121
+
122
122
+
.reply {
123
123
+
break-inside: avoid;
124
124
+
}
125
125
+
}
126
126
+
</style>
+64
src/lib/cards/GuestbookCard/index.ts
···
1
1
+
import { getPostThread } from '$lib/atproto/methods';
2
2
+
import type { CardDefinition } from '../types';
3
3
+
import GuestbookCard from './GuestbookCard.svelte';
4
4
+
import CreateGuestbookCardModal from './CreateGuestbookCardModal.svelte';
5
5
+
6
6
+
export const GuestbookCardDefinition = {
7
7
+
type: 'guestbook',
8
8
+
contentComponent: GuestbookCard,
9
9
+
creationModalComponent: CreateGuestbookCardModal,
10
10
+
sidebarButtonText: 'Guestbook',
11
11
+
createNew: (card) => {
12
12
+
card.w = 4;
13
13
+
card.h = 6;
14
14
+
card.mobileW = 8;
15
15
+
card.mobileH = 12;
16
16
+
card.cardData.label = 'Guestbook';
17
17
+
},
18
18
+
minW: 4,
19
19
+
minH: 4,
20
20
+
defaultColor: 'base',
21
21
+
canHaveLabel: true,
22
22
+
loadData: async (items) => {
23
23
+
const uris = items
24
24
+
.filter((item) => item.cardData?.uri)
25
25
+
.map((item) => item.cardData.uri as string);
26
26
+
27
27
+
if (uris.length === 0) return {};
28
28
+
29
29
+
const results: Record<string, unknown[]> = {};
30
30
+
31
31
+
await Promise.all(
32
32
+
uris.map(async (uri) => {
33
33
+
try {
34
34
+
const thread = await getPostThread({ uri, depth: 1 });
35
35
+
if (thread && '$type' in thread && thread.$type === 'app.bsky.feed.defs#threadViewPost') {
36
36
+
const typedThread = thread as { replies?: unknown[] };
37
37
+
results[uri] = (typedThread.replies ?? [])
38
38
+
.filter(
39
39
+
(r: unknown) =>
40
40
+
r != null &&
41
41
+
typeof r === 'object' &&
42
42
+
'$type' in r &&
43
43
+
(r as { $type: string }).$type === 'app.bsky.feed.defs#threadViewPost'
44
44
+
)
45
45
+
.sort((a: unknown, b: unknown) => {
46
46
+
const timeA = new Date(
47
47
+
((a as any).post?.record?.createdAt as string) ?? 0
48
48
+
).getTime();
49
49
+
const timeB = new Date(
50
50
+
((b as any).post?.record?.createdAt as string) ?? 0
51
51
+
).getTime();
52
52
+
return timeB - timeA;
53
53
+
});
54
54
+
}
55
55
+
} catch (e) {
56
56
+
console.error('Failed to load guestbook thread for', uri, e);
57
57
+
}
58
58
+
})
59
59
+
);
60
60
+
61
61
+
return results;
62
62
+
},
63
63
+
name: 'Guestbook'
64
64
+
} as CardDefinition & { type: 'guestbook' };
+2
src/lib/cards/index.ts
···
32
32
import { TimerCardDefinition } from './TimerCard';
33
33
import { SpotifyCardDefinition } from './SpotifyCard';
34
34
import { ButtonCardDefinition } from './ButtonCard';
35
35
+
import { GuestbookCardDefinition } from './GuestbookCard';
35
36
// import { Model3DCardDefinition } from './Model3DCard';
36
37
37
38
export const AllCardDefinitions = [
39
39
+
GuestbookCardDefinition,
38
40
ButtonCardDefinition,
39
41
ImageCardDefinition,
40
42
VideoCardDefinition,
+11
-1
src/lib/components/bluesky-post/BlueskyPost.svelte
···
8
8
feedViewPost,
9
9
children,
10
10
showLogo = false,
11
11
+
showAvatar = false,
12
12
+
compact = false,
11
13
...restProps
12
12
-
}: { feedViewPost?: PostView; children?: Snippet; showLogo?: boolean } = $props();
14
14
+
}: {
15
15
+
feedViewPost?: PostView;
16
16
+
children?: Snippet;
17
17
+
showLogo?: boolean;
18
18
+
showAvatar?: boolean;
19
19
+
compact?: boolean;
20
20
+
} = $props();
13
21
14
22
const postData = $derived(feedViewPost ? blueskyPostToPostData(feedViewPost) : undefined);
15
23
</script>
···
37
45
likeHref={postData?.href}
38
46
showBookmark={false}
39
47
logo={showLogo ? logo : undefined}
48
48
+
{showAvatar}
49
49
+
{compact}
40
50
{...restProps}
41
51
>
42
52
{@render children?.()}
+22
-4
src/lib/components/post/Post.svelte
···
36
36
37
37
children,
38
38
39
39
-
logo
39
39
+
logo,
40
40
+
41
41
+
showAvatar = false,
42
42
+
compact = false
40
43
}: WithElementRef<WithChildren<HTMLAttributes<HTMLDivElement>>> & {
41
44
data: PostData;
42
45
class?: string;
···
61
64
customActions?: Snippet;
62
65
63
66
logo?: Snippet;
67
67
+
68
68
+
showAvatar?: boolean;
69
69
+
compact?: boolean;
64
70
} = $props();
65
71
</script>
66
72
···
121
127
</div>
122
128
{/if}
123
129
<div class="flex gap-4">
130
130
+
{#if showAvatar && data.author.avatar}
131
131
+
<a href={data.author.href} class="flex-shrink-0">
132
132
+
<img
133
133
+
src={data.author.avatar}
134
134
+
alt=""
135
135
+
class={compact ? 'size-7 rounded-full object-cover' : 'size-10 rounded-full object-cover'}
136
136
+
/>
137
137
+
</a>
138
138
+
{/if}
124
139
<div class="w-full">
125
140
<div class="mb-1 flex items-start justify-between gap-2">
126
141
<div class="flex items-start gap-4">
···
161
176
{/if}
162
177
163
178
<div
164
164
-
class="text-base-600 dark:text-base-400 accent:text-accent-950 block text-sm no-underline"
179
179
+
class={cn(
180
180
+
'text-base-600 dark:text-base-400 accent:text-accent-950 block no-underline',
181
181
+
compact ? 'text-xs' : 'text-sm'
182
182
+
)}
165
183
>
166
184
<RelativeTime date={new Date(data.createdAt)} locale="en" />
167
185
</div>
···
173
191
</div>
174
192
175
193
<Prose
176
176
-
size="md"
194
194
+
size={compact ? 'default' : 'md'}
177
195
class="accent:prose-a:text-accent-950 accent:text-base-900 accent:prose-p:text-base-900 accent:prose-a:underline"
178
196
>
179
197
{#if data.htmlContent}
···
185
203
186
204
<PostEmbed {data} />
187
205
188
188
-
{#if showReply || showRepost || showLike || showBookmark || customActions}
206
206
+
{#if !compact && (showReply || showRepost || showLike || showBookmark || customActions)}
189
207
<div
190
208
class="text-base-500 dark:text-base-400 accent:text-base-900 mt-4 flex justify-between gap-2"
191
209
>