+2
.vscode/settings.json
+2
.vscode/settings.json
+1
package.json
+1
package.json
+112
pnpm-lock.yaml
+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
+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
+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
+3
src/lib/components/index.ts
+2
-5
src/lib/constants.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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}