your personal website on atproto - mirror blento.app

bluesky post card + fixes

+330 -44
+35 -1
src/lib/atproto/methods.ts
··· 1 - import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons'; 1 + import { 2 + parseResourceUri, 3 + type ActorIdentifier, 4 + type Did, 5 + type Handle, 6 + type ResourceUri 7 + } from '@atcute/lexicons'; 2 8 import { user } from './auth.svelte'; 3 9 import type { AllowedCollection } from './settings'; 4 10 import { ··· 428 434 429 435 return response.data; 430 436 } 437 + 438 + /** 439 + * Fetches posts by their AT URIs. 440 + * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 441 + * @param client - The client to use (defaults to public Bluesky API) 442 + * @returns Array of posts or undefined on failure 443 + */ 444 + export async function getPosts(data: { uris: string[]; client?: Client }) { 445 + data.client ??= new Client({ 446 + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 447 + }); 448 + 449 + const response = await data.client.get('app.bsky.feed.getPosts', { 450 + params: { uris: data.uris as ResourceUri[] } 451 + }); 452 + 453 + if (!response.ok) return; 454 + 455 + return response.data.posts; 456 + } 457 + 458 + export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier { 459 + if (profile.handle && profile.handle !== 'handle.invalid') { 460 + return profile.handle; 461 + } else { 462 + return profile.did; 463 + } 464 + }
+1 -1
src/lib/atproto/settings.ts
··· 44 44 45 45 // which PDS to use for signup 46 46 // ATTENTION: pds.rip is only for development, all accounts get deleted automatically after a week 47 - export const signUpPDS = 'https://selfhosted.social/'; 47 + export const signUpPDS = 'https://pds.rip/';
+26 -24
src/lib/cards/BlueskyPostCard/BlueskyPostCard.svelte
··· 2 2 import type { Item } from '$lib/types'; 3 3 import { onMount } from 'svelte'; 4 4 import { BlueskyPost } from '../../components/bluesky-post'; 5 - import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 6 - import { CardDefinitionsByType } from '..'; 5 + import { getAdditionalUserData } from '$lib/website/context'; 6 + import { getPosts, resolveHandle } from '$lib/atproto/methods'; 7 + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 8 + import type { Handle } from '@atcute/lexicons'; 9 + import { isDid } from '@atcute/lexicons/syntax'; 10 + import { resolveUri } from './utils'; 7 11 8 12 let { item }: { item: Item } = $props(); 9 13 10 14 const data = getAdditionalUserData(); 15 + let uri = $derived(item.cardData.uri as string); 16 + 11 17 // svelte-ignore state_referenced_locally 12 - let feed = $state((data[item.cardType] as any)?.feed); 13 - 14 - let did = getDidContext(); 15 - let handle = getHandleContext(); 18 + let post = $state((data['blueskyPost'] as Record<string, PostView>)?.[uri]); 16 19 17 20 onMount(async () => { 18 - if (!feed) { 19 - feed = ( 20 - (await CardDefinitionsByType[item.cardType]?.loadData?.([], { 21 - did, 22 - handle 23 - })) as any 24 - ).feed; 25 - 26 - console.log(feed); 21 + if (!post && uri) { 22 + // Resolve handle to DID if needed 23 + const resolvedUri = await resolveUri(uri); 27 24 28 - data[item.cardType] = feed; 25 + const posts = await getPosts({ uris: [resolvedUri] }); 26 + if (posts && posts.length > 0) { 27 + post = posts[0]; 28 + // Store in data for future use (keyed by resolved URI) 29 + if (!data['blueskyPost']) { 30 + data['blueskyPost'] = {}; 31 + } 32 + (data['blueskyPost'] as Record<string, PostView>)[resolvedUri] = post; 33 + // Also store under original URI for lookup 34 + (data['blueskyPost'] as Record<string, PostView>)[uri] = post; 35 + } 29 36 } 30 37 }); 31 38 </script> 32 39 33 40 <div class="flex h-full flex-col justify-center-safe overflow-y-scroll p-4"> 34 - <div 35 - class="accent:text-base-950 bg-base-200/50 dark:bg-base-700/30 mx-auto mb-6 w-fit rounded-xl p-1 px-2 text-2xl font-semibold" 36 - > 37 - My latest bluesky post 38 - </div> 39 - {#if feed?.[0]?.post} 40 - <BlueskyPost showLogo feedViewPost={feed?.[0].post}></BlueskyPost> 41 + {#if post} 42 + <BlueskyPost showLogo feedViewPost={post}></BlueskyPost> 41 43 <div class="h-4 w-full"></div> 42 44 {:else} 43 - Your latest bluesky post will appear here. 45 + <p class="text-base-600 dark:text-base-400 text-center">A bluesky post will appear here</p> 44 46 {/if} 45 47 </div>
+75
src/lib/cards/BlueskyPostCard/CreateBlueskyPostCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + import { parseBlueskyPostUrl } from './utils'; 5 + 6 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 7 + 8 + let isValidating = $state(false); 9 + let errorMessage = $state(''); 10 + let postUrl = $state(''); 11 + 12 + async function validateAndCreate() { 13 + errorMessage = ''; 14 + isValidating = true; 15 + 16 + try { 17 + const parsed = parseBlueskyPostUrl(postUrl.trim()); 18 + 19 + if (!parsed) { 20 + throw new Error('Invalid URL format'); 21 + } 22 + 23 + // Construct AT URI using handle (will be resolved to DID when loading) 24 + item.cardData.uri = `at://${parsed.handle}/app.bsky.feed.post/${parsed.rkey}`; 25 + item.cardData.href = postUrl.trim(); 26 + 27 + return true; 28 + } catch (err) { 29 + errorMessage = 30 + err instanceof Error && err.message === 'Post not found' 31 + ? "Couldn't find that post. Please check the URL and try again." 32 + : err instanceof Error && err.message === 'Could not resolve handle' 33 + ? "Couldn't find that user. Please check the URL and try again." 34 + : 'Invalid URL. Please enter a valid Bluesky post URL (e.g., https://bsky.app/profile/handle/post/rkey).'; 35 + return false; 36 + } finally { 37 + isValidating = false; 38 + } 39 + } 40 + </script> 41 + 42 + <Modal open={true} closeButton={false}> 43 + <form 44 + onsubmit={async () => { 45 + if (await validateAndCreate()) oncreate(); 46 + }} 47 + class="flex flex-col gap-2" 48 + > 49 + <Subheading>Enter a Bluesky post URL</Subheading> 50 + <Input 51 + bind:value={postUrl} 52 + placeholder="https://bsky.app/profile/handle/post/..." 53 + class="mt-4" 54 + /> 55 + 56 + {#if errorMessage} 57 + <Alert type="error" title="Failed to create post card"><span>{errorMessage}</span></Alert> 58 + {/if} 59 + 60 + <p class="text-base-500 dark:text-base-400 mt-2 text-xs"> 61 + Paste a URL from <a 62 + href="https://bsky.app" 63 + class="text-accent-800 dark:text-accent-300" 64 + target="_blank">bsky.app</a 65 + > to embed a Bluesky post. 66 + </p> 67 + 68 + <div class="mt-4 flex justify-end gap-2"> 69 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 70 + <Button type="submit" disabled={isValidating || !postUrl.trim()} 71 + >{isValidating ? 'Creating...' : 'Create'}</Button 72 + > 73 + </div> 74 + </form> 75 + </Modal>
src/lib/cards/BlueskyPostCard/SidebarItemBlueskyPostCard.svelte src/lib/cards/LatestBlueskyPostCard/SidebarItemLatestBlueskyPostCard.svelte
+55 -10
src/lib/cards/BlueskyPostCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 2 import BlueskyPostCard from './BlueskyPostCard.svelte'; 3 - import SidebarItemBlueskyPostCard from './SidebarItemBlueskyPostCard.svelte'; 4 - import { getAuthorFeed } from '$lib/atproto/methods'; 3 + import CreateBlueskyPostCardModal from './CreateBlueskyPostCardModal.svelte'; 4 + import { getPosts } from '$lib/atproto/methods'; 5 + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 6 + import { parseBlueskyPostUrl, resolveUri } from './utils'; 7 + 5 8 6 9 export const BlueskyPostCardDefinition = { 7 - type: 'latestPost', 10 + type: 'blueskyPost', 8 11 contentComponent: BlueskyPostCard, 12 + creationModalComponent: CreateBlueskyPostCardModal, 13 + sidebarButtonText: 'Bluesky Post', 9 14 createNew: (card) => { 10 - card.cardType = 'latestPost'; 15 + card.cardType = 'blueskyPost'; 11 16 card.w = 4; 12 17 card.mobileW = 8; 13 18 card.h = 4; 14 19 card.mobileH = 8; 15 20 }, 16 - sidebarButtonText: 'Latest Bluesky Post', 17 - loadData: async (items, { did }) => { 18 - const authorFeed = await getAuthorFeed({ did, filter: 'posts_no_replies', limit: 2 }); 21 + 22 + onUrlHandler: (url, item) => { 23 + const parsed = parseBlueskyPostUrl(url); 24 + if (!parsed) return null; 25 + 26 + // Construct AT URI using handle (will be resolved to DID when loading) 27 + item.cardData.uri = `at://${parsed.handle}/app.bsky.feed.post/${parsed.rkey}`; 28 + item.cardData.href = url; 29 + 30 + item.w = 4; 31 + item.mobileW = 8; 32 + item.h = 4; 33 + item.mobileH = 8; 34 + 35 + return item; 36 + }, 37 + urlHandlerPriority: 2, 38 + 39 + loadData: async (items) => { 40 + // Collect all unique URIs from blueskyPost cards 41 + const originalUris = items 42 + .filter((item) => item.cardData?.uri) 43 + .map((item) => item.cardData.uri as string); 19 44 20 - return JSON.parse(JSON.stringify(authorFeed)); 45 + if (originalUris.length === 0) return {}; 46 + 47 + // Resolve handles to DIDs 48 + const resolvedUris = await Promise.all(originalUris.map(resolveUri)); 49 + 50 + const posts = await getPosts({ uris: resolvedUris }); 51 + if (!posts) return {}; 52 + 53 + // Create a map of URI -> PostView (keyed by both original and resolved URIs) 54 + const postsMap: Record<string, PostView> = {}; 55 + for (let i = 0; i < posts.length; i++) { 56 + const post = posts[i]; 57 + postsMap[post.uri] = post; 58 + // Also map by original URI for lookup 59 + if (originalUris[i] && originalUris[i] !== post.uri) { 60 + postsMap[originalUris[i]] = post; 61 + } 62 + } 63 + 64 + return postsMap; 21 65 }, 22 - minW: 4 23 - } as CardDefinition & { type: 'latestPost' }; 66 + minW: 4, 67 + name: 'Bluesky Post' 68 + } as CardDefinition & { type: 'blueskyPost' };
+37
src/lib/cards/BlueskyPostCard/utils.ts
··· 1 + import { resolveHandle } from '$lib/atproto'; 2 + import type { Handle } from '@atcute/lexicons'; 3 + 4 + // Matches URLs like https://bsky.app/profile/jyc.dev/post/3mdfjepjpls24 5 + const blueskyPostUrlPattern = 6 + /^https?:\/\/(?:www\.)?bsky\.app\/profile\/([^/]+)\/post\/([A-Za-z0-9]+)\/?$/; 7 + 8 + /** 9 + * Extract handle and rkey from a Bluesky post URL 10 + * @param url URL to parse 11 + * @returns Object with handle and rkey, or undefined if not a valid Bluesky post URL 12 + */ 13 + export function parseBlueskyPostUrl(url: string): { handle: string; rkey: string } | undefined { 14 + const match = url.match(blueskyPostUrlPattern); 15 + if (!match) return undefined; 16 + return { handle: match[1], rkey: match[2] }; 17 + } 18 + 19 + // Resolve handle to DID if URI contains a handle (not starting with did:) 20 + export async function resolveUri(atUri: string): Promise<string> { 21 + const match = atUri.match(/^at:\/\/([^/]+)\/(.+)$/); 22 + if (!match) return atUri; 23 + 24 + const [, authority, rest] = match; 25 + 26 + // If already a DID, return as-is 27 + if (authority.startsWith('did:')) return atUri; 28 + 29 + // Resolve handle to DID 30 + try { 31 + const did = await resolveHandle({ handle: authority as Handle }); 32 + if (!did) return atUri; 33 + return `at://${did}/${rest}`; 34 + } catch { 35 + return atUri; 36 + } 37 + }
+45
src/lib/cards/LatestBlueskyPostCard/LatestBlueskyPostCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { onMount } from 'svelte'; 4 + import { BlueskyPost } from '../../components/bluesky-post'; 5 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 6 + import { CardDefinitionsByType } from '..'; 7 + 8 + let { item }: { item: Item } = $props(); 9 + 10 + const data = getAdditionalUserData(); 11 + // svelte-ignore state_referenced_locally 12 + let feed = $state((data[item.cardType] as any)?.feed); 13 + 14 + let did = getDidContext(); 15 + let handle = getHandleContext(); 16 + 17 + onMount(async () => { 18 + if (!feed) { 19 + feed = ( 20 + (await CardDefinitionsByType[item.cardType]?.loadData?.([], { 21 + did, 22 + handle 23 + })) as any 24 + ).feed; 25 + 26 + console.log(feed); 27 + 28 + data[item.cardType] = feed; 29 + } 30 + }); 31 + </script> 32 + 33 + <div class="flex h-full flex-col justify-center-safe overflow-y-scroll p-4"> 34 + <div 35 + class="accent:text-base-950 bg-base-200/50 dark:bg-base-700/30 mx-auto mb-6 w-fit rounded-xl p-1 px-2 text-2xl font-semibold" 36 + > 37 + My latest bluesky post 38 + </div> 39 + {#if feed?.[0]?.post} 40 + <BlueskyPost showLogo feedViewPost={feed?.[0].post}></BlueskyPost> 41 + <div class="h-4 w-full"></div> 42 + {:else} 43 + Your latest bluesky post will appear here. 44 + {/if} 45 + </div>
+22
src/lib/cards/LatestBlueskyPostCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import LatestBlueskyPostCard from './LatestBlueskyPostCard.svelte'; 3 + import { getAuthorFeed } from '$lib/atproto/methods'; 4 + 5 + export const LatestBlueskyPostCardDefinition = { 6 + type: 'latestPost', 7 + contentComponent: LatestBlueskyPostCard, 8 + createNew: (card) => { 9 + card.cardType = 'latestPost'; 10 + card.w = 4; 11 + card.mobileW = 8; 12 + card.h = 4; 13 + card.mobileH = 8; 14 + }, 15 + sidebarButtonText: 'Latest Bluesky Post', 16 + loadData: async (items, { did }) => { 17 + const authorFeed = await getAuthorFeed({ did, filter: 'posts_no_replies', limit: 2 }); 18 + 19 + return JSON.parse(JSON.stringify(authorFeed)); 20 + }, 21 + minW: 4 22 + } as CardDefinition & { type: 'latestPost' };
+9 -1
src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte
··· 9 9 const data = getAdditionalUserData(); 10 10 // svelte-ignore state_referenced_locally 11 11 const profiles = data[item.cardType] as AppBskyActorDefs.ProfileViewDetailed[]; 12 + 13 + function getLink(profile: AppBskyActorDefs.ProfileViewDetailed): string { 14 + if (profile.handle && profile.handle !== 'handle.invalid') { 15 + return `/${profile.handle}`; 16 + } else { 17 + return `/${profile.did}`; 18 + } 19 + } 12 20 </script> 13 21 14 22 <div class="flex h-full flex-col"> ··· 16 24 <div class="flex max-w-full grow items-center gap-4 overflow-x-scroll overflow-y-hidden px-4"> 17 25 {#each profiles as profile (profile.did)} 18 26 <a 19 - href="/{profile.handle}" 27 + href={getLink(profile)} 20 28 class="bg-base-100 dark:bg-base-800 hover:bg-base-200 dark:hover:bg-base-700 accent:bg-accent-200/30 accent:hover:bg-accent-200/50 flex h-52 w-44 min-w-44 flex-col items-center justify-center gap-2 rounded-xl p-2 transition-colors duration-150" 21 29 target="_blank" 22 30 >
+2
src/lib/cards/index.ts
··· 3 3 import { BigSocialCardDefinition } from './BigSocialCard'; 4 4 import { BlueskyMediaCardDefinition } from './BlueskyMediaCard'; 5 5 import { BlueskyPostCardDefinition } from './BlueskyPostCard'; 6 + import { LatestBlueskyPostCardDefinition } from './LatestBlueskyPostCard'; 6 7 import { DinoGameCardDefinition } from './GameCards/DinoGameCard'; 7 8 import { EmbedCardDefinition } from './EmbedCard'; 8 9 import { TetrisCardDefinition } from './GameCards/TetrisCard'; ··· 39 40 UpdatedBlentosCardDefitition, 40 41 YoutubeCardDefinition, 41 42 BlueskyPostCardDefinition, 43 + LatestBlueskyPostCardDefinition, 42 44 LivestreamCardDefitition, 43 45 LivestreamEmbedCardDefitition, 44 46 EmbedCardDefinition,
+8 -2
src/lib/website/FloatingEditButton.svelte
··· 7 7 import { page } from '$app/state'; 8 8 import type { ActorIdentifier } from '@atcute/lexicons'; 9 9 import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 10 + import { getHandleOrDid } from '$lib/atproto/methods'; 10 11 11 12 let { data }: { data: WebsiteData } = $props(); 12 13 ··· 20 21 const showEditBlentoButton = $derived( 21 22 isBlento && user.isLoggedIn && user.profile?.handle !== data.handle 22 23 ); 24 + 25 + function getUserIdentifier(): ActorIdentifier { 26 + const id = user.profile ? getHandleOrDid(user.profile) : (data.did as ActorIdentifier); 27 + return id; 28 + } 23 29 </script> 24 30 25 31 {#if isOwnPage && !isEditPage} ··· 44 50 </div> 45 51 {:else if showLoginOnEditPage} 46 52 <div class="fixed bottom-6 left-6 z-49"> 47 - <Button size="lg" onclick={() => login(data.handle as ActorIdentifier)}> 53 + <Button size="lg" onclick={() => login(getUserIdentifier())}> 48 54 <svg 49 55 xmlns="http://www.w3.org/2000/svg" 50 56 fill="none" ··· 68 74 </div> 69 75 {:else if showEditBlentoButton} 70 76 <div class="fixed bottom-6 left-6 z-49"> 71 - <Button size="lg" href="/{env.PUBLIC_IS_SELFHOSTED ? '' : user.profile?.handle}/edit"> 77 + <Button size="lg" href="/{env.PUBLIC_IS_SELFHOSTED ? '' : getUserIdentifier()}/edit"> 72 78 <svg 73 79 xmlns="http://www.w3.org/2000/svg" 74 80 fill="none"
+12 -3
src/lib/website/load.ts
··· 3 3 import type { Item, UserCache, WebsiteData } from '$lib/types'; 4 4 import { compactItems, fixAllCollisions } from '$lib/helper'; 5 5 import { error } from '@sveltejs/kit'; 6 - import type { Handle } from '@atcute/lexicons'; 6 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 7 + 8 + import { isDid, isHandle } from '@atcute/lexicons/syntax'; 7 9 8 10 const CURRENT_CACHE_VERSION = 1; 9 11 ··· 48 50 } 49 51 50 52 export async function loadData( 51 - handle: Handle, 53 + handle: ActorIdentifier, 52 54 cache: UserCache | undefined, 53 55 forceUpdate: boolean = false, 54 56 page: string = 'self' ··· 62 64 if (cachedResult) return cachedResult; 63 65 } 64 66 65 - const did = await resolveHandle({ handle }); 67 + let did: Did | undefined = undefined; 68 + if (isHandle(handle)) { 69 + did = await resolveHandle({ handle }); 70 + } else if (isDid(handle)) { 71 + did = handle; 72 + } else { 73 + throw error(404); 74 + } 66 75 67 76 const cards = await listRecords({ did, collection: 'app.blento.card' }).catch(() => { 68 77 console.error('error getting records for collection app.blento.card');
+1 -1
src/params/handle.ts
··· 1 1 import type { ParamMatcher } from '@sveltejs/kit'; 2 2 3 3 export const match = ((param: string) => { 4 - return param.includes('.'); 4 + return param.includes('.') || param.startsWith('did:'); 5 5 }) satisfies ParamMatcher;
+2 -1
src/routes/(auth)/oauth/callback/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { goto } from '$app/navigation'; 3 3 import { user } from '$lib/atproto'; 4 + import { getHandleOrDid } from '$lib/atproto/methods'; 4 5 5 6 $effect(() => { 6 7 console.log('hello', user); 7 8 if (user.profile) { 8 - goto('/' + user.profile.handle + '/edit', {}); 9 + goto('/' + getHandleOrDid(user.profile) + '/edit', {}); 9 10 } 10 11 }); 11 12 </script>