your personal website on atproto - mirror blento.app

blog stuff pt1

Florian 1cc74545 50224195

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