my website at ewancroft.uk
6
fork

Configure Feed

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

feat(website): add GitHub Sponsors webhook and unified supporters timeline

+157 -43
+6
.env.example
··· 13 13 # KOFI_VERIFICATION_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 14 14 # ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx 15 15 16 + # GitHub Sponsors (optional) 17 + # Set this to the secret you configure when registering the webhook on GitHub. 18 + # Webhook URL: https://your-domain.com/webhook/github 19 + # Event: Sponsorship only — content type: application/json 20 + # GITHUB_WEBHOOK_SECRET=your-webhook-secret 21 + 16 22 # Fallback URL (optional) 17 23 # If a document cannot be found, redirect here 18 24 # Example: https://archive.example.com
+1 -1
package.json
··· 34 34 "@atproto/api": "^0.18.21", 35 35 "@ewanc26/atproto": "^0.2.8", 36 36 "@ewanc26/noise-avatar": "^0.2.3", 37 - "@ewanc26/supporters": "^0.1.8", 37 + "@ewanc26/supporters": "^0.2.1", 38 38 "@ewanc26/tid": "^1.1.3", 39 39 "@ewanc26/ui": "^0.3.8", 40 40 "@ewanc26/utils": "^0.1.5",
+6 -11
pnpm-lock.yaml
··· 18 18 specifier: ^0.2.3 19 19 version: 0.2.3 20 20 '@ewanc26/supporters': 21 - specifier: ^0.1.8 22 - version: 0.1.8(@atproto/api@0.18.21)(svelte@5.54.1) 21 + specifier: ^0.2.1 22 + version: 0.2.1(@atproto/api@0.18.21)(svelte@5.54.1) 23 23 '@ewanc26/tid': 24 24 specifier: ^1.1.3 25 25 version: 1.1.3 ··· 428 428 '@ewanc26/noise@0.1.1': 429 429 resolution: {integrity: sha512-XeAc0vFrcDHQA7K8xoVVCTYhB2opeBnvGj4s+6SqDS/E3IAP6V32mGf+H2U0JcQHsH4hvpVehYEFt0i1Blnrfg==} 430 430 431 - '@ewanc26/supporters@0.1.8': 432 - resolution: {integrity: sha512-gL6gL0vNvXDUfBX/8IJtk3bEIcMbX9FGgid/MtNt7eiqELJRo0GrYk44hMAy+GfXNuUSp0c6wVFcriusetTbzg==} 431 + '@ewanc26/supporters@0.2.1': 432 + resolution: {integrity: sha512-m3JaJSkXBRvw3Xg3r7x38+yGAqRd9At5R5wzg/IDMkTP797r0FdF46luOtEnMZkKaJzsahWF8KJlqNgnSo2o7w==} 433 433 peerDependencies: 434 434 '@atproto/api': '>=0.13.0' 435 435 svelte: ^5.0.0 436 - 437 - '@ewanc26/tid@1.1.1': 438 - resolution: {integrity: sha512-u/Ks251B+5Dy1lx1PC814mWpMg2TNly4b+bHMdLCz4TwiArD/se3iApL+L6pl16eKQQlqWS2yrVKhhSwThC3WA==} 439 436 440 437 '@ewanc26/tid@1.1.3': 441 438 resolution: {integrity: sha512-y30dhmJL5iK5hSql+wUD+gXLD31J7uDBxJhL5VRqKj/BmIDSwY3BhHcpRi/K+dgr4Fwzr5WQ3PliKhBZ6+B0ng==} ··· 1530 1527 1531 1528 '@ewanc26/noise@0.1.1': {} 1532 1529 1533 - '@ewanc26/supporters@0.1.8(@atproto/api@0.18.21)(svelte@5.54.1)': 1530 + '@ewanc26/supporters@0.2.1(@atproto/api@0.18.21)(svelte@5.54.1)': 1534 1531 dependencies: 1535 1532 '@atproto/api': 0.18.21 1536 - '@ewanc26/tid': 1.1.1 1533 + '@ewanc26/tid': 1.1.3 1537 1534 svelte: 5.54.1 1538 - 1539 - '@ewanc26/tid@1.1.1': {} 1540 1535 1541 1536 '@ewanc26/tid@1.1.3': {} 1542 1537
+47 -16
src/lib/components/layout/main/card/SupportersCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { Heart, ExternalLink } from '@lucide/svelte'; 3 3 import { Card, NoiseImage } from '$lib/components/ui'; 4 - import type { KofiSupportEvent, KofiEventType } from '$lib/services/atproto'; 4 + import type { UnifiedSupportEvent, KofiEventType, GitHubSponsorshipAction } from '$lib/services/atproto'; 5 5 6 6 interface Props { 7 - supporters?: KofiSupportEvent[] | null; 7 + supporters?: UnifiedSupportEvent[] | null; 8 8 } 9 9 10 10 let { supporters = null }: Props = $props(); 11 11 12 - const TYPE_LABELS: Record<KofiEventType, string> = { 12 + const KOFI_TYPE_LABELS: Record<KofiEventType, string> = { 13 13 Donation: '☕', 14 14 Subscription: '⭐', 15 15 Commission: '🎨', 16 16 'Shop Order': '🛍️' 17 17 }; 18 18 19 - const TYPE_DESCRIPTIONS: Record<KofiEventType, string> = { 19 + const KOFI_TYPE_DESCRIPTIONS: Record<KofiEventType, string> = { 20 20 Donation: 'donated', 21 21 Subscription: 'subscribed', 22 22 Commission: 'commissioned', 23 23 'Shop Order': 'placed a shop order' 24 24 }; 25 25 26 + const GITHUB_ACTION_LABELS: Record<GitHubSponsorshipAction, string> = { 27 + created: 'started sponsoring', 28 + cancelled: 'ended their sponsorship', 29 + edited: 'updated their sponsorship', 30 + tier_changed: 'changed sponsorship tier', 31 + pending_cancellation: 'scheduled cancellation', 32 + pending_tier_change: 'scheduled a tier change' 33 + }; 34 + 26 35 function formatDate(date: Date): string { 27 36 return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); 37 + } 38 + 39 + function noiseKey(event: UnifiedSupportEvent): string { 40 + if (event.source === 'kofi') return `${event.name}|${event.type}`; 41 + return `${event.login}|${event.action}`; 28 42 } 29 43 </script> 30 44 ··· 53 67 <Heart class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 54 68 <h2 class="text-2xl font-bold text-ink-900 dark:text-ink-50">Supporters</h2> 55 69 </div> 56 - <ol class="space-y-3" aria-label="Ko-fi support timeline"> 70 + <ol class="space-y-3" aria-label="Support timeline"> 57 71 {#each supporters as event (event.rkey)} 58 72 <li class="flex items-start gap-3"> 59 73 <NoiseImage 60 - seed={`${event.name}|${event.type}`} 61 - class="mt-0.5 h-8 w-8 shrink-0 rounded-full" 74 + seed={noiseKey(event)} 75 + class="mt-0.5 h-8 w-8 shrink-0 rounded-full" 62 76 /> 63 77 <div class="flex flex-col"> 64 - <p class="text-sm text-ink-900 dark:text-ink-100"> 65 - <span class="font-semibold">{event.name}</span> 66 - <span class="text-ink-600 dark:text-ink-400"> {TYPE_DESCRIPTIONS[event.type]}</span> 67 - <span class="ml-1" aria-label={event.type}>{TYPE_LABELS[event.type]}</span> 68 - {#if event.tier} 69 - <span class="ml-1 text-xs text-ink-500 dark:text-ink-500">· {event.tier}</span> 70 - {/if} 71 - </p> 78 + {#if event.source === 'kofi'} 79 + <p class="text-sm text-ink-900 dark:text-ink-100"> 80 + <span class="font-semibold">{event.name}</span> 81 + <span class="text-ink-600 dark:text-ink-400"> {KOFI_TYPE_DESCRIPTIONS[event.type]}</span> 82 + <span class="ml-1" aria-label={event.type}>{KOFI_TYPE_LABELS[event.type]}</span> 83 + {#if event.tier} 84 + <span class="ml-1 text-xs text-ink-500 dark:text-ink-500">· {event.tier}</span> 85 + {/if} 86 + </p> 87 + {:else} 88 + <p class="text-sm text-ink-900 dark:text-ink-100"> 89 + <span class="font-semibold">{event.name ?? event.login}</span> 90 + <span class="text-ink-600 dark:text-ink-400"> {GITHUB_ACTION_LABELS[event.action]}</span> 91 + <span class="ml-1 text-xs text-ink-500 dark:text-ink-500">· {event.tierName} · via GitHub</span> 92 + </p> 93 + {/if} 72 94 <time 73 95 datetime={event.date.toISOString()} 74 96 class="text-xs text-ink-500 dark:text-ink-500" ··· 79 101 </li> 80 102 {/each} 81 103 </ol> 82 - <div class="mt-4 border-t border-canvas-200 pt-4 dark:border-canvas-700"> 104 + <div class="mt-4 flex flex-wrap gap-x-4 gap-y-2 border-t border-canvas-200 pt-4 dark:border-canvas-700"> 83 105 <a 84 106 href="https://ko-fi.com/ewancroft" 85 107 target="_blank" ··· 87 109 class="inline-flex items-center gap-2 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" 88 110 > 89 111 Support me on Ko-fi 112 + <ExternalLink class="h-3.5 w-3.5" aria-hidden="true" /> 113 + </a> 114 + <a 115 + href="https://github.com/sponsors/ewanc26" 116 + target="_blank" 117 + rel="noopener noreferrer" 118 + class="inline-flex items-center gap-2 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" 119 + > 120 + Sponsor me on GitHub 90 121 <ExternalLink class="h-3.5 w-3.5" aria-hidden="true" /> 91 122 </a> 92 123 </div>
+3 -3
src/lib/services/atproto/index.ts
··· 81 81 // Export cache for advanced use cases 82 82 export { cache, ATProtoCache } from './cache'; 83 83 84 - // Export Ko-fi supporters 85 - export { fetchSupporters } from './supporters'; 86 - export type { KofiSupportEvent, KofiEventType } from './supporters'; 84 + // Export unified supporters timeline 85 + export { fetchAllSupporters } from './supporters'; 86 + export type { UnifiedSupportEvent, KofiEventType, GitHubSponsorshipAction } from './supporters';
+23 -5
src/lib/services/atproto/supporters.ts
··· 1 1 import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 2 - import { fetchEvents } from '@ewanc26/supporters'; 2 + import { fetchEvents, fetchSponsorEvents } from '@ewanc26/supporters'; 3 + import type { KofiSupportEvent, KofiEventType, GitHubSponsorEvent, GitHubSponsorshipAction } from '@ewanc26/supporters'; 3 4 4 - export type { KofiSupportEvent } from '@ewanc26/supporters'; 5 - export type { KofiEventType } from '@ewanc26/supporters'; 5 + export type { KofiEventType, GitHubSponsorshipAction }; 6 6 7 - export function fetchSupporters() { 8 - return fetchEvents(PUBLIC_ATPROTO_DID); 7 + export type UnifiedSupportEvent = 8 + | ({ source: 'kofi' } & KofiSupportEvent) 9 + | ({ source: 'github' } & GitHubSponsorEvent); 10 + 11 + export async function fetchAllSupporters(): Promise<UnifiedSupportEvent[]> { 12 + const [kofi, github] = await Promise.allSettled([ 13 + fetchEvents(PUBLIC_ATPROTO_DID), 14 + fetchSponsorEvents(PUBLIC_ATPROTO_DID) 15 + ]); 16 + 17 + const events: UnifiedSupportEvent[] = []; 18 + 19 + if (kofi.status === 'fulfilled') { 20 + for (const e of kofi.value) events.push({ source: 'kofi', ...e }); 21 + } 22 + if (github.status === 'fulfilled') { 23 + for (const e of github.value) events.push({ source: 'github', ...e }); 24 + } 25 + 26 + return events.sort((a, b) => b.date.getTime() - a.date.getTime()); 9 27 }
+3 -7
src/routes/+page.ts
··· 5 5 fetchLatestBlueskyPost, 6 6 fetchTangledRepos, 7 7 fetchRecentDocuments, 8 - fetchSupporters, 8 + fetchAllSupporters, 9 9 fetchRecentPopfeedReviews 10 10 } from '$lib/services/atproto'; 11 11 12 12 export const load: PageLoad = async ({ fetch, parent }) => { 13 - // Get parent data (includes profile from layout) 14 13 const { profile } = await parent(); 15 14 16 - // Fetch page-specific data in parallel for better performance 17 15 const [musicStatus, kibunStatus, latestPost, tangledRepos, documents, supporters, popfeedReview] = await Promise.allSettled([ 18 16 fetchMusicStatus(fetch), 19 17 fetchKibunStatus(fetch), 20 18 fetchLatestBlueskyPost(fetch), 21 19 fetchTangledRepos(fetch), 22 - fetchRecentDocuments(5, fetch), // Fetch 5 most recent documents 23 - fetchSupporters(), 20 + fetchRecentDocuments(5, fetch), 21 + fetchAllSupporters(), 24 22 fetchRecentPopfeedReviews(fetch) 25 23 ]); 26 24 27 25 return { 28 - // Pass through profile from parent 29 26 profile, 30 - // Page-specific data 31 27 musicStatus: musicStatus.status === 'fulfilled' ? musicStatus.value : null, 32 28 kibunStatus: kibunStatus.status === 'fulfilled' ? kibunStatus.value : null, 33 29 latestPost: latestPost.status === 'fulfilled' ? latestPost.value : null,
+68
src/routes/webhook/github/+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 { parseGitHubSponsorsWebhook, GitHubWebhookError, appendSponsorEvent } from '@ewanc26/supporters'; 5 + import type { RequestHandler } from './$types'; 6 + 7 + export const POST: RequestHandler = async ({ request }) => { 8 + console.log('[webhook/github] POST received', { 9 + event: request.headers.get('x-github-event'), 10 + delivery: request.headers.get('x-github-delivery') 11 + }); 12 + 13 + // Bridge SvelteKit's private env into process.env for the supporters package. 14 + process.env.ATPROTO_DID = PUBLIC_ATPROTO_DID; 15 + process.env.ATPROTO_APP_PASSWORD = env.ATPROTO_APP_PASSWORD; 16 + 17 + let payload; 18 + try { 19 + payload = await parseGitHubSponsorsWebhook(request, { 20 + secret: env.GITHUB_WEBHOOK_SECRET 21 + }); 22 + console.log('[webhook/github] parsed payload', { 23 + action: payload.action, 24 + sponsor: payload.sponsorship.sponsor.login, 25 + tier: payload.sponsorship.tier.name, 26 + privacy: payload.sponsorship.privacy_level 27 + }); 28 + } catch (err) { 29 + if (err instanceof GitHubWebhookError) { 30 + console.error('[webhook/github] GitHubWebhookError', { status: err.status, message: err.message }); 31 + return json({ error: err.message }, { status: err.status }); 32 + } 33 + console.error('[webhook/github] unexpected parse error', err); 34 + throw err; 35 + } 36 + 37 + // Respect the sponsor's privacy preference. 38 + if (payload.sponsorship.privacy_level !== 'public') { 39 + console.log('[webhook/github] skipping private sponsorship'); 40 + return new Response(null, { status: 200 }); 41 + } 42 + 43 + // pending_* actions are informational — don't write a record until the action completes. 44 + if (payload.action === 'pending_cancellation' || payload.action === 'pending_tier_change') { 45 + console.log('[webhook/github] skipping pending action', payload.action); 46 + return new Response(null, { status: 200 }); 47 + } 48 + 49 + try { 50 + await appendSponsorEvent( 51 + payload.sponsorship.sponsor.login, 52 + payload.sponsorship.sponsor.name, 53 + payload.action, 54 + payload.sponsorship.tier.name, 55 + payload.sponsorship.tier.monthly_price_in_dollars, 56 + payload.sponsorship.created_at 57 + ); 58 + console.log('[webhook/github] appendSponsorEvent success', { 59 + login: payload.sponsorship.sponsor.login, 60 + action: payload.action 61 + }); 62 + } catch (err) { 63 + console.error('[webhook/github] appendSponsorEvent failed', err); 64 + throw err; 65 + } 66 + 67 + return new Response(null, { status: 200 }); 68 + };