tangled
alpha
login
or
join now
flo-bit.dev
/
blento
21
fork
atom
your personal website on atproto - mirror
blento.app
21
fork
atom
overview
issues
pulls
pipelines
more event fixes+improvements
Florian
3 weeks ago
d2ed12fc
ff35b135
+368
-176
12 changed files
expand all
collapse all
unified
split
src
lib
cards
social
EventCard
EventCard.svelte
routes
(auth)
oauth
callback
+page.svelte
[[actor=actor]]
blog
+page.svelte
[rkey]
+page.svelte
new
+page.svelte
events
+page.svelte
[rkey]
+page.svelte
EventAttendees.svelte
EventRsvp.svelte
api.remote.ts
edit
+page.svelte
new
+page.svelte
+1
-3
src/lib/cards/social/EventCard/EventCard.svelte
···
9
9
import { browser } from '$app/environment';
10
10
import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte';
11
11
import type { Did } from '@atcute/lexicons';
12
12
-
import { page } from '$app/state';
13
12
14
13
let { item }: ContentComponentProps = $props();
15
14
···
93
92
94
93
let eventUrl = $derived(() => {
95
94
if (parsedUri) {
96
96
-
const actorPrefix = page.params.actor ? `/${page.params.actor}` : '';
97
97
-
return `${actorPrefix}/events/${parsedUri.rkey}`;
95
95
+
return `https://blento.app/${parsedUri.repo}/events/${parsedUri.rkey}`;
98
96
}
99
97
return '#';
100
98
});
+6
-1
src/routes/(auth)/oauth/callback/+page.svelte
···
17
17
localStorage.removeItem('login-redirect');
18
18
19
19
const editPath = '/' + getHandleOrDid(user.profile) + '/edit';
20
20
-
if (!redirect || redirect === '/' || redirect === 'https://blento.app' || redirect === 'https://blento.app/') {
20
20
+
if (
21
21
+
!redirect ||
22
22
+
redirect === '/' ||
23
23
+
redirect === 'https://blento.app' ||
24
24
+
redirect === 'https://blento.app/'
25
25
+
) {
21
26
redirect = editPath;
22
27
}
23
28
+1
-1
src/routes/[[actor=actor]]/blog/+page.svelte
···
46
46
</svelte:head>
47
47
48
48
<div class="min-h-screen px-6 py-12">
49
49
-
<div class="mx-auto max-w-2xl">
49
49
+
<div class="mx-auto max-w-3xl">
50
50
<!-- Header -->
51
51
<div class="mb-8">
52
52
<div class="flex items-center justify-between gap-4">
+1
-1
src/routes/[[actor=actor]]/blog/[rkey]/+page.svelte
···
110
110
</svelte:head>
111
111
112
112
<div class="min-h-screen px-6 py-12">
113
113
-
<div class="mx-auto max-w-2xl">
113
113
+
<div class="mx-auto max-w-3xl">
114
114
<!-- Cover image -->
115
115
{#if coverUrl}
116
116
<img src={coverUrl} alt={title} class="mb-8 aspect-video w-full rounded-2xl object-cover" />
+1
-1
src/routes/[[actor=actor]]/blog/new/+page.svelte
···
373
373
</svelte:head>
374
374
375
375
<div class="min-h-screen px-6 py-12">
376
376
-
<div class="mx-auto max-w-2xl">
376
376
+
<div class="mx-auto max-w-3xl">
377
377
{#if user.isInitializing || !draftRestored}
378
378
<div class="flex items-center gap-3">
379
379
<div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div>
+1
-1
src/routes/[[actor=actor]]/events/+page.svelte
···
89
89
</svelte:head>
90
90
91
91
<div class="min-h-screen px-6 py-12 sm:py-12">
92
92
-
<div class="mx-auto max-w-2xl">
92
92
+
<div class="mx-auto max-w-3xl">
93
93
<!-- Header -->
94
94
<div class="mb-8 flex items-start justify-between">
95
95
<div>
+32
-18
src/routes/[[actor=actor]]/events/[rkey]/+page.svelte
···
161
161
let ogImageUrl = $derived(`${page.url.origin}${page.url.pathname}/og.png`);
162
162
163
163
let isOwner = $derived(user.isLoggedIn && user.did === did);
164
164
+
165
165
+
let attendeesRef: EventAttendees | undefined = $state();
166
166
+
167
167
+
function handleRsvp(status: 'going' | 'interested') {
168
168
+
if (!user.did) return;
169
169
+
attendeesRef?.addAttendee({
170
170
+
did: user.did,
171
171
+
status,
172
172
+
avatar: user.profile?.avatar,
173
173
+
name: user.profile?.displayName || user.profile?.handle || user.did
174
174
+
});
175
175
+
}
176
176
+
177
177
+
function handleRsvpCancel() {
178
178
+
if (!user.did) return;
179
179
+
attendeesRef?.removeAttendee(user.did);
180
180
+
}
164
181
</script>
165
182
166
183
<svelte:head>
···
176
193
</svelte:head>
177
194
178
195
<div class="min-h-screen px-6 py-12 sm:py-12">
179
179
-
<div class="mx-auto max-w-2xl">
196
196
+
<div class="mx-auto max-w-3xl">
180
197
<!-- Banner image (full width, only when no thumbnail) -->
181
198
{#if isBannerOnly && displayImage}
182
199
<img
···
218
235
<!-- Right column: event details -->
219
236
<div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1">
220
237
<div class="mb-2 flex items-start justify-between gap-4">
221
221
-
<h1
222
222
-
class="text-base-900 dark:text-base-50 text-4xl leading-tight font-bold sm:text-5xl"
223
223
-
>
238
238
+
<h1 class="text-base-900 dark:text-base-50 text-4xl leading-tight font-bold sm:text-5xl">
224
239
{eventData.name}
225
240
</h1>
226
241
{#if isOwner}
227
227
-
<Button href="./edit" variant="ghost" size="sm" class="shrink-0">Edit</Button>
242
242
+
<Button href="./edit" size="sm" class="shrink-0">Edit</Button>
228
243
{/if}
229
244
</div>
230
245
···
295
310
</div>
296
311
{/if}
297
312
298
298
-
<EventRsvp {eventUri} eventCid={data.eventCid} />
299
299
-
300
300
-
{#if isOwner}
301
301
-
<div class="mt-4">
302
302
-
<Button href="./edit" variant="secondary" size="sm">Edit event</Button>
303
303
-
</div>
304
304
-
{/if}
313
313
+
<EventRsvp
314
314
+
{eventUri}
315
315
+
eventCid={data.eventCid}
316
316
+
onrsvp={handleRsvp}
317
317
+
oncancel={handleRsvpCancel}
318
318
+
/>
305
319
306
320
<!-- About Event -->
307
321
{#if descriptionHtml}
···
380
394
{/if}
381
395
382
396
<!-- Attendees -->
383
383
-
<!-- <div class="order-5 md:order-0 md:col-start-1">
384
384
-
<EventAttendees {eventUri} {did} />
385
385
-
</div> -->
397
397
+
<div class="order-5 md:order-0 md:col-start-1">
398
398
+
<EventAttendees bind:this={attendeesRef} {eventUri} />
399
399
+
</div>
386
400
387
387
-
<!-- View on Smoke Signal link -->
388
388
-
<a
401
401
+
<!-- View on Smoke Signal link, currently disabled as some events dont work on smokesignal -->
402
402
+
<!-- <a
389
403
href={smokesignalUrl}
390
404
target="_blank"
391
405
rel="noopener noreferrer"
···
406
420
d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25"
407
421
/>
408
422
</svg>
409
409
-
</a>
423
423
+
</a> -->
410
424
</div>
411
425
</div>
412
426
</div>
+174
-107
src/routes/[[actor=actor]]/events/[rkey]/EventAttendees.svelte
···
1
1
<script lang="ts">
2
2
import { Avatar as FoxAvatar } from '@foxui/core';
3
3
import { onMount } from 'svelte';
4
4
-
import { fetchEventBacklinks } from './api.remote';
4
4
+
import { scale } from 'svelte/transition';
5
5
+
import { flip } from 'svelte/animate';
6
6
+
import { fetchEventAttendees, type AttendeeInfo } from './api.remote';
7
7
+
import Modal from '$lib/components/modal/Modal.svelte';
5
8
6
6
-
let { eventUri, did }: { eventUri: string; did: string } = $props();
9
9
+
let { eventUri }: { eventUri: string } = $props();
7
10
8
11
let goingCount = $state(0);
9
12
let interestedCount = $state(0);
10
10
-
let goingAvatars: Array<{ did: string; avatar?: string; name: string }> = $state([]);
11
11
-
let interestedAvatars: Array<{ did: string; avatar?: string; name: string }> = $state([]);
13
13
+
let goingAttendees: AttendeeInfo[] = $state([]);
14
14
+
let interestedAttendees: AttendeeInfo[] = $state([]);
12
15
let loading = $state(true);
13
16
14
14
-
onMount(() => {
15
15
-
fetchEventBacklinks(eventUri)
16
16
-
.then((records) => {
17
17
-
console.log(records);
18
18
-
if (!records) return;
19
19
-
let going = 0;
20
20
-
let interested = 0;
21
21
-
const goingAvatarList: Array<{ did: string; avatar?: string; name: string }> = [];
22
22
-
const interestedAvatarList: Array<{ did: string; avatar?: string; name: string }> = [];
17
17
+
let modalOpen = $state(false);
18
18
+
let modalGroup: 'going' | 'interested' = $state('going');
23
19
24
24
-
for (const raw of records) {
25
25
-
const record = raw as {
26
26
-
did: string;
27
27
-
value?: { status?: string };
28
28
-
author?: { avatar?: string; displayName?: string; handle?: string };
29
29
-
};
30
30
-
const status = record.value?.status || '';
31
31
-
const author = record.author;
32
32
-
const avatarInfo = {
33
33
-
did: record.did,
34
34
-
avatar: author?.avatar,
35
35
-
name: author?.displayName || author?.handle || record.did
36
36
-
};
20
20
+
const MAX_AVATARS = 18;
37
21
38
38
-
if (status.includes('#going')) {
39
39
-
going++;
40
40
-
goingAvatarList.push(avatarInfo);
41
41
-
} else if (status.includes('#interested')) {
42
42
-
interested++;
43
43
-
interestedAvatarList.push(avatarInfo);
44
44
-
}
45
45
-
}
46
46
-
47
47
-
goingCount = going;
48
48
-
interestedCount = interested;
49
49
-
goingAvatars = goingAvatarList;
50
50
-
interestedAvatars = interestedAvatarList;
51
51
-
})
52
52
-
.catch((err) => {
53
53
-
console.error('Failed to fetch event attendees:', err);
54
54
-
})
55
55
-
.finally(() => {
56
56
-
loading = false;
57
57
-
});
22
22
+
onMount(async () => {
23
23
+
try {
24
24
+
const result = await fetchEventAttendees(eventUri);
25
25
+
if (!result) return;
26
26
+
goingCount = result.goingCount;
27
27
+
interestedCount = result.interestedCount;
28
28
+
goingAttendees = result.going;
29
29
+
interestedAttendees = result.interested;
30
30
+
} catch (err) {
31
31
+
console.error('Failed to fetch event attendees:', err);
32
32
+
} finally {
33
33
+
loading = false;
34
34
+
}
58
35
});
59
36
60
37
let totalCount = $derived(goingCount + interestedCount);
61
61
-
let allAvatars = $derived([...goingAvatars, ...interestedAvatars]);
62
62
-
let displayAvatars = $derived(allAvatars.slice(0, 8));
63
63
-
let overflowCount = $derived(allAvatars.length - displayAvatars.length);
38
38
+
39
39
+
let goingDisplay = $derived(goingAttendees.slice(0, MAX_AVATARS));
40
40
+
let goingOverflow = $derived(goingCount - goingDisplay.length);
41
41
+
42
42
+
let interestedDisplay = $derived(interestedAttendees.slice(0, MAX_AVATARS));
43
43
+
let interestedOverflow = $derived(interestedCount - interestedDisplay.length);
44
44
+
45
45
+
let modalAttendees = $derived(modalGroup === 'going' ? goingAttendees : interestedAttendees);
46
46
+
let modalTitle = $derived(modalGroup === 'going' ? 'Going' : 'Interested');
47
47
+
48
48
+
function openModal(group: 'going' | 'interested') {
49
49
+
modalGroup = group;
50
50
+
modalOpen = true;
51
51
+
}
52
52
+
53
53
+
export function addAttendee(attendee: AttendeeInfo) {
54
54
+
// Remove from both lists first (in case of status change)
55
55
+
goingAttendees = goingAttendees.filter((a) => a.did !== attendee.did);
56
56
+
interestedAttendees = interestedAttendees.filter((a) => a.did !== attendee.did);
57
57
+
58
58
+
if (attendee.status === 'going') {
59
59
+
goingAttendees = [attendee, ...goingAttendees];
60
60
+
goingCount = goingAttendees.length;
61
61
+
} else if (attendee.status === 'interested') {
62
62
+
interestedAttendees = [attendee, ...interestedAttendees];
63
63
+
interestedCount = interestedAttendees.length;
64
64
+
}
65
65
+
}
66
66
+
67
67
+
export function removeAttendee(did: string) {
68
68
+
const wasGoing = goingAttendees.some((a) => a.did === did);
69
69
+
const wasInterested = interestedAttendees.some((a) => a.did === did);
70
70
+
goingAttendees = goingAttendees.filter((a) => a.did !== did);
71
71
+
interestedAttendees = interestedAttendees.filter((a) => a.did !== did);
72
72
+
if (wasGoing) goingCount = goingAttendees.length;
73
73
+
if (wasInterested) interestedCount = interestedAttendees.length;
74
74
+
}
64
75
</script>
65
76
66
77
{#if loading}
···
68
79
<div class="bg-base-300 dark:bg-base-700 h-3 w-24 animate-pulse rounded"></div>
69
80
</div>
70
81
{:else if totalCount > 0}
71
71
-
<div>
72
72
-
<p class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase">
73
73
-
Attendees
74
74
-
</p>
75
75
-
76
76
-
<!-- Avatar stack -->
77
77
-
{#if displayAvatars.length > 0}
78
78
-
<div class="mb-3 flex items-center">
79
79
-
<div class="flex -space-x-2">
80
80
-
{#each displayAvatars as person (person.did)}
81
81
-
<FoxAvatar
82
82
-
src={person.avatar}
83
83
-
alt={person.name}
84
84
-
class="ring-base-50 dark:ring-base-950 size-7 ring-2"
85
85
-
/>
86
86
-
{/each}
82
82
+
<div class="mb-2">
83
83
+
{#if goingCount > 0}
84
84
+
<button
85
85
+
type="button"
86
86
+
class="hover:bg-base-100 dark:hover:bg-base-800/50 -mx-2 cursor-pointer rounded-xl px-2 py-2 text-left transition-colors"
87
87
+
onclick={() => openModal('going')}
88
88
+
>
89
89
+
<p class="text-base-900 dark:text-base-50 mb-2 text-sm">
90
90
+
<span class="font-bold">{goingCount}</span>
91
91
+
<span
92
92
+
class="text-base-500 dark:text-base-400 text-xs font-semibold tracking-wider uppercase"
93
93
+
>Going</span
94
94
+
>
95
95
+
</p>
96
96
+
<div class="flex items-center">
97
97
+
<div class="flex flex-wrap -space-y-2 -space-x-4 pr-4">
98
98
+
{#each goingDisplay as person (person.did)}
99
99
+
<div
100
100
+
animate:flip={{ duration: 300 }}
101
101
+
in:scale={{ duration: 300, start: 0.5 }}
102
102
+
out:scale={{ duration: 200, start: 0.5 }}
103
103
+
>
104
104
+
<FoxAvatar
105
105
+
src={person.avatar}
106
106
+
alt={person.name}
107
107
+
fallback={person.name}
108
108
+
class="border-base-100 dark:border-base-900 size-12 border-2"
109
109
+
/>
110
110
+
</div>
111
111
+
{/each}
112
112
+
{#if goingOverflow > 0}
113
113
+
<span
114
114
+
class="bg-base-200 dark:bg-base-800 text-base-950 dark:text-base-100 border-base-100 dark:border-base-900 z-10 inline-flex size-12 items-center justify-center rounded-full border-2 text-sm font-semibold"
115
115
+
>
116
116
+
+{goingOverflow}
117
117
+
</span>
118
118
+
{/if}
119
119
+
</div>
87
120
</div>
88
88
-
{#if overflowCount > 0}
89
89
-
<span class="text-base-500 dark:text-base-400 ml-2 text-xs">
90
90
-
+{overflowCount}
91
91
-
</span>
92
92
-
{/if}
93
93
-
</div>
121
121
+
</button>
94
122
{/if}
95
123
96
96
-
<!-- Counts -->
97
97
-
<div class="text-base-600 dark:text-base-400 flex items-center gap-3 text-sm">
98
98
-
{#if goingCount > 0}
99
99
-
<span class="flex items-center gap-1.5">
100
100
-
<svg
101
101
-
xmlns="http://www.w3.org/2000/svg"
102
102
-
viewBox="0 0 20 20"
103
103
-
fill="currentColor"
104
104
-
class="size-3.5 text-green-500"
124
124
+
{#if interestedCount > 0}
125
125
+
<button
126
126
+
type="button"
127
127
+
class="hover:bg-base-100 dark:hover:bg-base-800/50 -mx-2 mt-4 cursor-pointer rounded-xl px-2 py-2 text-left transition-colors"
128
128
+
onclick={() => openModal('interested')}
129
129
+
>
130
130
+
<p class="text-base-900 dark:text-base-50 mb-2 text-sm">
131
131
+
<span class="font-bold">{interestedCount}</span>
132
132
+
<span
133
133
+
class="text-base-500 dark:text-base-400 text-xs font-semibold tracking-wider uppercase"
134
134
+
>Interested</span
105
135
>
106
106
-
<path
107
107
-
fill-rule="evenodd"
108
108
-
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
109
109
-
clip-rule="evenodd"
110
110
-
/>
111
111
-
</svg>
112
112
-
{goingCount} going
113
113
-
</span>
114
114
-
{/if}
115
115
-
{#if interestedCount > 0}
116
116
-
<span class="flex items-center gap-1.5">
117
117
-
<svg
118
118
-
xmlns="http://www.w3.org/2000/svg"
119
119
-
viewBox="0 0 20 20"
120
120
-
fill="currentColor"
121
121
-
class="size-3.5 text-amber-500"
122
122
-
>
123
123
-
<path
124
124
-
fill-rule="evenodd"
125
125
-
d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401Z"
126
126
-
clip-rule="evenodd"
127
127
-
/>
128
128
-
</svg>
129
129
-
{interestedCount} interested
130
130
-
</span>
131
131
-
{/if}
132
132
-
</div>
136
136
+
</p>
137
137
+
<div class="flex items-center">
138
138
+
<div class="flex flex-wrap -space-y-2 -space-x-4 pr-4">
139
139
+
{#each interestedDisplay as person (person.did)}
140
140
+
<div
141
141
+
animate:flip={{ duration: 300 }}
142
142
+
in:scale={{ duration: 300, start: 0.5 }}
143
143
+
out:scale={{ duration: 200, start: 0.5 }}
144
144
+
>
145
145
+
<FoxAvatar
146
146
+
src={person.avatar}
147
147
+
alt={person.name}
148
148
+
fallback={person.name}
149
149
+
class="border-base-100 dark:border-base-900 size-12 border-2"
150
150
+
/>
151
151
+
</div>
152
152
+
{/each}
153
153
+
{#if interestedOverflow > 0}
154
154
+
<span
155
155
+
class="bg-base-200 dark:bg-base-800 text-base-950 dark:text-base-100 border-base-100 dark:border-base-900 z-10 inline-flex size-12 items-center justify-center rounded-full border-2 text-sm font-semibold"
156
156
+
>
157
157
+
+{interestedOverflow}
158
158
+
</span>
159
159
+
{/if}
160
160
+
</div>
161
161
+
</div>
162
162
+
</button>
163
163
+
{/if}
133
164
</div>
134
165
{/if}
166
166
+
167
167
+
<Modal bind:open={modalOpen} closeButton onOpenAutoFocus={(e) => e.preventDefault()}>
168
168
+
<p class="text-base-900 dark:text-base-50 text-lg font-semibold">
169
169
+
{modalTitle}
170
170
+
<span class="text-base-500 dark:text-base-400 text-sm font-normal">
171
171
+
({modalAttendees.length})
172
172
+
</span>
173
173
+
</p>
174
174
+
<div class="mt-3 max-h-80 space-y-1 overflow-y-auto p-2">
175
175
+
{#each modalAttendees as person (person.did)}
176
176
+
<a
177
177
+
href={person.url}
178
178
+
target={person.url?.startsWith('/') ? undefined : '_blank'}
179
179
+
rel={person.url?.startsWith('/') ? undefined : 'noopener noreferrer'}
180
180
+
class="hover:bg-base-100 dark:hover:bg-base-800 flex items-center gap-3 rounded-xl px-2 py-2 transition-colors"
181
181
+
>
182
182
+
<FoxAvatar
183
183
+
src={person.avatar}
184
184
+
alt={person.name}
185
185
+
fallback={person.name}
186
186
+
class="size-10 shrink-0"
187
187
+
/>
188
188
+
<div class="min-w-0">
189
189
+
<p class="text-base-900 dark:text-base-50 truncate text-sm font-medium">
190
190
+
{person.name}
191
191
+
</p>
192
192
+
{#if person.handle}
193
193
+
<p class="text-base-500 dark:text-base-400 truncate text-xs">
194
194
+
@{person.handle}
195
195
+
</p>
196
196
+
{/if}
197
197
+
</div>
198
198
+
</a>
199
199
+
{/each}
200
200
+
</div>
201
201
+
</Modal>
+13
-1
src/routes/[[actor=actor]]/events/[rkey]/EventRsvp.svelte
···
3
3
import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte';
4
4
import { Avatar, Button } from '@foxui/core';
5
5
6
6
-
let { eventUri, eventCid }: { eventUri: string; eventCid: string | null } = $props();
6
6
+
let {
7
7
+
eventUri,
8
8
+
eventCid,
9
9
+
onrsvp,
10
10
+
oncancel
11
11
+
}: {
12
12
+
eventUri: string;
13
13
+
eventCid: string | null;
14
14
+
onrsvp?: (status: 'going' | 'interested') => void;
15
15
+
oncancel?: () => void;
16
16
+
} = $props();
7
17
8
18
let rsvpStatus: 'going' | 'interested' | 'notgoing' | null = $state(null);
9
19
let rsvpRkey: string | null = $state(null);
···
90
100
rsvpStatus = status;
91
101
const parts = response.data.uri.split('/');
92
102
rsvpRkey = parts[parts.length - 1];
103
103
+
onrsvp?.(status);
93
104
}
94
105
} catch (e) {
95
106
console.error('Failed to submit RSVP:', e);
···
111
122
});
112
123
rsvpStatus = null;
113
124
rsvpRkey = null;
125
125
+
oncancel?.();
114
126
} catch (e) {
115
127
console.error('Failed to cancel RSVP:', e);
116
128
} finally {
+133
-37
src/routes/[[actor=actor]]/events/[rkey]/api.remote.ts
···
1
1
-
import { query } from '$app/server';
1
1
+
import { query, getRequestEvent } from '$app/server';
2
2
+
import { createCache, type CachedProfile } from '$lib/cache';
3
3
+
import { getBlentoOrBskyProfile, parseUri } from '$lib/atproto/methods';
4
4
+
import type { Did } from '@atcute/lexicons';
5
5
+
6
6
+
export type AttendeeInfo = {
7
7
+
did: string;
8
8
+
status: 'going' | 'interested';
9
9
+
avatar?: string;
10
10
+
name: string;
11
11
+
handle?: string;
12
12
+
url?: string;
13
13
+
};
14
14
+
15
15
+
export type EventAttendeesResult = {
16
16
+
going: AttendeeInfo[];
17
17
+
interested: AttendeeInfo[];
18
18
+
goingCount: number;
19
19
+
interestedCount: number;
20
20
+
};
21
21
+
22
22
+
export const fetchEventAttendees = query(
23
23
+
'unchecked',
24
24
+
async (eventUri: string): Promise<EventAttendeesResult> => {
25
25
+
// 1. Fetch backlinks (RSVPs)
26
26
+
const allRecords: Record<string, unknown> = {};
27
27
+
let cursor: string | undefined;
28
28
+
29
29
+
do {
30
30
+
const params: Record<string, unknown> = {
31
31
+
subject: eventUri,
32
32
+
source: 'community.lexicon.calendar.rsvp:subject.uri'
33
33
+
};
34
34
+
if (cursor) params.cursor = cursor;
35
35
+
36
36
+
const res = await fetch(
37
37
+
'https://slingshot.microcosm.blue/xrpc/com.bad-example.proxy.hydrateQueryResponse',
38
38
+
{
39
39
+
method: 'POST',
40
40
+
headers: { 'Content-Type': 'application/json' },
41
41
+
body: JSON.stringify({
42
42
+
atproto_proxy: 'did:web:constellation.microcosm.blue#constellation',
43
43
+
hydration_sources: [
44
44
+
{
45
45
+
path: 'records[]',
46
46
+
shape: 'at-uri-parts'
47
47
+
}
48
48
+
],
49
49
+
params,
50
50
+
xrpc: 'blue.microcosm.links.getBacklinks'
51
51
+
})
52
52
+
}
53
53
+
);
2
54
3
3
-
export const fetchEventBacklinks = query('unchecked', async (eventUri: string) => {
4
4
-
const allRecords: Record<string, unknown>[] = [];
5
5
-
let cursor: string | undefined;
55
55
+
if (!res.ok) break;
6
56
7
7
-
do {
8
8
-
const params: Record<string, unknown> = {
9
9
-
subject: eventUri,
10
10
-
source: 'community.lexicon.calendar.rsvp:subject.uri'
11
11
-
};
12
12
-
if (cursor) params.cursor = cursor;
57
57
+
const data = await res.json();
58
58
+
const output = data.output;
13
59
14
14
-
const res = await fetch(
15
15
-
'https://slingshot.microcosm.blue/xrpc/com.bad-example.proxy.hydrateQueryResponse',
16
16
-
{
17
17
-
method: 'POST',
18
18
-
headers: { 'Content-Type': 'application/json' },
19
19
-
body: JSON.stringify({
20
20
-
atproto_proxy: 'did:web:constellation.microcosm.blue#constellation',
21
21
-
hydration_sources: [
22
22
-
{
23
23
-
path: 'records[]',
24
24
-
shape: 'at-uri-parts'
25
25
-
}
26
26
-
],
27
27
-
params,
28
28
-
xrpc: 'blue.microcosm.links.getBacklinks'
29
29
-
})
60
60
+
for (const [key, value] of Object.entries(data.records ?? {})) {
61
61
+
allRecords[key] = value;
30
62
}
31
31
-
);
32
63
33
33
-
if (!res.ok) break;
64
64
+
cursor = output.cursor || undefined;
65
65
+
} while (cursor);
34
66
35
35
-
const data = await res.json();
36
36
-
const output = data.output;
37
37
-
if (!output) break;
67
67
+
// 2. Parse RSVPs and collect unique DIDs
68
68
+
const going: string[] = [];
69
69
+
const interested: string[] = [];
38
70
39
39
-
if (output.records && Array.isArray(output.records)) {
40
40
-
allRecords.push(...output.records);
71
71
+
for (const [uri, raw] of Object.entries(allRecords)) {
72
72
+
console.log(uri, raw);
73
73
+
const record = raw as { did?: string; value?: { status?: string } };
74
74
+
// DID can be on the record directly or extracted from the AT URI key
75
75
+
const parts = parseUri(uri);
76
76
+
const repo = parts?.repo;
77
77
+
if (!repo) continue;
78
78
+
79
79
+
const status = record.value?.status || '';
80
80
+
if (status.includes('#going')) {
81
81
+
going.push(repo);
82
82
+
} else if (status.includes('#interested')) {
83
83
+
interested.push(repo);
84
84
+
}
41
85
}
42
86
43
43
-
cursor = output.cursor || undefined;
44
44
-
} while (cursor);
87
87
+
// 3. Fetch profiles for attendees (with caching)
88
88
+
const uniqueDids = [...new Set([...going, ...interested])];
89
89
+
const { platform } = getRequestEvent();
90
90
+
const cache = createCache(platform);
91
91
+
92
92
+
const profileMap = new Map<string, CachedProfile>();
93
93
+
94
94
+
await Promise.all(
95
95
+
uniqueDids.map(async (did) => {
96
96
+
try {
97
97
+
let profile: CachedProfile;
98
98
+
if (cache) {
99
99
+
profile = await cache.getProfile(did as Did);
100
100
+
} else {
101
101
+
const p = await getBlentoOrBskyProfile({ did: did as Did });
102
102
+
profile = {
103
103
+
did: p.did as string,
104
104
+
handle: p.handle as string,
105
105
+
displayName: p.displayName as string | undefined,
106
106
+
avatar: p.avatar as string | undefined,
107
107
+
hasBlento: p.hasBlento,
108
108
+
url: p.url
109
109
+
};
110
110
+
}
111
111
+
profileMap.set(did, profile);
112
112
+
} catch {
113
113
+
// skip failed profile fetches
114
114
+
}
115
115
+
})
116
116
+
);
45
117
46
46
-
return allRecords;
47
47
-
});
118
118
+
function toAttendeeInfo(did: string, status: 'going' | 'interested'): AttendeeInfo {
119
119
+
const profile = profileMap.get(did);
120
120
+
const handle = profile?.handle;
121
121
+
const url = profile?.hasBlento
122
122
+
? profile.url || (handle ? `/${handle}` : undefined)
123
123
+
: handle
124
124
+
? `https://bsky.app/profile/${handle}`
125
125
+
: `https://bsky.app/profile/${did}`;
126
126
+
return {
127
127
+
did,
128
128
+
status,
129
129
+
avatar: profile?.avatar,
130
130
+
name: profile?.displayName || handle || did,
131
131
+
handle,
132
132
+
url
133
133
+
};
134
134
+
}
135
135
+
136
136
+
return {
137
137
+
going: going.map((did) => toAttendeeInfo(did, 'going')),
138
138
+
interested: interested.map((did) => toAttendeeInfo(did, 'interested')),
139
139
+
goingCount: going.length,
140
140
+
interestedCount: interested.length
141
141
+
};
142
142
+
}
143
143
+
);
+1
-1
src/routes/[[actor=actor]]/events/[rkey]/edit/+page.svelte
···
548
548
</svelte:head>
549
549
550
550
<div class="min-h-screen px-6 py-12 sm:py-12">
551
551
-
<div class="mx-auto max-w-2xl">
551
551
+
<div class="mx-auto max-w-3xl">
552
552
{#if user.isInitializing}
553
553
<div class="flex items-center gap-3">
554
554
<div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div>
+4
-4
src/routes/[[actor=actor]]/events/new/+page.svelte
···
451
451
</svelte:head>
452
452
453
453
<div class="min-h-screen px-6 py-12 sm:py-12">
454
454
-
<div class="mx-auto max-w-2xl">
454
454
+
<div class="mx-auto max-w-3xl">
455
455
{#if user.isInitializing}
456
456
<div class="flex items-center gap-3">
457
457
<div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div>
···
459
459
</div>
460
460
{:else if !user.isLoggedIn}
461
461
<div
462
462
-
class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 rounded-2xl border p-8 text-center"
462
462
+
class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-950/50 rounded-2xl border p-8 text-center"
463
463
>
464
464
<p class="text-base-600 dark:text-base-400 mb-4">Log in to create an event.</p>
465
465
<Button onclick={() => loginModalState.show()}>Log in</Button>
···
565
565
bind:value={name}
566
566
required
567
567
placeholder="Event name"
568
568
-
class="text-base-900 dark:text-base-50 placeholder:text-base-300 dark:placeholder:text-base-700 mb-2 w-full border-0 bg-transparent text-4xl leading-tight font-bold focus:border-0 focus:ring-0 focus:outline-none sm:text-5xl"
568
568
+
class="text-base-900 dark:text-base-50 placeholder:text-base-500 dark:placeholder:text-base-500 mb-2 w-full border-0 bg-transparent text-4xl leading-tight font-bold focus:border-0 focus:ring-0 focus:outline-none sm:text-5xl"
569
569
/>
570
570
571
571
<!-- Mode toggle -->
···
747
747
bind:value={description}
748
748
rows={4}
749
749
placeholder="What's this event about? @mentions, #hashtags and links will be detected automatically."
750
750
-
class="text-base-700 dark:text-base-300 placeholder:text-base-300 dark:placeholder:text-base-700 w-full resize-none border-0 bg-transparent leading-relaxed focus:border-0 focus:ring-0 focus:outline-none"
750
750
+
class="text-base-700 dark:text-base-300 placeholder:text-base-500 dark:placeholder:text-base-500 w-full resize-none border-0 bg-transparent leading-relaxed focus:border-0 focus:ring-0 focus:outline-none"
751
751
></textarea>
752
752
</div>
753
753