your personal website on atproto - mirror blento.app

guestbook card

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