WIP: My personal website
0

Configure Feed

Select the types of activity you want to include in your feed.

shows recent posts

+172 -9
+63 -2
src/lib/components/Writing.svelte
··· 1 1 <script lang="ts"> 2 2 import { reveal } from '$lib/actions/reveal'; 3 3 import LazyLoadingImg from './LazyLoadingImg.svelte'; 4 - import type { Publication } from '$lib/types'; 4 + import type { LatestPost, Publication } from '$lib/types'; 5 5 6 - let { publications }: { publications: Publication[] } = $props(); 6 + let { 7 + publications, 8 + latestPosts 9 + }: { publications: Publication[]; latestPosts: Promise<LatestPost[]> } = $props(); 7 10 8 11 const INITIAL_COUNT = 4; 9 12 const LOAD_STEP = 2; 10 13 let visibleCount = $state(INITIAL_COUNT); 11 14 const visiblePublications = $derived(publications.slice(0, visibleCount)); 12 15 const hasMore = $derived(visibleCount < publications.length); 16 + 17 + function publishedLabel(iso: string | null): string { 18 + if (!iso) return ''; 19 + const date = new Date(iso); 20 + if (Number.isNaN(date.getTime())) return ''; 21 + return date.toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' }); 22 + } 13 23 </script> 14 24 15 25 {#if publications.length > 0} ··· 53 63 Load More 54 64 </button> 55 65 {/if} 66 + 67 + <h2 class="mt-16 text-center font-urbanist text-xl font-semibold opacity-80 md:mt-24 md:text-3xl"> 68 + Latest Posts 69 + </h2> 70 + {#await latestPosts} 71 + <div class="container mt-10 grid gap-10 p-4 md:grid-cols-2 xl:grid-cols-3"> 72 + {#each Array(2) as _, i (i)} 73 + <div class="card bg-base-100 shadow-sm"> 74 + <div class="h-48 w-full skeleton"></div> 75 + <div class="card-body flex flex-col gap-3"> 76 + <div class="h-5 w-28 skeleton"></div> 77 + <div class="h-7 w-3/4 skeleton"></div> 78 + <div class="h-4 w-1/3 skeleton"></div> 79 + <div class="h-4 w-full skeleton"></div> 80 + </div> 81 + </div> 82 + {/each} 83 + </div> 84 + {:then posts} 85 + {#if posts.length > 0} 86 + <div class="container mt-10 grid gap-10 p-4 md:grid-cols-2 xl:grid-cols-3"> 87 + {#each posts as post (post.postUrl)} 88 + <div class="card bg-base-100 shadow-sm transition duration-300 hover:-translate-y-1"> 89 + {#if post.coverImage} 90 + <figure> 91 + <LazyLoadingImg class="h-48 w-full" src={post.coverImage} alt={post.title} /> 92 + </figure> 93 + {/if} 94 + <div class="card-body"> 95 + <span class="badge font-urbanist badge-primary">{post.publicationName}</span> 96 + <h3 class="mt-2 card-title font-urbanist text-2xl font-black">{post.title}</h3> 97 + {#if post.publishedAt} 98 + <p class="font-urbanist text-xs opacity-40">{publishedLabel(post.publishedAt)}</p> 99 + {/if} 100 + <p class="text-md font-urbanist font-medium opacity-60">{post.description}</p> 101 + <div class="mt-2 card-actions justify-end"> 102 + <a 103 + class="btn font-urbanist btn-primary" 104 + href={post.postUrl} 105 + target="_blank" 106 + rel="noopener noreferrer" 107 + > 108 + Read 109 + </a> 110 + </div> 111 + </div> 112 + </div> 113 + {/each} 114 + </div> 115 + {/if} 116 + {/await} 56 117 </div> 57 118 {/if}
+89
src/lib/server/atproto/documents.ts
··· 1 + import type { LatestPost, Publication } from '$lib/types'; 2 + import { cache, HOUR_MS } from '$lib/server/cache'; 3 + import { getRepoInfo } from './client'; 4 + import { fetchPublications } from './publications'; 5 + 6 + /** Minimal view of a `site.standard.document` record — metadata only, no content. */ 7 + type DocumentRecord = { 8 + uri: string; 9 + value: { 10 + site: string; 11 + title: string; 12 + path: string; 13 + description?: string; 14 + publishedAt?: string; 15 + coverImage?: { ref: { $link: string }; mimeType?: string }; 16 + }; 17 + }; 18 + 19 + /** Joins a publication base url with a document path, tolerating leading/trailing slashes. */ 20 + function buildPostUrl(publicationUrl: string, path: string): string { 21 + return `${publicationUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; 22 + } 23 + 24 + /** 25 + * Walks the `site.standard.document` records on the PDS and returns the most recent 26 + * post for each publication. A document maps to its publication via its `site` field 27 + * (the publication record's at-uri). Cached for 1 hour. 28 + */ 29 + export async function fetchLatestPosts(): Promise<LatestPost[]> { 30 + const cached = cache.get('latestPosts'); 31 + if (cached) { 32 + return cached as LatestPost[]; 33 + } 34 + 35 + const repo = getRepoInfo('Latest posts'); 36 + if (!repo) return []; 37 + 38 + try { 39 + // Reuse the cached, blento-filtered publications and key them by their at-uri. 40 + const publications = await fetchPublications(); 41 + const byUri = new Map<string, Publication>(publications.map((p) => [p.uri, p])); 42 + 43 + const url = 44 + `${repo.pds}/xrpc/com.atproto.repo.listRecords` + 45 + `?repo=${encodeURIComponent(repo.did)}` + 46 + `&collection=site.standard.document&limit=100`; 47 + const res = await fetch(url); 48 + if (!res.ok) { 49 + console.warn(`Failed to list documents: ${res.status} ${res.statusText}`); 50 + return []; 51 + } 52 + const { records } = (await res.json()) as { records: DocumentRecord[] }; 53 + 54 + // Most recent document per publication, by publishedAt. 55 + const newestByPublication = new Map<string, DocumentRecord>(); 56 + for (const doc of records) { 57 + const pub = byUri.get(doc.value.site); 58 + if (!pub) continue; // not one of the publications we surface 59 + const current = newestByPublication.get(doc.value.site); 60 + if (!current || (doc.value.publishedAt ?? '') > (current.value.publishedAt ?? '')) { 61 + newestByPublication.set(doc.value.site, doc); 62 + } 63 + } 64 + 65 + const posts: LatestPost[] = []; 66 + for (const [siteUri, doc] of newestByPublication) { 67 + const pub = byUri.get(siteUri)!; 68 + const cid = doc.value.coverImage?.ref.$link; 69 + posts.push({ 70 + title: doc.value.title, 71 + description: doc.value.description ?? '', 72 + publishedAt: doc.value.publishedAt ?? null, 73 + postUrl: buildPostUrl(pub.href, doc.value.path), 74 + coverImage: cid 75 + ? `${repo.pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(repo.did)}&cid=${cid}` 76 + : null, 77 + publicationName: pub.name 78 + }); 79 + } 80 + 81 + posts.sort((a, b) => (b.publishedAt ?? '').localeCompare(a.publishedAt ?? '')); 82 + 83 + cache.setWithExpiry('latestPosts', posts, HOUR_MS); 84 + return posts; 85 + } catch (error) { 86 + console.warn('Failed to fetch latest posts:', error); 87 + return []; 88 + } 89 + }
+6 -5
src/lib/server/atproto/publications.ts
··· 25 25 const publications: Publication[] = records 26 26 //Sorry flo <3, just wanting to show my writings 27 27 .filter((x) => !x.uri.includes('blento.self')) 28 - .map(({ value }) => ({ 29 - name: value.name, 30 - description: value.description ?? '', 31 - href: value.url, 32 - image: value.icon ? blobUrl(repo.pds, repo.did, value.icon) : null 28 + .map((x) => ({ 29 + uri: x.uri, 30 + name: x.value.name, 31 + description: x.value.description ?? '', 32 + href: x.value.url, 33 + image: x.value.icon ? blobUrl(repo.pds, repo.did, x.value.icon) : null 33 34 })); 34 35 35 36 cache.setWithExpiry('publications', publications, HOUR_MS);
+10
src/lib/types.ts
··· 6 6 }; 7 7 8 8 export type Publication = { 9 + uri: string; 9 10 name: string; 10 11 description: string; 11 12 href: string; 12 13 image: string | null; 14 + }; 15 + 16 + export type LatestPost = { 17 + title: string; 18 + description: string; 19 + publishedAt: string | null; 20 + postUrl: string; // publication.url + document.path 21 + coverImage: string | null; 22 + publicationName: string; 13 23 }; 14 24 15 25 export type CurrentlyReading = {
+2
src/routes/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 2 import { fetchSponsors } from '$lib/server/github'; 3 3 import { fetchPublications } from '$lib/server/atproto/publications'; 4 + import { fetchLatestPosts } from '$lib/server/atproto/documents'; 4 5 import { fetchCurrentlyReading } from '$lib/server/atproto/books'; 5 6 import { fetchNowPlaying } from '$lib/server/atproto/music'; 6 7 ··· 12 13 return { 13 14 sponsors, 14 15 publications, 16 + latestPosts: fetchLatestPosts(), 15 17 currentlyReading: fetchCurrentlyReading(), 16 18 nowPlaying: fetchNowPlaying(fetch) 17 19 };
+2 -2
src/routes/+page.svelte
··· 11 11 import type { PageProps } from './$types'; 12 12 13 13 let { data }: PageProps = $props(); 14 - const { sponsors, publications, currentlyReading, nowPlaying } = data; 14 + const { sponsors, publications, latestPosts, currentlyReading, nowPlaying } = data; 15 15 </script> 16 16 17 17 <svelte:head> ··· 26 26 <NavBar /> 27 27 <Hero /> 28 28 <OpenSourceProjects /> 29 - <Writing {publications} /> 29 + <Writing {publications} {latestPosts} /> 30 30 <FreeTime {currentlyReading} {nowPlaying} /> 31 31 <Sponsors {sponsors} /> 32 32 </div>