WIP: My personal website
0

Configure Feed

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

some counts

+118 -22
+1 -1
src/lib/components/FreeTime.svelte
··· 61 61 target="_blank" 62 62 rel="noopener noreferrer" 63 63 > 64 - Read 64 + View 65 65 </a> 66 66 </div> 67 67 <a class="mt-3 link font-urbanist text-sm link-accent" href="/history/books">
+25 -4
src/lib/components/Writing.svelte
··· 5 5 6 6 let { 7 7 publications, 8 - latestPosts 9 - }: { publications: Publication[]; latestPosts: Promise<LatestPost[]> } = $props(); 8 + latestPosts, 9 + subscriberCounts 10 + }: { 11 + publications: Publication[]; 12 + latestPosts: Promise<LatestPost[]>; 13 + subscriberCounts: Promise<Record<string, number>>; 14 + } = $props(); 10 15 11 16 const INITIAL_COUNT = 4; 12 17 const LOAD_STEP = 2; ··· 39 44 </figure> 40 45 {/if} 41 46 <div class="card-body"> 47 + {#await subscriberCounts then counts} 48 + {#if counts[item.uri] > 0} 49 + <span class="badge font-urbanist badge-secondary" 50 + >♥ {counts[item.uri]} subscribers</span 51 + > 52 + {/if} 53 + {/await} 42 54 <h2 class="card-title font-urbanist text-2xl font-black">{item.name}</h2> 43 55 <p class="text-md font-urbanist font-medium opacity-60">{item.description}</p> 44 56 <div class="mt-2 card-actions justify-end"> ··· 64 76 </button> 65 77 {/if} 66 78 67 - <h2 class="mt-16 text-center font-urbanist text-xl font-semibold opacity-80 md:mt-24 md:text-3xl"> 79 + <h2 80 + class="mt-16 text-center font-urbanist text-xl font-semibold opacity-80 md:mt-24 md:text-3xl" 81 + > 68 82 Latest Posts 69 83 </h2> 70 84 {#await latestPosts} ··· 92 106 </figure> 93 107 {/if} 94 108 <div class="card-body"> 95 - <span class="badge font-urbanist badge-primary">{post.publicationName}</span> 109 + <div class="flex flex-wrap items-center gap-2"> 110 + <span class="badge font-urbanist badge-primary">{post.publicationName}</span> 111 + {#if post.recommendCount > 0} 112 + <span class="badge font-urbanist badge-secondary" 113 + >★ {post.recommendCount} recommendations</span 114 + > 115 + {/if} 116 + </div> 96 117 <h3 class="mt-2 card-title font-urbanist text-2xl font-black">{post.title}</h3> 97 118 {#if post.publishedAt} 98 119 <p class="font-urbanist text-xs opacity-40">{publishedLabel(post.publishedAt)}</p>
+23 -15
src/lib/server/atproto/documents.ts
··· 2 2 import { cache, HOUR_MS } from '$lib/server/cache'; 3 3 import { getRepoInfo } from './client'; 4 4 import { fetchPublications } from './publications'; 5 + import { countDistinctDids } from './interactions'; 5 6 6 7 /** Minimal view of a `site.standard.document` record — metadata only, no content. */ 7 8 type DocumentRecord = { ··· 62 63 } 63 64 } 64 65 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 - } 66 + const posts: LatestPost[] = await Promise.all( 67 + [...newestByPublication].map(async ([siteUri, doc]) => { 68 + const pub = byUri.get(siteUri)!; 69 + const cid = doc.value.coverImage?.ref.$link; 70 + return { 71 + uri: doc.uri, 72 + title: doc.value.title, 73 + description: doc.value.description ?? '', 74 + publishedAt: doc.value.publishedAt ?? null, 75 + postUrl: buildPostUrl(pub.href, doc.value.path), 76 + coverImage: cid 77 + ? `${repo.pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(repo.did)}&cid=${cid}` 78 + : null, 79 + publicationName: pub.name, 80 + recommendCount: await countDistinctDids( 81 + doc.uri, 82 + 'site.standard.graph.recommend', 83 + '.document' 84 + ) 85 + }; 86 + }) 87 + ); 80 88 81 89 posts.sort((a, b) => (b.publishedAt ?? '').localeCompare(a.publishedAt ?? '')); 82 90
+62
src/lib/server/atproto/interactions.ts
··· 1 + import { cache, HOUR_MS } from '$lib/server/cache'; 2 + import { fetchPublications } from './publications'; 3 + 4 + const CONSTELLATION = 'https://constellation.microcosm.blue'; 5 + 6 + /** 7 + * Counts the distinct identities (DIDs) that link to `target` via a given 8 + * `collection`/`path` in the Constellation backlink index. Used for engagement 9 + * signals like subscribers and recommends. Cached per-target for 1 hour; returns 10 + * 0 on any failure so callers can simply hide a zero-count chip. 11 + */ 12 + export async function countDistinctDids( 13 + target: string, 14 + collection: string, 15 + path: string 16 + ): Promise<number> { 17 + const cacheKey = `constellation:${collection}:${target}`; 18 + const cached = cache.get(cacheKey) as { count: number } | null; 19 + if (cached) { 20 + return cached.count; 21 + } 22 + 23 + try { 24 + const url = 25 + `${CONSTELLATION}/links/count/distinct-dids` + 26 + `?target=${encodeURIComponent(target)}` + 27 + `&collection=${encodeURIComponent(collection)}` + 28 + `&path=${encodeURIComponent(path)}`; 29 + console.log(`Fetching Constellation count: ${url}`); 30 + const res = await fetch(url); 31 + if (!res.ok) { 32 + console.warn(`Constellation count failed: ${res.status} ${res.statusText}`); 33 + return 0; 34 + } 35 + const { total } = (await res.json()) as { total?: number }; 36 + const count = total ?? 0; 37 + cache.setWithExpiry(cacheKey, { count }, HOUR_MS); 38 + return count; 39 + } catch (error) { 40 + console.warn('Failed to fetch Constellation count:', error); 41 + return 0; 42 + } 43 + } 44 + 45 + /** 46 + * Maps each surfaced publication's at-uri to its subscriber count 47 + * (`site.standard.graph.subscription` records pointing at the publication). 48 + */ 49 + export async function fetchSubscriberCounts(): Promise<Record<string, number>> { 50 + const publications = await fetchPublications(); 51 + const counts = await Promise.all( 52 + publications.map((pub) => 53 + countDistinctDids(pub.uri, 'site.standard.graph.subscription', '.publication') 54 + ) 55 + ); 56 + 57 + const byUri: Record<string, number> = {}; 58 + publications.forEach((pub, i) => { 59 + byUri[pub.uri] = counts[i]; 60 + }); 61 + return byUri; 62 + }
+2
src/lib/types.ts
··· 14 14 }; 15 15 16 16 export type LatestPost = { 17 + uri: string; // the document's at-uri, used as the recommend-count target/key 17 18 title: string; 18 19 description: string; 19 20 publishedAt: string | null; 20 21 postUrl: string; // publication.url + document.path 21 22 coverImage: string | null; 22 23 publicationName: string; 24 + recommendCount: number; 23 25 }; 24 26 25 27 export type CurrentlyReading = {
+2
src/routes/+page.server.ts
··· 2 2 import { fetchSponsors } from '$lib/server/github'; 3 3 import { fetchPublications } from '$lib/server/atproto/publications'; 4 4 import { fetchLatestPosts } from '$lib/server/atproto/documents'; 5 + import { fetchSubscriberCounts } from '$lib/server/atproto/interactions'; 5 6 import { fetchCurrentlyReading } from '$lib/server/atproto/books'; 6 7 import { fetchNowPlaying } from '$lib/server/atproto/music'; 7 8 ··· 14 15 sponsors, 15 16 publications, 16 17 latestPosts: fetchLatestPosts(), 18 + subscriberCounts: fetchSubscriberCounts(), 17 19 currentlyReading: fetchCurrentlyReading(), 18 20 nowPlaying: fetchNowPlaying(fetch) 19 21 };
+3 -2
src/routes/+page.svelte
··· 11 11 import type { PageProps } from './$types'; 12 12 13 13 let { data }: PageProps = $props(); 14 - const { sponsors, publications, latestPosts, currentlyReading, nowPlaying } = data; 14 + const { sponsors, publications, latestPosts, subscriberCounts, currentlyReading, nowPlaying } = 15 + data; 15 16 </script> 16 17 17 18 <svelte:head> ··· 26 27 <NavBar /> 27 28 <Hero /> 28 29 <OpenSourceProjects /> 29 - <Writing {publications} {latestPosts} /> 30 + <Writing {publications} {latestPosts} {subscriberCounts} /> 30 31 <FreeTime {currentlyReading} {nowPlaying} /> 31 32 <Sponsors {sponsors} /> 32 33 </div>