your personal website on atproto - mirror blento.app

more event fixes+improvements

Florian d2ed12fc ff35b135

+368 -176
+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>
+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 + 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 ··· 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>
+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 - 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 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 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()}> 168 + <p class="text-base-900 dark:text-base-50 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 class="mt-3 max-h-80 space-y-1 overflow-y-auto p-2"> 175 + {#each modalAttendees as person (person.did)} 176 + <a 177 + href={person.url} 178 + target={person.url?.startsWith('/') ? undefined : '_blank'} 179 + rel={person.url?.startsWith('/') ? undefined : 'noopener noreferrer'} 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 + > 182 + <FoxAvatar 183 + src={person.avatar} 184 + alt={person.name} 185 + fallback={person.name} 186 + class="size-10 shrink-0" 187 + /> 188 + <div class="min-w-0"> 189 + <p class="text-base-900 dark:text-base-50 truncate text-sm font-medium"> 190 + {person.name} 191 + </p> 192 + {#if person.handle} 193 + <p class="text-base-500 dark:text-base-400 truncate text-xs"> 194 + @{person.handle} 195 + </p> 196 + {/if} 197 + </div> 198 + </a> 199 + {/each} 200 + </div> 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 - let { eventUri, eventCid }: { eventUri: string; eventCid: string | null } = $props(); 6 + let { 7 + eventUri, 8 + eventCid, 9 + onrsvp, 10 + oncancel 11 + }: { 12 + eventUri: string; 13 + eventCid: string | null; 14 + onrsvp?: (status: 'going' | 'interested') => void; 15 + oncancel?: () => void; 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 + 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 + 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 - 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 + ); 2 54 3 - export const fetchEventBacklinks = query('unchecked', async (eventUri: string) => { 4 - const allRecords: Record<string, unknown>[] = []; 5 - let cursor: string | undefined; 55 + if (!res.ok) break; 6 56 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; 57 + const data = await res.json(); 58 + const output = data.output; 13 59 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 - }) 60 + for (const [key, value] of Object.entries(data.records ?? {})) { 61 + allRecords[key] = value; 30 62 } 31 - ); 32 63 33 - if (!res.ok) break; 64 + cursor = output.cursor || undefined; 65 + } while (cursor); 34 66 35 - const data = await res.json(); 36 - const output = data.output; 37 - if (!output) break; 67 + // 2. Parse RSVPs and collect unique DIDs 68 + const going: string[] = []; 69 + const interested: string[] = []; 38 70 39 - if (output.records && Array.isArray(output.records)) { 40 - allRecords.push(...output.records); 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; 78 + 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); 84 + } 41 85 } 42 86 43 - cursor = output.cursor || undefined; 44 - } while (cursor); 87 + // 3. Fetch profiles for attendees (with caching) 88 + const uniqueDids = [...new Set([...going, ...interested])]; 89 + const { platform } = getRequestEvent(); 90 + const cache = createCache(platform); 91 + 92 + const profileMap = new Map<string, CachedProfile>(); 93 + 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 + ); 45 117 46 - return allRecords; 47 - }); 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 + }; 134 + } 135 + 136 + return { 137 + going: going.map((did) => toAttendeeInfo(did, 'going')), 138 + interested: interested.map((did) => toAttendeeInfo(did, 'interested')), 139 + goingCount: going.length, 140 + interestedCount: interested.length 141 + }; 142 + } 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 - <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