The jollywhoppers homepage ๐Ÿฌ๐Ÿ”

feat: fetch members dynamically from Bluesky list via AT Protocol

- Add @atproto/api service layer with caching and agent management
- Extend ProjectCard component to support optional avatars and handles
- Implement server-side list member fetching with alphabetical sorting
- Replace static MEMBERS array with dynamic data from list
- Add proper error handling and graceful degradation

ewancroft.uk 7da9c57f e29a88b0

verified
+2
.vscode/settings.json
··· 1 1 { 2 2 "cSpell.words": [ 3 + "atproto", 3 4 "colours", 4 5 "jollywhoppers", 6 + "lineheight", 5 7 "revitalisation", 6 8 "Witchsky" 7 9 ]
+1
package.json
··· 25 25 "vite": "^7.2.6" 26 26 }, 27 27 "dependencies": { 28 + "@atproto/api": "^0.18.8", 28 29 "@lucide/svelte": "^0.562.0" 29 30 } 30 31 }
+112
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@atproto/api': 12 + specifier: ^0.18.8 13 + version: 0.18.8 11 14 '@lucide/svelte': 12 15 specifier: ^0.562.0 13 16 version: 0.562.0(svelte@5.46.0) ··· 41 44 version: 7.3.0 42 45 43 46 packages: 47 + 48 + '@atproto/api@0.18.8': 49 + resolution: {integrity: sha512-Qo3sGd1N5hdHTaEWUBgptvPkULt2SXnMcWRhveSyctSd/IQwTMyaIH6E62A1SU+8xBSN5QLpoUJNE7iSrYM2Zg==} 50 + 51 + '@atproto/common-web@0.4.7': 52 + resolution: {integrity: sha512-vjw2+81KPo2/SAbbARGn64Ln+6JTI0FTI4xk8if0ebBfDxFRmHb2oSN1y77hzNq/ybGHqA2mecfhS03pxC5+lg==} 53 + 54 + '@atproto/lex-data@0.0.3': 55 + resolution: {integrity: sha512-ivo1IpY/EX+RIpxPgCf4cPhQo5bfu4nrpa1vJCt8hCm9SfoonJkDFGa0n4SMw4JnXZoUcGcrJ46L+D8bH6GI2g==} 56 + 57 + '@atproto/lex-json@0.0.3': 58 + resolution: {integrity: sha512-ZVcY7XlRfdPYvQQ2WroKUepee0+NCovrSXgXURM3Xv+n5jflJCoczguROeRr8sN0xvT0ZbzMrDNHCUYKNnxcjw==} 59 + 60 + '@atproto/lexicon@0.6.0': 61 + resolution: {integrity: sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==} 62 + 63 + '@atproto/syntax@0.4.2': 64 + resolution: {integrity: sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==} 65 + 66 + '@atproto/xrpc@0.7.7': 67 + resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} 44 68 45 69 '@esbuild/aix-ppc64@0.27.2': 46 70 resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} ··· 388 412 resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} 389 413 engines: {node: '>= 0.4'} 390 414 415 + await-lock@2.2.2: 416 + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} 417 + 391 418 axobject-query@4.1.0: 392 419 resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} 393 420 engines: {node: '>= 0.4'} ··· 448 475 is-reference@3.0.3: 449 476 resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} 450 477 478 + iso-datestring-validator@2.2.2: 479 + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} 480 + 451 481 kleur@4.1.5: 452 482 resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} 453 483 engines: {node: '>=6'} ··· 469 499 ms@2.1.3: 470 500 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 471 501 502 + multiformats@9.9.0: 503 + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 504 + 472 505 nanoid@3.3.11: 473 506 resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} 474 507 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} ··· 536 569 resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 537 570 engines: {node: '>=12.0.0'} 538 571 572 + tlds@1.261.0: 573 + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} 574 + hasBin: true 575 + 539 576 totalist@3.0.1: 540 577 resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} 541 578 engines: {node: '>=6'} 579 + 580 + tslib@2.8.1: 581 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 542 582 543 583 typescript@5.9.3: 544 584 resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 545 585 engines: {node: '>=14.17'} 546 586 hasBin: true 547 587 588 + uint8arrays@3.0.0: 589 + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} 590 + 591 + unicode-segmenter@0.14.4: 592 + resolution: {integrity: sha512-pR5VCiCrLrKOL6FRW61jnk9+wyMtKKowq+jyFY9oc6uHbWKhDL4yVRiI4YZPksGMK72Pahh8m0cn/0JvbDDyJg==} 593 + 548 594 vite@7.3.0: 549 595 resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} 550 596 engines: {node: ^20.19.0 || >=22.12.0} ··· 596 642 zimmerframe@1.1.4: 597 643 resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} 598 644 645 + zod@3.25.76: 646 + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} 647 + 599 648 snapshots: 600 649 650 + '@atproto/api@0.18.8': 651 + dependencies: 652 + '@atproto/common-web': 0.4.7 653 + '@atproto/lexicon': 0.6.0 654 + '@atproto/syntax': 0.4.2 655 + '@atproto/xrpc': 0.7.7 656 + await-lock: 2.2.2 657 + multiformats: 9.9.0 658 + tlds: 1.261.0 659 + zod: 3.25.76 660 + 661 + '@atproto/common-web@0.4.7': 662 + dependencies: 663 + '@atproto/lex-data': 0.0.3 664 + '@atproto/lex-json': 0.0.3 665 + zod: 3.25.76 666 + 667 + '@atproto/lex-data@0.0.3': 668 + dependencies: 669 + '@atproto/syntax': 0.4.2 670 + multiformats: 9.9.0 671 + tslib: 2.8.1 672 + uint8arrays: 3.0.0 673 + unicode-segmenter: 0.14.4 674 + 675 + '@atproto/lex-json@0.0.3': 676 + dependencies: 677 + '@atproto/lex-data': 0.0.3 678 + tslib: 2.8.1 679 + 680 + '@atproto/lexicon@0.6.0': 681 + dependencies: 682 + '@atproto/common-web': 0.4.7 683 + '@atproto/syntax': 0.4.2 684 + iso-datestring-validator: 2.2.2 685 + multiformats: 9.9.0 686 + zod: 3.25.76 687 + 688 + '@atproto/syntax@0.4.2': {} 689 + 690 + '@atproto/xrpc@0.7.7': 691 + dependencies: 692 + '@atproto/lexicon': 0.6.0 693 + zod: 3.25.76 694 + 601 695 '@esbuild/aix-ppc64@0.27.2': 602 696 optional: true 603 697 ··· 825 919 826 920 aria-query@5.3.2: {} 827 921 922 + await-lock@2.2.2: {} 923 + 828 924 axobject-query@4.1.0: {} 829 925 830 926 chokidar@4.0.3: ··· 889 985 dependencies: 890 986 '@types/estree': 1.0.8 891 987 988 + iso-datestring-validator@2.2.2: {} 989 + 892 990 kleur@4.1.5: {} 893 991 894 992 locate-character@3.0.0: {} ··· 902 1000 mrmime@2.0.1: {} 903 1001 904 1002 ms@2.1.3: {} 1003 + 1004 + multiformats@9.9.0: {} 905 1005 906 1006 nanoid@3.3.11: {} 907 1007 ··· 1001 1101 fdir: 6.5.0(picomatch@4.0.3) 1002 1102 picomatch: 4.0.3 1003 1103 1104 + tlds@1.261.0: {} 1105 + 1004 1106 totalist@3.0.1: {} 1005 1107 1108 + tslib@2.8.1: {} 1109 + 1006 1110 typescript@5.9.3: {} 1111 + 1112 + uint8arrays@3.0.0: 1113 + dependencies: 1114 + multiformats: 9.9.0 1115 + 1116 + unicode-segmenter@0.14.4: {} 1007 1117 1008 1118 vite@7.3.0: 1009 1119 dependencies: ··· 1021 1131 vite: 7.3.0 1022 1132 1023 1133 zimmerframe@1.1.4: {} 1134 + 1135 + zod@3.25.76: {}
+77 -6
src/lib/components/ProjectCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { ExternalLink } from '@lucide/svelte'; 3 3 4 - let { title, href = '#' } = $props<{ title: string; href?: string }>(); 4 + let { title, href = '#', avatar, handle } = $props<{ 5 + title: string; 6 + href?: string; 7 + avatar?: string; 8 + handle?: string; 9 + }>(); 5 10 </script> 6 11 7 - <a {href} class="project-card"> 8 - <h3 class="project-title">{title}</h3> 12 + <a {href} class="project-card" target="_blank" rel="noopener noreferrer"> 13 + {#if avatar} 14 + <img src={avatar} alt={title} class="project-avatar" /> 15 + {/if} 16 + <div class="project-text"> 17 + <h3 class="project-title">{title}</h3> 18 + {#if handle} 19 + <p class="project-handle">@{handle}</p> 20 + {/if} 21 + </div> 9 22 <ExternalLink size={20} class="project-icon" /> 10 23 <div class="card-shine"></div> 11 24 </a> ··· 14 27 .project-card { 15 28 position: relative; 16 29 display: flex; 30 + flex-direction: column; 17 31 align-items: center; 18 32 justify-content: center; 19 - gap: var(--size-2); 33 + gap: var(--size-3); 20 34 min-height: 180px; 21 35 padding: var(--size-6); 22 36 background: var(--color-surface); ··· 66 80 box-shadow: var(--shadow-3); 67 81 } 68 82 83 + .project-avatar { 84 + width: 64px; 85 + height: 64px; 86 + border-radius: 50%; 87 + object-fit: cover; 88 + transition: transform var(--ease-out-3) 300ms; 89 + z-index: 1; 90 + } 91 + 92 + .project-card:hover .project-avatar { 93 + transform: scale(1.05); 94 + } 95 + 96 + .project-text { 97 + display: flex; 98 + flex-direction: column; 99 + align-items: center; 100 + gap: var(--size-1); 101 + width: 100%; 102 + z-index: 1; 103 + } 104 + 69 105 .project-title { 70 106 position: relative; 71 107 margin: 0; ··· 75 111 text-align: center; 76 112 line-height: var(--font-lineheight-2); 77 113 transition: color var(--ease-out-3) 300ms; 78 - z-index: 1; 114 + overflow: hidden; 115 + text-overflow: ellipsis; 116 + white-space: nowrap; 117 + max-width: 100%; 118 + } 119 + 120 + .project-handle { 121 + margin: 0; 122 + color: rgba(67, 87, 173, 0.7); 123 + font-size: var(--font-size-1); 124 + font-weight: var(--font-weight-5); 125 + text-align: center; 126 + line-height: var(--font-lineheight-2); 127 + transition: color var(--ease-out-3) 300ms; 128 + overflow: hidden; 129 + text-overflow: ellipsis; 130 + white-space: nowrap; 131 + max-width: 100%; 79 132 } 80 133 81 134 .project-card :global(.project-icon) { 82 - position: relative; 135 + position: absolute; 136 + top: var(--size-3); 137 + right: var(--size-3); 83 138 color: rgba(67, 87, 173, 0.5); 84 139 transition: all var(--ease-out-3) 300ms; 85 140 z-index: 1; ··· 89 144 color: var(--color-primary-600); 90 145 } 91 146 147 + .project-card:hover .project-handle { 148 + color: var(--color-primary-500); 149 + } 150 + 92 151 .project-card:hover :global(.project-icon) { 93 152 color: var(--color-primary-600); 94 153 transform: translate(4px, -4px); ··· 100 159 padding: var(--size-4); 101 160 } 102 161 162 + .project-avatar { 163 + width: 48px; 164 + height: 48px; 165 + } 166 + 103 167 .project-title { 104 168 font-size: var(--font-size-2); 105 169 } 106 170 171 + .project-handle { 172 + font-size: var(--font-size-0); 173 + } 174 + 107 175 .project-card :global(.project-icon) { 108 176 width: 16px; 109 177 height: 16px; ··· 114 182 .project-card, 115 183 .card-shine, 116 184 .project-title, 185 + .project-handle, 186 + .project-avatar, 117 187 .project-card::before, 118 188 .project-card :global(.project-icon) { 119 189 transition: none; ··· 123 193 transform: none; 124 194 } 125 195 196 + .project-card:hover .project-avatar, 126 197 .project-card:hover :global(.project-icon) { 127 198 transform: none; 128 199 }
+7 -2
src/lib/components/ProjectGrid.svelte
··· 6 6 projects = [] 7 7 } = $props<{ 8 8 title?: string; 9 - projects?: Array<{ title: string; href?: string }>; 9 + projects?: Array<{ title: string; href?: string; avatar?: string; handle?: string }>; 10 10 }>(); 11 11 </script> 12 12 ··· 15 15 <div class="project-grid"> 16 16 {#each projects as project, i} 17 17 <div class="grid-item" style="--delay: {i * 80}ms"> 18 - <ProjectCard title={project.title} href={project.href} /> 18 + <ProjectCard 19 + title={project.title} 20 + href={project.href} 21 + avatar={project.avatar} 22 + handle={project.handle} 23 + /> 19 24 </div> 20 25 {/each} 21 26 </div>
+3
src/lib/components/index.ts
··· 9 9 export type Project = { 10 10 title: string; 11 11 href?: string; 12 + // Optional member-specific fields 13 + avatar?: string; 14 + handle?: string; 12 15 }; 13 16 14 17 export type AboutItem = {
+2 -5
src/lib/constants.ts
··· 24 24 { title: 'Contributor 3', href: '#' } 25 25 ]; 26 26 27 - export const MEMBERS: Project[] = [ 28 - { title: 'Member 1', href: '#' }, 29 - { title: 'Member 2', href: '#' }, 30 - { title: 'Member 3', href: '#' } 31 - ]; 27 + // Members are now fetched dynamically from Bluesky list 28 + // See src/routes/+page.server.ts for the fetch logic 32 29 33 30 export const ABOUT_ITEMS: AboutItem[] = []; 34 31
+150
src/lib/services/atproto/README.md
··· 1 + # AT Protocol Service for Jollywhoppers 2 + 3 + This directory contains a DRY (Don't Repeat Yourself) service for fetching member data from a Bluesky list using the AT Protocol. Members are displayed using the existing `ProjectCard` component, which has been extended to support optional avatars and handles. 4 + 5 + ## Architecture 6 + 7 + The service is modeled after the implementation in [Ewan's website](https://ewancroft.uk) and follows best practices for AT Protocol data fetching. 8 + 9 + ### Files 10 + 11 + - **`types.ts`** - TypeScript type definitions for profiles, lists, and cache entries 12 + - **`cache.ts`** - In-memory caching with TTL support to reduce API calls 13 + - **`agents.ts`** - AT Protocol agent creation with PDS resolution via Slingshot 14 + - **`list.ts`** - Core logic for fetching list members and their profiles 15 + - **`index.ts`** - Public API exports 16 + 17 + ## Usage 18 + 19 + ### Server-Side Data Loading 20 + 21 + ```typescript 22 + // src/routes/+page.server.ts 23 + import { fetchListMembers } from '$lib/services/atproto'; 24 + 25 + export const load: PageServerLoad = async ({ fetch }) => { 26 + const LIST_URI = 'at://did:plc:lwckcyzhyrufq4ytg2abji7d/app.bsky.graph.list/3mas22fg3ud2y'; 27 + const listMembers = await fetchListMembers(LIST_URI, fetch); 28 + 29 + return { 30 + members: listMembers.members 31 + }; 32 + }; 33 + ``` 34 + 35 + ### Component Usage 36 + 37 + ```svelte 38 + <script lang="ts"> 39 + import { ProjectGrid } from '$lib/components'; 40 + import type { PageData } from './$types'; 41 + 42 + let { data }: { data: PageData } = $props(); 43 + </script> 44 + 45 + <!-- Members use the same component as Projects for consistency --> 46 + <ProjectGrid title="Members" projects={data.members} /> 47 + ``` 48 + 49 + ## Features 50 + 51 + ### Smart Agent Resolution 52 + 53 + - Attempts to resolve the list owner's PDS via Slingshot 54 + - Falls back to Bluesky public API if PDS is unavailable 55 + - Caches resolved agents for performance 56 + 57 + ### Profile Fetching 58 + 59 + - Fetches profiles in batches of 25 to avoid rate limits 60 + - Gracefully handles failed profile fetches 61 + - Continues fetching even if individual profiles fail 62 + 63 + ### Caching 64 + 65 + - 5-minute default TTL for cached data 66 + - Reduces API calls for frequently accessed lists 67 + - Can be cleared or customized as needed 68 + 69 + ### Error Handling 70 + 71 + - Comprehensive error logging 72 + - Graceful degradation on failures 73 + - Returns empty arrays instead of throwing errors 74 + 75 + ## List URI Format 76 + 77 + The list URI follows the AT Protocol format: 78 + 79 + ```plaintext 80 + at://[DID]/app.bsky.graph.list/[RKEY] 81 + ``` 82 + 83 + Example: 84 + 85 + ```plaintext 86 + at://did:plc:lwckcyzhyrufq4ytg2abji7d/app.bsky.graph.list/3mas22fg3ud2y 87 + ``` 88 + 89 + ## Visual Design 90 + 91 + The `ProjectCard` component has been extended to support optional member-specific fields: 92 + 93 + - `avatar` - Profile picture URL 94 + - `handle` - Bluesky handle 95 + 96 + When these fields are present, the card displays: 97 + 98 + - Avatar 99 + - Display name as title 100 + - Handle below the title 101 + - Link to Bluesky profile 102 + 103 + When these fields are absent (for Projects/Contributors), the card displays: 104 + 105 + - Simple title with icon 106 + - Standard hover effects 107 + 108 + This unified component approach ensures: 109 + 110 + - **Visual consistency** across all sections 111 + - **DRY principle** - no duplicate card components 112 + - **Flexible design** - gracefully adapts to available data 113 + 114 + Member data is transformed into the `Project` format: 115 + 116 + ```typescript 117 + { 118 + title: member.displayName || member.handle, 119 + href: `https://witchsky.app/profile/${member.handle}`, 120 + avatar: member.avatar, // Optional 121 + handle: member.handle // Optional 122 + } 123 + ``` 124 + 125 + ## Dependencies 126 + 127 + - `@atproto/api` - Official AT Protocol client library 128 + 129 + ## Installation 130 + 131 + ```bash 132 + npm install @atproto/api 133 + # or 134 + pnpm install @atproto/api 135 + ``` 136 + 137 + ## Configuration 138 + 139 + Update the list URI in `src/routes/+page.server.ts`: 140 + 141 + ```typescript 142 + const JOLLYWHOPPERS_LIST_URI = 'at://[YOUR_DID]/app.bsky.graph.list/[YOUR_RKEY]'; 143 + ``` 144 + 145 + ## Performance Considerations 146 + 147 + 1. **Server-Side Rendering** - Data is fetched server-side for better performance and SEO 148 + 2. **Caching** - Reduces redundant API calls 149 + 3. **Batch Fetching** - Profiles are fetched in batches to avoid rate limits 150 + 4. **Parallel Requests** - Uses `Promise.all()` for concurrent fetching
+163
src/lib/services/atproto/agents.ts
··· 1 + import { AtpAgent } from '@atproto/api'; 2 + import type { ResolvedIdentity } from './types'; 3 + 4 + /** 5 + * Creates an AtpAgent with optional fetch function injection 6 + */ 7 + export function createAgent(service: string, fetchFn?: typeof fetch): AtpAgent { 8 + // If we have an injected fetch, wrap it to ensure we handle headers correctly 9 + const wrappedFetch = fetchFn 10 + ? async (url: URL | RequestInfo, init?: RequestInit) => { 11 + // Convert URL to string if needed 12 + const urlStr = url instanceof URL ? url.toString() : url; 13 + 14 + // Make the request with the injected fetch 15 + const response = await fetchFn(urlStr, init); 16 + 17 + // Create a new response with the same body but add content-type if missing 18 + const headers = new Headers(response.headers); 19 + if (!headers.has('content-type')) { 20 + headers.set('content-type', 'application/json'); 21 + } 22 + 23 + return new Response(response.body, { 24 + status: response.status, 25 + statusText: response.statusText, 26 + headers 27 + }); 28 + } 29 + : undefined; 30 + 31 + return new AtpAgent({ 32 + service, 33 + ...(wrappedFetch && { fetch: wrappedFetch }) 34 + }); 35 + } 36 + 37 + // Default Bluesky public API agent 38 + export const defaultAgent = createAgent('https://public.api.bsky.app'); 39 + 40 + // Cached agents 41 + let resolvedAgent: AtpAgent | null = null; 42 + let pdsAgent: AtpAgent | null = null; 43 + 44 + /** 45 + * Resolves a DID to find its PDS endpoint using Slingshot. 46 + */ 47 + export async function resolveIdentity( 48 + did: string, 49 + fetchFn?: typeof fetch 50 + ): Promise<ResolvedIdentity> { 51 + console.info(`[Identity] Resolving DID: ${did}`); 52 + 53 + // Prefer an injected fetch (from SvelteKit load), fall back to global fetch 54 + const _fetch = fetchFn ?? globalThis.fetch; 55 + 56 + const response = await _fetch( 57 + `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent( 58 + did 59 + )}` 60 + ); 61 + 62 + if (!response.ok) { 63 + console.error(`[Identity] Resolution failed: ${response.status} ${response.statusText}`); 64 + throw new Error( 65 + `Failed to resolve identifier via Slingshot: ${response.status} ${response.statusText}` 66 + ); 67 + } 68 + 69 + const rawText = await response.text(); 70 + console.debug(`[Identity] Raw response:`, rawText); 71 + let data: any; 72 + try { 73 + data = JSON.parse(rawText); 74 + } catch (err) { 75 + console.error('[Identity] Failed to parse identity resolver response as JSON', err); 76 + throw err; 77 + } 78 + 79 + if (!data.did || !data.pds) { 80 + throw new Error('Invalid response from identity resolver'); 81 + } 82 + 83 + return data; 84 + } 85 + 86 + /** 87 + * Gets or creates an agent for the public Bluesky API with PDS fallback 88 + */ 89 + export async function getPublicAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> { 90 + console.info(`[Agent] Getting public agent for DID: ${did}`); 91 + if (resolvedAgent) { 92 + console.debug('[Agent] Using cached agent'); 93 + return resolvedAgent; 94 + } 95 + 96 + try { 97 + // Try Slingshot for PDS resolution 98 + console.info('[Agent] Attempting Slingshot resolution'); 99 + const resolved = await resolveIdentity(did, fetchFn); 100 + console.info(`[Agent] Resolved PDS endpoint: ${resolved.pds}`); 101 + resolvedAgent = createAgent(resolved.pds, fetchFn); 102 + return resolvedAgent; 103 + } catch (err) { 104 + console.error('[Agent] Slingshot failed, falling back to Bluesky:', err); 105 + resolvedAgent = defaultAgent; 106 + return resolvedAgent; 107 + } 108 + } 109 + 110 + /** 111 + * Gets or creates a PDS-specific agent 112 + */ 113 + export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> { 114 + if (pdsAgent) return pdsAgent; 115 + 116 + try { 117 + const resolved = await resolveIdentity(did, fetchFn); 118 + pdsAgent = createAgent(resolved.pds, fetchFn); 119 + return pdsAgent; 120 + } catch (err) { 121 + console.error('Failed to resolve PDS for DID:', err); 122 + throw err; 123 + } 124 + } 125 + 126 + /** 127 + * Executes a function with automatic fallback from Bluesky public API to user's PDS 128 + */ 129 + export async function withFallback<T>( 130 + did: string, 131 + operation: (agent: AtpAgent) => Promise<T>, 132 + usePDSFirst = false, 133 + fetchFn?: typeof fetch 134 + ): Promise<T> { 135 + const defaultAgentFn = () => 136 + fetchFn ? createAgent('https://public.api.bsky.app', fetchFn) : Promise.resolve(defaultAgent); 137 + 138 + const agents = usePDSFirst 139 + ? [() => getPDSAgent(did, fetchFn), defaultAgentFn] 140 + : [defaultAgentFn, () => getPDSAgent(did, fetchFn)]; 141 + 142 + let lastError: any; 143 + 144 + for (const getAgent of agents) { 145 + try { 146 + const agent = await getAgent(); 147 + return await operation(agent); 148 + } catch (error) { 149 + console.warn('Operation failed, trying next agent:', error); 150 + lastError = error; 151 + } 152 + } 153 + 154 + throw lastError; 155 + } 156 + 157 + /** 158 + * Resets cached agents (useful for testing or when identity changes) 159 + */ 160 + export function resetAgents(): void { 161 + resolvedAgent = null; 162 + pdsAgent = null; 163 + }
+57
src/lib/services/atproto/cache.ts
··· 1 + import type { CacheEntry } from './types'; 2 + 3 + /** 4 + * Simple in-memory cache with TTL support 5 + */ 6 + export class ATProtoCache { 7 + private cache = new Map<string, CacheEntry<any>>(); 8 + private defaultTTL: number; 9 + 10 + constructor(defaultTTL: number = 5 * 60 * 1000) { 11 + // 5 minutes default 12 + this.defaultTTL = defaultTTL; 13 + } 14 + 15 + /** 16 + * Gets a cached value if it exists and hasn't expired 17 + */ 18 + get<T>(key: string): T | null { 19 + const entry = this.cache.get(key); 20 + if (!entry) return null; 21 + 22 + const now = Date.now(); 23 + if (now - entry.timestamp > this.defaultTTL) { 24 + this.cache.delete(key); 25 + return null; 26 + } 27 + 28 + return entry.data as T; 29 + } 30 + 31 + /** 32 + * Sets a value in the cache 33 + */ 34 + set<T>(key: string, data: T): void { 35 + this.cache.set(key, { 36 + data, 37 + timestamp: Date.now() 38 + }); 39 + } 40 + 41 + /** 42 + * Clears the entire cache 43 + */ 44 + clear(): void { 45 + this.cache.clear(); 46 + } 47 + 48 + /** 49 + * Removes a specific key from the cache 50 + */ 51 + delete(key: string): void { 52 + this.cache.delete(key); 53 + } 54 + } 55 + 56 + // Export a singleton instance 57 + export const cache = new ATProtoCache();
+25
src/lib/services/atproto/index.ts
··· 1 + /** 2 + * Unified AT Protocol service exports for Jollywhoppers 3 + * 4 + * This module provides a clean API for interacting with AT Protocol services, 5 + * specifically for fetching Bluesky list members and their profile data. 6 + */ 7 + 8 + // Export all types 9 + export type { 10 + ProfileData, 11 + ResolvedIdentity, 12 + CacheEntry, 13 + ListItem, 14 + ListMember, 15 + ListMembersData 16 + } from './types'; 17 + 18 + // Export list functions 19 + export { fetchListMembers } from './list'; 20 + 21 + // Export utility functions 22 + export { resolveIdentity, withFallback, resetAgents } from './agents'; 23 + 24 + // Export cache for advanced use cases 25 + export { cache, ATProtoCache } from './cache';
+188
src/lib/services/atproto/list.ts
··· 1 + import { cache } from './cache'; 2 + import { withFallback, defaultAgent } from './agents'; 3 + import type { ListMembersData, ListMember, ListItem, ProfileData } from './types'; 4 + 5 + /** 6 + * Parses an AT URI to extract the DID and record key 7 + */ 8 + function parseAtUri(uri: string): { did: string; collection: string; rkey: string } | null { 9 + const match = uri.match(/^at:\/\/([^\/]+)\/([^\/]+)\/([^\/]+)$/); 10 + if (!match) return null; 11 + 12 + return { 13 + did: match[1], 14 + collection: match[2], 15 + rkey: match[3] 16 + }; 17 + } 18 + 19 + /** 20 + * Fetches all list items (members) from a Bluesky list with pagination 21 + */ 22 + async function fetchListItems( 23 + listUri: string, 24 + fetchFn?: typeof fetch 25 + ): Promise<ListItem[]> { 26 + console.info(`[List] Fetching list items from: ${listUri}`); 27 + 28 + const parsed = parseAtUri(listUri); 29 + if (!parsed) { 30 + throw new Error(`Invalid list URI: ${listUri}`); 31 + } 32 + 33 + const allItems: ListItem[] = []; 34 + let cursor: string | undefined; 35 + 36 + const agent = fetchFn ? defaultAgent : defaultAgent; 37 + 38 + try { 39 + do { 40 + const response = await withFallback( 41 + parsed.did, 42 + async (agent) => { 43 + const res = await agent.com.atproto.repo.listRecords({ 44 + repo: parsed.did, 45 + collection: 'app.bsky.graph.listitem', 46 + limit: 100, 47 + cursor 48 + }); 49 + return res.data; 50 + }, 51 + true, 52 + fetchFn 53 + ); 54 + 55 + // Filter items that belong to this specific list 56 + const listItems = response.records 57 + .filter((record: any) => record.value.list === listUri) 58 + .map((record: any) => ({ 59 + uri: record.uri, 60 + subject: record.value.subject, 61 + createdAt: record.value.createdAt 62 + })); 63 + 64 + allItems.push(...listItems); 65 + cursor = response.cursor; 66 + } while (cursor); 67 + 68 + console.info(`[List] Found ${allItems.length} list items`); 69 + return allItems; 70 + } catch (error) { 71 + console.error('[List] Failed to fetch list items:', error); 72 + throw error; 73 + } 74 + } 75 + 76 + /** 77 + * Fetches profile data for a list of DIDs 78 + */ 79 + async function fetchProfiles( 80 + dids: string[], 81 + fetchFn?: typeof fetch 82 + ): Promise<Map<string, ProfileData>> { 83 + console.info(`[List] Fetching profiles for ${dids.length} DIDs`); 84 + 85 + const profiles = new Map<string, ProfileData>(); 86 + const agent = fetchFn ? defaultAgent : defaultAgent; 87 + 88 + // Fetch profiles in batches to avoid overwhelming the API 89 + const batchSize = 25; 90 + for (let i = 0; i < dids.length; i += batchSize) { 91 + const batch = dids.slice(i, i + batchSize); 92 + 93 + await Promise.all( 94 + batch.map(async (did) => { 95 + try { 96 + const profile = await withFallback( 97 + did, 98 + async (agent) => { 99 + const response = await agent.getProfile({ actor: did }); 100 + return response.data; 101 + }, 102 + false, 103 + fetchFn 104 + ); 105 + 106 + profiles.set(did, { 107 + did: profile.did, 108 + handle: profile.handle, 109 + displayName: profile.displayName, 110 + description: profile.description, 111 + avatar: profile.avatar, 112 + banner: profile.banner, 113 + followersCount: profile.followersCount, 114 + followsCount: profile.followsCount, 115 + postsCount: profile.postsCount 116 + }); 117 + } catch (error) { 118 + console.warn(`[List] Failed to fetch profile for ${did}:`, error); 119 + // Continue with other profiles even if one fails 120 + } 121 + }) 122 + ); 123 + } 124 + 125 + console.info(`[List] Successfully fetched ${profiles.size} profiles`); 126 + return profiles; 127 + } 128 + 129 + /** 130 + * Fetches list members with their profile data 131 + */ 132 + export async function fetchListMembers( 133 + listUri: string, 134 + fetchFn?: typeof fetch 135 + ): Promise<ListMembersData> { 136 + console.info(`[List] Fetching list members for: ${listUri}`); 137 + 138 + const cacheKey = `list-members:${listUri}`; 139 + const cached = cache.get<ListMembersData>(cacheKey); 140 + if (cached) { 141 + console.debug('[List] Returning cached list members'); 142 + return cached; 143 + } 144 + 145 + try { 146 + // Fetch all list items 147 + const listItems = await fetchListItems(listUri, fetchFn); 148 + 149 + // Extract unique DIDs 150 + const dids = [...new Set(listItems.map((item) => item.subject))]; 151 + 152 + // Fetch profile data for all DIDs 153 + const profiles = await fetchProfiles(dids, fetchFn); 154 + 155 + // Combine list items with profile data 156 + const members: ListMember[] = listItems 157 + .map((item) => { 158 + const profile = profiles.get(item.subject); 159 + if (!profile) return null; 160 + 161 + return { 162 + did: profile.did, 163 + handle: profile.handle, 164 + displayName: profile.displayName, 165 + description: profile.description, 166 + avatar: profile.avatar, 167 + addedAt: item.createdAt, 168 + uri: item.uri 169 + }; 170 + }) 171 + .filter((member): member is ListMember => member !== null); 172 + 173 + // Sort by when they were added (newest first) 174 + members.sort((a, b) => new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime()); 175 + 176 + const data: ListMembersData = { 177 + members, 178 + listUri 179 + }; 180 + 181 + console.info(`[List] Successfully fetched ${members.length} list members`); 182 + cache.set(cacheKey, data); 183 + return data; 184 + } catch (error) { 185 + console.error('[List] Failed to fetch list members:', error); 186 + throw error; 187 + } 188 + }
+64
src/lib/services/atproto/types.ts
··· 1 + /** 2 + * Type definitions for AT Protocol services 3 + */ 4 + 5 + /** 6 + * Profile data from Bluesky 7 + */ 8 + export interface ProfileData { 9 + did: string; 10 + handle: string; 11 + displayName?: string; 12 + description?: string; 13 + avatar?: string; 14 + banner?: string; 15 + followersCount?: number; 16 + followsCount?: number; 17 + postsCount?: number; 18 + } 19 + 20 + /** 21 + * Resolved identity from Slingshot 22 + */ 23 + export interface ResolvedIdentity { 24 + did: string; 25 + pds: string; 26 + } 27 + 28 + /** 29 + * Cache entry with timestamp 30 + */ 31 + export interface CacheEntry<T> { 32 + data: T; 33 + timestamp: number; 34 + } 35 + 36 + /** 37 + * Bluesky list item (member) 38 + */ 39 + export interface ListItem { 40 + uri: string; 41 + subject: string; // DID of the member 42 + createdAt: string; 43 + } 44 + 45 + /** 46 + * List member with profile data 47 + */ 48 + export interface ListMember { 49 + did: string; 50 + handle: string; 51 + displayName?: string; 52 + description?: string; 53 + avatar?: string; 54 + addedAt: string; // When they were added to the list 55 + uri: string; // AT URI of the list item 56 + } 57 + 58 + /** 59 + * List members data 60 + */ 61 + export interface ListMembersData { 62 + members: ListMember[]; 63 + listUri: string; 64 + }
+34
src/routes/+page.server.ts
··· 1 + import { fetchListMembers } from '$lib/services/atproto'; 2 + import type { PageServerLoad } from './$types'; 3 + import type { Project } from '$lib/components'; 4 + 5 + // The list URI from the Jollywhoppers list 6 + const JOLLYWHOPPERS_LIST_URI = 'at://did:plc:lwckcyzhyrufq4ytg2abji7d/app.bsky.graph.list/3mas22fg3ud2y'; 7 + 8 + export const load: PageServerLoad = async ({ fetch }) => { 9 + try { 10 + const listMembers = await fetchListMembers(JOLLYWHOPPERS_LIST_URI, fetch); 11 + 12 + // Transform members into Project format for consistent display 13 + const members: Project[] = listMembers.members.map((member) => ({ 14 + title: member.displayName || member.handle, 15 + href: `https://witchsky.app/profile/${member.handle}`, 16 + avatar: member.avatar, 17 + handle: member.handle 18 + })); 19 + 20 + // Sort alphabetically by title 21 + members.sort((a, b) => a.title.localeCompare(b.title)); 22 + 23 + return { 24 + members 25 + }; 26 + } catch (error) { 27 + console.error('Failed to fetch list members:', error); 28 + 29 + // Return empty members array if fetch fails 30 + return { 31 + members: [] 32 + }; 33 + } 34 + };
+5 -2
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { Header, Hero, ProjectGrid, About } from '$lib/components'; 3 - import { SITE_CONFIG, PROJECTS, CONTRIBUTORS, MEMBERS, ABOUT_ITEMS } from '$lib/constants'; 3 + import { SITE_CONFIG, PROJECTS, CONTRIBUTORS, ABOUT_ITEMS } from '$lib/constants'; 4 + import type { PageData } from './$types'; 5 + 6 + let { data }: { data: PageData } = $props(); 4 7 </script> 5 8 6 9 <div class="page"> ··· 12 15 <div class="content-wrapper"> 13 16 <ProjectGrid title="Projects" projects={PROJECTS} /> 14 17 <ProjectGrid title="Contributors" projects={CONTRIBUTORS} /> 15 - <ProjectGrid title="Members" projects={MEMBERS} /> 18 + <ProjectGrid title="Members" projects={data.members} /> 16 19 {#if ABOUT_ITEMS.length > 0} 17 20 <About title="About" items={ABOUT_ITEMS} /> 18 21 {/if}