my website at ewancroft.uk
6
fork

Configure Feed

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

feat(website): integrate @ewanc26/supporters

+232 -12
+6
.env.example
··· 2 2 # You can find this in your Bluesky profile settings or at https://bsky.app 3 3 PUBLIC_ATPROTO_DID=did:plc:your-did-here 4 4 5 + # Ko-fi Supporters (optional) 6 + # PDS and DID are resolved automatically from PUBLIC_ATPROTO_DID above. 7 + # The only secrets needed are the Ko-fi verification token and a PDS app password. 8 + # KOFI_VERIFICATION_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 9 + # ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx 10 + 5 11 # Fallback URL (optional) 6 12 # If a document cannot be found, redirect here 7 13 # Example: https://archive.example.com
+5 -5
package.json
··· 4 4 "version": "11.0.0", 5 5 "type": "module", 6 6 "scripts": { 7 - "build:packages": "pnpm --filter '@ewanc26/*' build", 8 - "prebuild": "pnpm build:packages", 9 7 "dev": "vite dev", 10 8 "build": "vite build", 11 9 "preview": "vite preview", ··· 32 30 }, 33 31 "dependencies": { 34 32 "@atproto/api": "^0.18.1", 35 - "@ewanc26/atproto": "workspace:*", 36 - "@ewanc26/ui": "workspace:*", 37 - "@ewanc26/utils": "workspace:*", 33 + "@ewanc26/atproto": "^0.2.1", 34 + "@ewanc26/supporters": "^0.1.0", 35 + "@ewanc26/tid": "^1.1.1", 36 + "@ewanc26/ui": "^0.1.3", 37 + "@ewanc26/utils": "^0.1.1", 38 38 "@lucide/svelte": "^0.554.0", 39 39 "hls.js": "^1.6.15" 40 40 }
+1 -2
pnpm-workspace.yaml
··· 1 - packages: 2 - - packages/* 1 + packages: []
+92
src/lib/components/layout/main/card/SupportersCard.svelte
··· 1 + <script lang="ts"> 2 + import { Heart } from '@lucide/svelte'; 3 + import { Card } from '$lib/components/ui'; 4 + import type { KofiSupporter, KofiEventType } from '$lib/services/atproto'; 5 + 6 + interface Props { 7 + supporters?: KofiSupporter[] | null; 8 + } 9 + 10 + let { supporters = null }: Props = $props(); 11 + 12 + const TYPE_LABELS: Record<KofiEventType, string> = { 13 + Donation: '☕', 14 + Subscription: '⭐', 15 + Commission: '🎨', 16 + 'Shop Order': '🛍️' 17 + }; 18 + 19 + /** Deterministic pastel hue from a name string. */ 20 + function nameToHsl(name: string): string { 21 + let hash = 0; 22 + for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash); 23 + const h = Math.abs(hash) % 360; 24 + return `hsl(${h} 55% 70%)`; 25 + } 26 + 27 + /** Up-to-two-letter initials from a display name. */ 28 + function initials(name: string): string { 29 + return name 30 + .split(/\s+/) 31 + .slice(0, 2) 32 + .map((w) => w[0]?.toUpperCase() ?? '') 33 + .join(''); 34 + } 35 + </script> 36 + 37 + <div class="mx-auto w-full max-w-2xl"> 38 + {#if !supporters} 39 + <Card loading={true} variant="elevated" padding="md"> 40 + {#snippet skeleton()} 41 + <div class="mb-4 h-6 w-40 rounded bg-canvas-300 dark:bg-canvas-700"></div> 42 + <div class="flex flex-wrap gap-3"> 43 + {#each Array(6) as _} 44 + <div class="flex flex-col items-center gap-2 p-2"> 45 + <div class="h-12 w-12 rounded-full bg-canvas-300 dark:bg-canvas-700"></div> 46 + <div class="h-3 w-14 rounded bg-canvas-300 dark:bg-canvas-700"></div> 47 + </div> 48 + {/each} 49 + </div> 50 + {/snippet} 51 + </Card> 52 + {:else if supporters.length > 0} 53 + <Card variant="elevated" padding="md"> 54 + {#snippet children()} 55 + <div class="mb-4 flex items-center gap-2"> 56 + <Heart class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 57 + <h2 class="text-2xl font-bold text-ink-900 dark:text-ink-50">Supporters</h2> 58 + </div> 59 + <p class="mb-4 text-sm text-ink-600 dark:text-ink-400"> 60 + People who support my work on Ko-fi. 61 + </p> 62 + <ul class="flex flex-wrap gap-2" aria-label="Ko-fi supporters"> 63 + {#each supporters as supporter (supporter.name)} 64 + {@const icons = supporter.types.map((t) => TYPE_LABELS[t]).join('')} 65 + <li> 66 + <div 67 + class="flex flex-col items-center gap-1 rounded-lg p-2 transition-colors hover:bg-canvas-100 dark:hover:bg-canvas-800" 68 + title="{supporter.name} · {supporter.types.join(', ')}{supporter.tiers.length ? ` · ${supporter.tiers.join(', ')}` : ''}" 69 + > 70 + <span 71 + class="flex h-12 w-12 items-center justify-content-center items-center justify-center rounded-full text-sm font-bold text-white" 72 + style="background-color: {nameToHsl(supporter.name)}" 73 + aria-hidden="true" 74 + > 75 + {initials(supporter.name)} 76 + </span> 77 + <span 78 + class="max-w-[4.5rem] overflow-hidden text-ellipsis whitespace-nowrap text-xs text-ink-800 dark:text-ink-200" 79 + > 80 + {supporter.name} 81 + </span> 82 + <span class="text-[0.65rem] leading-none" aria-label={supporter.types.join(', ')}> 83 + {icons} 84 + </span> 85 + </div> 86 + </li> 87 + {/each} 88 + </ul> 89 + {/snippet} 90 + </Card> 91 + {/if} 92 + </div>
+2
src/lib/components/layout/main/card/index.ts
··· 1 1 // BlueskyPostCard uses the app's DID-bound fetchLatestBlueskyPost wrapper — keep it local. 2 2 export { default as BlueskyPostCard } from './BlueskyPostCard.svelte'; 3 + // SupportersCard — local because it uses the app's own service layer. 4 + export { default as SupportersCard } from './SupportersCard.svelte'; 3 5 // The rest are data-in, presentation-only — re-export from the package. 4 6 export { LinkCard, ProfileCard, PostCard, TangledRepoCard, MusicStatusCard, KibunStatusCard } from '@ewanc26/ui';
+4
src/lib/services/atproto/index.ts
··· 76 76 77 77 // Export cache for advanced use cases 78 78 export { cache, ATProtoCache } from './cache'; 79 + 80 + // Export Ko-fi supporters 81 + export { fetchSupporters } from './supporters'; 82 + export type { KofiSupporter, KofiEventType } from './supporters';
+65
src/lib/services/atproto/supporters.ts
··· 1 + /** 2 + * Ko-fi supporters service 3 + * 4 + * Reads uk.ewancroft.kofi.supporter records from the PDS and aggregates them 5 + * into KofiSupporter objects. No auth required — records are publicly readable. 6 + * 7 + * The PDS URL is resolved automatically from PUBLIC_ATPROTO_DID via resolveIdentity. 8 + * No additional environment variables are needed for the read path. 9 + */ 10 + 11 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 12 + import { getPDSAgent } from '@ewanc26/atproto'; 13 + import type { KofiSupporter, KofiEventType } from '@ewanc26/supporters'; 14 + 15 + export type { KofiSupporter, KofiEventType }; 16 + 17 + const COLLECTION = 'uk.ewancroft.kofi.supporter'; 18 + 19 + interface KofiEventRecord { 20 + name: string; 21 + type: KofiEventType; 22 + tier?: string; 23 + } 24 + 25 + function dedupe<T>(arr: T[], extra: T): T[] { 26 + return Array.from(new Set([...arr, extra])); 27 + } 28 + 29 + function aggregateEvents(events: KofiEventRecord[]): KofiSupporter[] { 30 + const map = new Map<string, KofiSupporter>(); 31 + 32 + for (const event of events) { 33 + const existing = map.get(event.name); 34 + map.set(event.name, { 35 + name: event.name, 36 + types: dedupe(existing?.types ?? [], event.type), 37 + tiers: event.tier ? dedupe(existing?.tiers ?? [], event.tier) : (existing?.tiers ?? []) 38 + }); 39 + } 40 + 41 + return Array.from(map.values()); 42 + } 43 + 44 + export async function fetchSupporters(): Promise<KofiSupporter[]> { 45 + const agent = await getPDSAgent(PUBLIC_ATPROTO_DID); 46 + const events: KofiEventRecord[] = []; 47 + let cursor: string | undefined; 48 + 49 + do { 50 + const res = await agent.com.atproto.repo.listRecords({ 51 + repo: PUBLIC_ATPROTO_DID, 52 + collection: COLLECTION, 53 + limit: 100, 54 + cursor 55 + }); 56 + 57 + for (const record of res.data.records) { 58 + events.push(record.value as unknown as KofiEventRecord); 59 + } 60 + 61 + cursor = res.data.cursor; 62 + } while (cursor); 63 + 64 + return aggregateEvents(events); 65 + }
+7 -1
src/routes/+page.svelte
··· 6 6 BlueskyPostCard, 7 7 MusicStatusCard, 8 8 KibunStatusCard, 9 - TangledRepoCard 9 + TangledRepoCard, 10 + SupportersCard 10 11 } from '$lib/components/layout/main/card'; 11 12 import { createSiteMeta } from '$lib/helper/siteMeta'; 12 13 import type { PageData } from './$types'; ··· 58 59 <div class="mb-6 break-inside-avoid"> 59 60 <TangledRepoCard repos={data.tangledRepos} profile={data.profile} /> 60 61 </div> 62 + {#if data.supporters.length > 0} 63 + <div class="mb-6 break-inside-avoid"> 64 + <SupportersCard supporters={data.supporters} /> 65 + </div> 66 + {/if} 61 67 </div> 62 68 </div>
+7 -4
src/routes/+page.ts
··· 4 4 fetchKibunStatus, 5 5 fetchLatestBlueskyPost, 6 6 fetchTangledRepos, 7 - fetchRecentDocuments 7 + fetchRecentDocuments, 8 + fetchSupporters 8 9 } from '$lib/services/atproto'; 9 10 10 11 export const load: PageLoad = async ({ fetch, parent }) => { ··· 12 13 const { profile } = await parent(); 13 14 14 15 // Fetch page-specific data in parallel for better performance 15 - const [musicStatus, kibunStatus, latestPost, tangledRepos, documents] = await Promise.allSettled([ 16 + const [musicStatus, kibunStatus, latestPost, tangledRepos, documents, supporters] = await Promise.allSettled([ 16 17 fetchMusicStatus(fetch), 17 18 fetchKibunStatus(fetch), 18 19 fetchLatestBlueskyPost(fetch), 19 20 fetchTangledRepos(fetch), 20 - fetchRecentDocuments(5, fetch) // Fetch 5 most recent documents 21 + fetchRecentDocuments(5, fetch), // Fetch 5 most recent documents 22 + fetchSupporters() 21 23 ]); 22 24 23 25 return { ··· 28 30 kibunStatus: kibunStatus.status === 'fulfilled' ? kibunStatus.value : null, 29 31 latestPost: latestPost.status === 'fulfilled' ? latestPost.value : null, 30 32 tangledRepos: tangledRepos.status === 'fulfilled' ? tangledRepos.value : null, 31 - documents: documents.status === 'fulfilled' ? documents.value : [] 33 + documents: documents.status === 'fulfilled' ? documents.value : [], 34 + supporters: supporters.status === 'fulfilled' ? supporters.value : [] 32 35 }; 33 36 };
+43
src/routes/webhook/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import { env } from '$env/dynamic/private'; 3 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 4 + import { parseWebhook, WebhookError } from '@ewanc26/supporters'; 5 + import { getPDSAgent } from '@ewanc26/atproto'; 6 + import { generateTID } from '@ewanc26/tid'; 7 + import type { RequestHandler } from './$types'; 8 + 9 + const COLLECTION = 'uk.ewancroft.kofi.supporter'; 10 + 11 + export const POST: RequestHandler = async ({ request }) => { 12 + let payload; 13 + try { 14 + payload = await parseWebhook(request); 15 + } catch (err) { 16 + if (err instanceof WebhookError) { 17 + return json({ error: err.message }, { status: err.status }); 18 + } 19 + throw err; 20 + } 21 + 22 + if (!payload.is_public) { 23 + return new Response(null, { status: 200 }); 24 + } 25 + 26 + const agent = await getPDSAgent(PUBLIC_ATPROTO_DID); 27 + await agent.login({ identifier: PUBLIC_ATPROTO_DID, password: env.ATPROTO_APP_PASSWORD }); 28 + 29 + const record = { 30 + name: payload.from_name, 31 + type: payload.type, 32 + ...(payload.tier_name ? { tier: payload.tier_name } : {}) 33 + }; 34 + 35 + await agent.com.atproto.repo.putRecord({ 36 + repo: PUBLIC_ATPROTO_DID, 37 + collection: COLLECTION, 38 + rkey: generateTID(payload.timestamp), 39 + record: record as Record<string, unknown> 40 + }); 41 + 42 + return new Response(null, { status: 200 }); 43 + };