your personal website on atproto - mirror blento.app

Merge pull request #218 from flo-bit/event-fixes

more event fixes+improvements

authored by

Florian and committed by
GitHub
c0b34c16 ff35b135

+414 -227
+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 - 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 - const actorPrefix = page.params.actor ? `/${page.params.actor}` : ''; 97 - return `${actorPrefix}/events/${parsedUri.rkey}`; 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 - if (!redirect || redirect === '/' || redirect === 'https://blento.app' || redirect === 'https://blento.app/') { 20 + if ( 21 + !redirect || 22 + redirect === '/' || 23 + redirect === 'https://blento.app' || 24 + redirect === 'https://blento.app/' 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 - <div class="mx-auto max-w-2xl"> 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 - <div class="mx-auto max-w-2xl"> 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 - <div class="mx-auto max-w-2xl"> 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 - <div class="mx-auto max-w-2xl"> 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>
+35 -21
src/routes/[[actor=actor]]/events/[rkey]/+page.svelte
··· 155 155 : null 156 156 ); 157 157 158 - let smokesignalUrl = $derived(`https://smokesignal.events/${did}/${rkey}`); 158 + // let smokesignalUrl = $derived(`https://smokesignal.events/${did}/${rkey}`); 159 159 let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`); 160 160 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 + 165 + let attendeesRef: EventAttendees | undefined = $state(); 166 + 167 + function handleRsvp(status: 'going' | 'interested') { 168 + if (!user.did) return; 169 + attendeesRef?.addAttendee({ 170 + did: user.did, 171 + status, 172 + avatar: user.profile?.avatar, 173 + name: user.profile?.displayName || user.profile?.handle || user.did 174 + }); 175 + } 176 + 177 + function handleRsvpCancel() { 178 + if (!user.did) return; 179 + attendeesRef?.removeAttendee(user.did); 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 - <div class="mx-auto max-w-2xl"> 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 - <h1 222 - class="text-base-900 dark:text-base-50 text-4xl leading-tight font-bold sm:text-5xl" 223 - > 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 - <Button href="./edit" variant="ghost" size="sm" class="shrink-0">Edit</Button> 242 + <Button href="./edit" size="sm" class="shrink-0">Edit</Button> 228 243 {/if} 229 244 </div> 230 245 ··· 237 252 </div> 238 253 {/if} 239 254 240 - <!-- Date row (Luma-style calendar icon) --> 255 + <!-- Date row --> 241 256 <div class="mb-4 flex items-center gap-4"> 242 257 <div 243 - class="border-base-200 dark:border-base-700 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 258 + class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 244 259 > 245 260 <span class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold"> 246 261 {formatMonth(startDate)} ··· 295 310 </div> 296 311 {/if} 297 312 298 - <EventRsvp {eventUri} eventCid={data.eventCid} /> 299 - 300 - {#if isOwner} 301 - <div class="mt-4"> 302 - <Button href="./edit" variant="secondary" size="sm">Edit event</Button> 303 - </div> 304 - {/if} 313 + <EventRsvp 314 + {eventUri} 315 + eventCid={data.eventCid} 316 + onrsvp={handleRsvp} 317 + oncancel={handleRsvpCancel} 318 + /> 305 319 306 320 <!-- About Event --> 307 321 {#if descriptionHtml} ··· 380 394 {/if} 381 395 382 396 <!-- Attendees --> 383 - <!-- <div class="order-5 md:order-0 md:col-start-1"> 384 - <EventAttendees {eventUri} {did} /> 385 - </div> --> 397 + <div class="order-5 md:order-0 md:col-start-1"> 398 + <EventAttendees bind:this={attendeesRef} {eventUri} /> 399 + </div> 386 400 387 - <!-- View on Smoke Signal link --> 388 - <a 401 + <!-- View on Smoke Signal link, currently disabled as some events dont work on smokesignal --> 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 - </a> 423 + </a> --> 410 424 </div> 411 425 </div> 412 426 </div>
+176 -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 - import { fetchEventBacklinks } from './api.remote'; 4 + import { scale } from 'svelte/transition'; 5 + import { flip } from 'svelte/animate'; 6 + import { fetchEventAttendees, type AttendeeInfo } from './api.remote'; 7 + import Modal from '$lib/components/modal/Modal.svelte'; 5 8 6 - let { eventUri, did }: { eventUri: string; did: string } = $props(); 9 + let { eventUri }: { eventUri: string } = $props(); 7 10 8 11 let goingCount = $state(0); 9 12 let interestedCount = $state(0); 10 - let goingAvatars: Array<{ did: string; avatar?: string; name: string }> = $state([]); 11 - let interestedAvatars: Array<{ did: string; avatar?: string; name: string }> = $state([]); 13 + let goingAttendees: AttendeeInfo[] = $state([]); 14 + let interestedAttendees: AttendeeInfo[] = $state([]); 12 15 let loading = $state(true); 13 16 14 - onMount(() => { 15 - fetchEventBacklinks(eventUri) 16 - .then((records) => { 17 - console.log(records); 18 - if (!records) return; 19 - let going = 0; 20 - let interested = 0; 21 - const goingAvatarList: Array<{ did: string; avatar?: string; name: string }> = []; 22 - const interestedAvatarList: Array<{ did: string; avatar?: string; name: string }> = []; 17 + let modalOpen = $state(false); 18 + let modalGroup: 'going' | 'interested' = $state('going'); 23 19 24 - for (const raw of records) { 25 - const record = raw as { 26 - did: string; 27 - value?: { status?: string }; 28 - author?: { avatar?: string; displayName?: string; handle?: string }; 29 - }; 30 - const status = record.value?.status || ''; 31 - const author = record.author; 32 - const avatarInfo = { 33 - did: record.did, 34 - avatar: author?.avatar, 35 - name: author?.displayName || author?.handle || record.did 36 - }; 20 + const MAX_AVATARS = 18; 37 21 38 - if (status.includes('#going')) { 39 - going++; 40 - goingAvatarList.push(avatarInfo); 41 - } else if (status.includes('#interested')) { 42 - interested++; 43 - interestedAvatarList.push(avatarInfo); 44 - } 45 - } 46 - 47 - goingCount = going; 48 - interestedCount = interested; 49 - goingAvatars = goingAvatarList; 50 - interestedAvatars = interestedAvatarList; 51 - }) 52 - .catch((err) => { 53 - console.error('Failed to fetch event attendees:', err); 54 - }) 55 - .finally(() => { 56 - loading = false; 57 - }); 22 + onMount(async () => { 23 + try { 24 + const result = await fetchEventAttendees(eventUri); 25 + if (!result) return; 26 + goingCount = result.goingCount; 27 + interestedCount = result.interestedCount; 28 + goingAttendees = result.going; 29 + interestedAttendees = result.interested; 30 + } catch (err) { 31 + console.error('Failed to fetch event attendees:', err); 32 + } finally { 33 + loading = false; 34 + } 58 35 }); 59 36 60 37 let totalCount = $derived(goingCount + interestedCount); 61 - let allAvatars = $derived([...goingAvatars, ...interestedAvatars]); 62 - let displayAvatars = $derived(allAvatars.slice(0, 8)); 63 - let overflowCount = $derived(allAvatars.length - displayAvatars.length); 38 + 39 + let goingDisplay = $derived(goingAttendees.slice(0, MAX_AVATARS)); 40 + let goingOverflow = $derived(goingCount - goingDisplay.length); 41 + 42 + let interestedDisplay = $derived(interestedAttendees.slice(0, MAX_AVATARS)); 43 + let interestedOverflow = $derived(interestedCount - interestedDisplay.length); 44 + 45 + let modalAttendees = $derived(modalGroup === 'going' ? goingAttendees : interestedAttendees); 46 + let modalTitle = $derived(modalGroup === 'going' ? 'Going' : 'Interested'); 47 + 48 + function openModal(group: 'going' | 'interested') { 49 + modalGroup = group; 50 + modalOpen = true; 51 + } 52 + 53 + export function addAttendee(attendee: AttendeeInfo) { 54 + // Remove from both lists first (in case of status change) 55 + goingAttendees = goingAttendees.filter((a) => a.did !== attendee.did); 56 + interestedAttendees = interestedAttendees.filter((a) => a.did !== attendee.did); 57 + 58 + if (attendee.status === 'going') { 59 + goingAttendees = [attendee, ...goingAttendees]; 60 + goingCount = goingAttendees.length; 61 + } else if (attendee.status === 'interested') { 62 + interestedAttendees = [attendee, ...interestedAttendees]; 63 + interestedCount = interestedAttendees.length; 64 + } 65 + } 66 + 67 + export function removeAttendee(did: string) { 68 + const wasGoing = goingAttendees.some((a) => a.did === did); 69 + const wasInterested = interestedAttendees.some((a) => a.did === did); 70 + goingAttendees = goingAttendees.filter((a) => a.did !== did); 71 + interestedAttendees = interestedAttendees.filter((a) => a.did !== did); 72 + if (wasGoing) goingCount = goingAttendees.length; 73 + if (wasInterested) interestedCount = interestedAttendees.length; 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 - <div> 72 - <p class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"> 73 - Attendees 74 - </p> 75 - 76 - <!-- Avatar stack --> 77 - {#if displayAvatars.length > 0} 78 - <div class="mb-3 flex items-center"> 79 - <div class="flex -space-x-2"> 80 - {#each displayAvatars as person (person.did)} 81 - <FoxAvatar 82 - src={person.avatar} 83 - alt={person.name} 84 - class="ring-base-50 dark:ring-base-950 size-7 ring-2" 85 - /> 86 - {/each} 82 + <div class="mb-2"> 83 + {#if goingCount > 0} 84 + <button 85 + type="button" 86 + class="hover:bg-base-100 dark:hover:bg-base-800/50 -mx-2 block w-full cursor-pointer rounded-xl px-2 py-2 text-left transition-colors" 87 + onclick={() => openModal('going')} 88 + > 89 + <p class="text-base-900 dark:text-base-50 mb-2 text-sm"> 90 + <span class="font-bold">{goingCount}</span> 91 + <span 92 + class="text-base-500 dark:text-base-400 text-xs font-semibold tracking-wider uppercase" 93 + >Going</span 94 + > 95 + </p> 96 + <div class="flex items-center"> 97 + <div class="flex flex-wrap -space-y-2 -space-x-4 pr-4"> 98 + {#each goingDisplay as person (person.did)} 99 + <div 100 + animate:flip={{ duration: 300 }} 101 + in:scale={{ duration: 300, start: 0.5 }} 102 + out:scale={{ duration: 200, start: 0.5 }} 103 + > 104 + <FoxAvatar 105 + src={person.avatar} 106 + alt={person.name} 107 + fallback={person.name} 108 + class="border-base-100 dark:border-base-900 size-12 border-2" 109 + /> 110 + </div> 111 + {/each} 112 + {#if goingOverflow > 0} 113 + <span 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 + > 116 + +{goingOverflow} 117 + </span> 118 + {/if} 119 + </div> 87 120 </div> 88 - {#if overflowCount > 0} 89 - <span class="text-base-500 dark:text-base-400 ml-2 text-xs"> 90 - +{overflowCount} 91 - </span> 92 - {/if} 93 - </div> 121 + </button> 94 122 {/if} 95 123 96 - <!-- Counts --> 97 - <div class="text-base-600 dark:text-base-400 flex items-center gap-3 text-sm"> 98 - {#if goingCount > 0} 99 - <span class="flex items-center gap-1.5"> 100 - <svg 101 - xmlns="http://www.w3.org/2000/svg" 102 - viewBox="0 0 20 20" 103 - fill="currentColor" 104 - class="size-3.5 text-green-500" 124 + {#if interestedCount > 0} 125 + <button 126 + type="button" 127 + class="hover:bg-base-100 dark:hover:bg-base-800/50 -mx-2 mt-4 block w-full cursor-pointer rounded-xl px-2 py-2 text-left transition-colors" 128 + onclick={() => openModal('interested')} 129 + > 130 + <p class="text-base-900 dark:text-base-50 mb-2 text-sm"> 131 + <span class="font-bold">{interestedCount}</span> 132 + <span 133 + class="text-base-500 dark:text-base-400 text-xs font-semibold tracking-wider uppercase" 134 + >Interested</span 105 135 > 106 - <path 107 - fill-rule="evenodd" 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 - clip-rule="evenodd" 110 - /> 111 - </svg> 112 - {goingCount} going 113 - </span> 114 - {/if} 115 - {#if interestedCount > 0} 116 - <span class="flex items-center gap-1.5"> 117 - <svg 118 - xmlns="http://www.w3.org/2000/svg" 119 - viewBox="0 0 20 20" 120 - fill="currentColor" 121 - class="size-3.5 text-amber-500" 122 - > 123 - <path 124 - fill-rule="evenodd" 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 - clip-rule="evenodd" 127 - /> 128 - </svg> 129 - {interestedCount} interested 130 - </span> 131 - {/if} 132 - </div> 136 + </p> 137 + <div class="flex items-center"> 138 + <div class="flex flex-wrap -space-y-2 -space-x-4 pr-4"> 139 + {#each interestedDisplay as person (person.did)} 140 + <div 141 + animate:flip={{ duration: 300 }} 142 + in:scale={{ duration: 300, start: 0.5 }} 143 + out:scale={{ duration: 200, start: 0.5 }} 144 + > 145 + <FoxAvatar 146 + src={person.avatar} 147 + alt={person.name} 148 + fallback={person.name} 149 + class="border-base-100 dark:border-base-900 size-12 border-2" 150 + /> 151 + </div> 152 + {/each} 153 + {#if interestedOverflow > 0} 154 + <span 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 + > 157 + +{interestedOverflow} 158 + </span> 159 + {/if} 160 + </div> 161 + </div> 162 + </button> 163 + {/if} 133 164 </div> 134 165 {/if} 166 + 167 + <Modal bind:open={modalOpen} closeButton onOpenAutoFocus={(e) => e.preventDefault()} class="p-0"> 168 + <p class="text-base-900 dark:text-base-50 px-4 pt-4 text-lg font-semibold"> 169 + {modalTitle} 170 + <span class="text-base-500 dark:text-base-400 text-sm font-normal"> 171 + ({modalAttendees.length}) 172 + </span> 173 + </p> 174 + <div 175 + class="dark:bg-base-900/50 bg-base-200/30 mx-4 mb-4 max-h-80 space-y-1 overflow-y-auto rounded-xl p-2" 176 + > 177 + {#each modalAttendees as person (person.did)} 178 + <a 179 + href={person.url} 180 + target={person.url?.startsWith('/') ? undefined : '_blank'} 181 + rel={person.url?.startsWith('/') ? undefined : 'noopener noreferrer'} 182 + class="hover:bg-base-200 dark:hover:bg-base-900 flex items-center gap-3 rounded-xl px-2 py-2 transition-colors" 183 + > 184 + <FoxAvatar 185 + src={person.avatar} 186 + alt={person.name} 187 + fallback={person.name} 188 + class="size-10 shrink-0" 189 + /> 190 + <div class="min-w-0"> 191 + <p class="text-base-900 dark:text-base-50 truncate text-sm font-medium"> 192 + {person.name} 193 + </p> 194 + {#if person.handle} 195 + <p class="text-base-500 dark:text-base-400 truncate text-xs"> 196 + @{person.handle} 197 + </p> 198 + {/if} 199 + </div> 200 + </a> 201 + {/each} 202 + </div> 203 + </Modal>
+51 -49
src/routes/[[actor=actor]]/events/[rkey]/EventRsvp.svelte
··· 1 1 <script lang="ts"> 2 2 import { user } from '$lib/atproto/auth.svelte'; 3 + import { getRecord, putRecord, deleteRecord, createTID } from '$lib/atproto/methods'; 3 4 import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 4 5 import { Avatar, Button } from '@foxui/core'; 6 + import type { Did } from '@atcute/lexicons'; 5 7 6 - let { eventUri, eventCid }: { eventUri: string; eventCid: string | null } = $props(); 8 + let { 9 + eventUri, 10 + eventCid, 11 + onrsvp, 12 + oncancel 13 + }: { 14 + eventUri: string; 15 + eventCid: string | null; 16 + onrsvp?: (status: 'going' | 'interested') => void; 17 + oncancel?: () => void; 18 + } = $props(); 7 19 8 20 let rsvpStatus: 'going' | 'interested' | 'notgoing' | null = $state(null); 9 21 let rsvpRkey: string | null = $state(null); ··· 20 32 21 33 rsvpLoading = true; 22 34 23 - fetch( 24 - `https://smokesignal.events/xrpc/community.lexicon.calendar.getRSVP?identity=${encodeURIComponent(userDid)}&event=${encodeURIComponent(eventUri)}` 25 - ) 35 + const url = `https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(eventUri)}&source=community.lexicon.calendar.rsvp:subject.uri&did=${encodeURIComponent(userDid)}&limit=1`; 36 + 37 + fetch(url) 26 38 .then((res) => { 27 - if (!res.ok) { 28 - rsvpStatus = null; 29 - rsvpRkey = null; 30 - return; 31 - } 39 + if (!res.ok) throw new Error('Failed to fetch backlinks'); 32 40 return res.json(); 33 41 }) 34 - .then((data) => { 35 - if (!data?.record?.status) { 42 + .then(async (data) => { 43 + if (!data?.records?.length) { 36 44 rsvpStatus = null; 37 45 rsvpRkey = null; 38 46 return; 39 47 } 40 - if (data.uri) { 41 - const parts = data.uri.split('/'); 42 - rsvpRkey = parts[parts.length - 1]; 43 - } 44 - const status = data.record.status as string; 45 - if (status.includes('#going')) rsvpStatus = 'going'; 46 - else if (status.includes('#interested')) rsvpStatus = 'interested'; 47 - else if (status.includes('#notgoing')) rsvpStatus = 'notgoing'; 48 + 49 + const backlink = data.records[0]; 50 + rsvpRkey = backlink.rkey; 51 + 52 + const recordData = await getRecord({ 53 + did: backlink.did as Did, 54 + collection: backlink.collection, 55 + rkey: backlink.rkey 56 + }); 57 + 58 + const status = recordData?.value?.status as string; 59 + if (status?.includes('#going')) rsvpStatus = 'going'; 60 + else if (status?.includes('#interested')) rsvpStatus = 'interested'; 61 + else if (status?.includes('#notgoing')) rsvpStatus = 'notgoing'; 48 62 else rsvpStatus = null; 49 63 }) 50 64 .catch(() => { ··· 60 74 if (!user.client || !user.did) return; 61 75 rsvpSubmitting = true; 62 76 try { 63 - if (rsvpRkey) { 64 - await user.client.post('com.atproto.repo.deleteRecord', { 65 - input: { 66 - collection: 'community.lexicon.calendar.rsvp', 67 - repo: user.did, 68 - rkey: rsvpRkey 69 - } 70 - }); 71 - } 77 + const key = rsvpRkey ?? createTID(); 72 78 73 - const response = await user.client.post('com.atproto.repo.createRecord', { 74 - input: { 75 - collection: 'community.lexicon.calendar.rsvp', 76 - repo: user.did, 77 - record: { 78 - $type: 'community.lexicon.calendar.rsvp', 79 - status: `community.lexicon.calendar.rsvp#${status}`, 80 - subject: { 81 - uri: eventUri, 82 - ...(eventCid ? { cid: eventCid } : {}) 83 - }, 84 - createdAt: new Date().toISOString() 85 - } 79 + const response = await putRecord({ 80 + collection: 'community.lexicon.calendar.rsvp', 81 + rkey: key, 82 + record: { 83 + $type: 'community.lexicon.calendar.rsvp', 84 + status: `community.lexicon.calendar.rsvp#${status}`, 85 + subject: { 86 + uri: eventUri, 87 + ...(eventCid ? { cid: eventCid } : {}) 88 + }, 89 + createdAt: new Date().toISOString() 86 90 } 87 91 }); 88 92 89 93 if (response.ok) { 90 94 rsvpStatus = status; 91 - const parts = response.data.uri.split('/'); 92 - rsvpRkey = parts[parts.length - 1]; 95 + rsvpRkey = key; 96 + onrsvp?.(status); 93 97 } 94 98 } catch (e) { 95 99 console.error('Failed to submit RSVP:', e); ··· 102 106 if (!user.client || !user.did || !rsvpRkey) return; 103 107 rsvpSubmitting = true; 104 108 try { 105 - await user.client.post('com.atproto.repo.deleteRecord', { 106 - input: { 107 - collection: 'community.lexicon.calendar.rsvp', 108 - repo: user.did, 109 - rkey: rsvpRkey 110 - } 109 + await deleteRecord({ 110 + collection: 'community.lexicon.calendar.rsvp', 111 + rkey: rsvpRkey 111 112 }); 112 113 rsvpStatus = null; 113 114 rsvpRkey = null; 115 + oncancel?.(); 114 116 } catch (e) { 115 117 console.error('Failed to cancel RSVP:', e); 116 118 } finally {
+136 -37
src/routes/[[actor=actor]]/events/[rkey]/api.remote.ts
··· 1 - import { query } from '$app/server'; 1 + import { query, getRequestEvent } from '$app/server'; 2 + import { createCache, type CachedProfile } from '$lib/cache'; 3 + import { getBlentoOrBskyProfile, parseUri } from '$lib/atproto/methods'; 4 + import type { Did } from '@atcute/lexicons'; 5 + 6 + export type AttendeeInfo = { 7 + did: string; 8 + status: 'going' | 'interested'; 9 + avatar?: string; 10 + name: string; 11 + handle?: string; 12 + url?: string; 13 + }; 14 + 15 + export type EventAttendeesResult = { 16 + going: AttendeeInfo[]; 17 + interested: AttendeeInfo[]; 18 + goingCount: number; 19 + interestedCount: number; 20 + }; 21 + 22 + export const fetchEventAttendees = query( 23 + 'unchecked', 24 + async (eventUri: string): Promise<EventAttendeesResult> => { 25 + // 1. Fetch backlinks (RSVPs) 26 + const allRecords: Record<string, unknown> = {}; 27 + let cursor: string | undefined; 28 + 29 + do { 30 + const params: Record<string, unknown> = { 31 + subject: eventUri, 32 + source: 'community.lexicon.calendar.rsvp:subject.uri' 33 + }; 34 + if (cursor) params.cursor = cursor; 35 + 36 + const res = await fetch( 37 + 'https://slingshot.microcosm.blue/xrpc/com.bad-example.proxy.hydrateQueryResponse', 38 + { 39 + method: 'POST', 40 + headers: { 'Content-Type': 'application/json' }, 41 + body: JSON.stringify({ 42 + atproto_proxy: 'did:web:constellation.microcosm.blue#constellation', 43 + hydration_sources: [ 44 + { 45 + path: 'records[]', 46 + shape: 'at-uri-parts' 47 + } 48 + ], 49 + params, 50 + xrpc: 'blue.microcosm.links.getBacklinks' 51 + }) 52 + } 53 + ); 54 + 55 + if (!res.ok) break; 56 + 57 + const data = await res.json(); 58 + const output = data.output; 59 + 60 + for (const [key, value] of Object.entries(data.records ?? {})) { 61 + allRecords[key] = value; 62 + } 63 + 64 + cursor = output.cursor || undefined; 65 + } while (cursor); 2 66 3 - export const fetchEventBacklinks = query('unchecked', async (eventUri: string) => { 4 - const allRecords: Record<string, unknown>[] = []; 5 - let cursor: string | undefined; 67 + // 2. Parse RSVPs and collect unique DIDs 68 + const going: string[] = []; 69 + const interested: string[] = []; 6 70 7 - do { 8 - const params: Record<string, unknown> = { 9 - subject: eventUri, 10 - source: 'community.lexicon.calendar.rsvp:subject.uri' 11 - }; 12 - if (cursor) params.cursor = cursor; 71 + for (const [uri, raw] of Object.entries(allRecords)) { 72 + console.log(uri, raw); 73 + const record = raw as { did?: string; value?: { status?: string } }; 74 + // DID can be on the record directly or extracted from the AT URI key 75 + const parts = parseUri(uri); 76 + const repo = parts?.repo; 77 + if (!repo) continue; 13 78 14 - const res = await fetch( 15 - 'https://slingshot.microcosm.blue/xrpc/com.bad-example.proxy.hydrateQueryResponse', 16 - { 17 - method: 'POST', 18 - headers: { 'Content-Type': 'application/json' }, 19 - body: JSON.stringify({ 20 - atproto_proxy: 'did:web:constellation.microcosm.blue#constellation', 21 - hydration_sources: [ 22 - { 23 - path: 'records[]', 24 - shape: 'at-uri-parts' 25 - } 26 - ], 27 - params, 28 - xrpc: 'blue.microcosm.links.getBacklinks' 29 - }) 79 + const status = record.value?.status || ''; 80 + if (status.includes('#going')) { 81 + going.push(repo); 82 + } else if (status.includes('#interested')) { 83 + interested.push(repo); 30 84 } 31 - ); 85 + } 32 86 33 - if (!res.ok) break; 87 + // 3. Fetch profiles for attendees (with caching) 88 + const uniqueDids = [...new Set([...going, ...interested])]; 89 + const { platform } = getRequestEvent(); 90 + const cache = createCache(platform); 34 91 35 - const data = await res.json(); 36 - const output = data.output; 37 - if (!output) break; 92 + const profileMap = new Map<string, CachedProfile>(); 38 93 39 - if (output.records && Array.isArray(output.records)) { 40 - allRecords.push(...output.records); 94 + await Promise.all( 95 + uniqueDids.map(async (did) => { 96 + try { 97 + let profile: CachedProfile; 98 + if (cache) { 99 + profile = await cache.getProfile(did as Did); 100 + } else { 101 + const p = await getBlentoOrBskyProfile({ did: did as Did }); 102 + profile = { 103 + did: p.did as string, 104 + handle: p.handle as string, 105 + displayName: p.displayName as string | undefined, 106 + avatar: p.avatar as string | undefined, 107 + hasBlento: p.hasBlento, 108 + url: p.url 109 + }; 110 + } 111 + profileMap.set(did, profile); 112 + } catch { 113 + // skip failed profile fetches 114 + } 115 + }) 116 + ); 117 + 118 + function toAttendeeInfo(did: string, status: 'going' | 'interested'): AttendeeInfo { 119 + const profile = profileMap.get(did); 120 + const handle = profile?.handle; 121 + const url = profile?.hasBlento 122 + ? profile.url || (handle ? `/${handle}` : undefined) 123 + : handle 124 + ? `https://bsky.app/profile/${handle}` 125 + : `https://bsky.app/profile/${did}`; 126 + return { 127 + did, 128 + status, 129 + avatar: profile?.avatar, 130 + name: profile?.displayName || handle || did, 131 + handle, 132 + url 133 + }; 41 134 } 42 135 43 - cursor = output.cursor || undefined; 44 - } while (cursor); 136 + const uniqueGoing = [...new Set(going)]; 137 + const uniqueInterested = [...new Set(interested)]; 45 138 46 - return allRecords; 47 - }); 139 + return { 140 + going: uniqueGoing.map((did) => toAttendeeInfo(did, 'going')), 141 + interested: uniqueInterested.map((did) => toAttendeeInfo(did, 'interested')), 142 + goingCount: uniqueGoing.length, 143 + interestedCount: uniqueInterested.length 144 + }; 145 + } 146 + );
+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 - <div class="mx-auto max-w-2xl"> 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 - <div class="mx-auto max-w-2xl"> 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 - class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 rounded-2xl border p-8 text-center" 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 - 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 + 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 - 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 + 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