your personal website on atproto - mirror blento.app

more event stuff

Florian d68501ad 2d0f550d

+648 -214
+2
src/lib/cache.ts
··· 13 13 npmx: 60 * 60 * 12, // 12 hours 14 14 profile: 60 * 60 * 24, // 24 hours 15 15 ical: 60 * 60 * 2, // 2 hours 16 + events: 60 * 60, // 1 hour 17 + rsvps: 60 * 60, // 1 hour 16 18 meta: 0 // no auto-expiry 17 19 } as const; 18 20
+73 -1
src/lib/events/fetch-attendees.ts
··· 1 - import { getBlentoOrBskyProfile, parseUri } from '$lib/atproto/methods'; 1 + import { getBlentoOrBskyProfile, getRecord, listRecords, parseUri } from '$lib/atproto/methods'; 2 2 import type { CacheService, CachedProfile } from '$lib/cache'; 3 + import type { EventData } from '$lib/cards/social/EventCard'; 3 4 import type { Did } from '@atcute/lexicons'; 4 5 5 6 export type RsvpStatus = 'going' | 'interested'; 7 + 8 + export interface ResolvedRsvp { 9 + event: EventData; 10 + rkey: string; 11 + hostDid: string; 12 + hostProfile: CachedProfile | null; 13 + status: 'going' | 'interested'; 14 + eventUri: string; 15 + } 6 16 7 17 /** 8 18 * Fetch raw RSVP data for an event from Microcosm Constellation backlinks. ··· 106 116 const handle = profile?.handle; 107 117 return handle ? `https://bsky.app/profile/${handle}` : `https://bsky.app/profile/${did}`; 108 118 } 119 + 120 + interface RsvpRecord { 121 + $type: string; 122 + status: string; 123 + subject: { uri: string; cid?: string }; 124 + createdAt: string; 125 + } 126 + 127 + /** 128 + * Fetch a user's RSVPs (going/interested) and resolve each referenced event + host profile. 129 + */ 130 + export async function fetchUserRsvps( 131 + did: string, 132 + cache?: CacheService | null 133 + ): Promise<ResolvedRsvp[]> { 134 + const rsvpRecords = await listRecords({ 135 + did: did as Did, 136 + collection: 'community.lexicon.calendar.rsvp', 137 + limit: 100 138 + }); 139 + 140 + const activeRsvps = rsvpRecords.filter((r) => { 141 + const rsvp = r.value as unknown as RsvpRecord; 142 + return rsvp.status?.endsWith('#going') || rsvp.status?.endsWith('#interested'); 143 + }); 144 + 145 + const results = await Promise.all( 146 + activeRsvps.map(async (r) => { 147 + const rsvp = r.value as unknown as RsvpRecord; 148 + const parsed = parseUri(rsvp.subject.uri); 149 + if (!parsed?.rkey || !parsed?.repo) return null; 150 + 151 + try { 152 + const [record, hostProfile] = await Promise.all([ 153 + getRecord({ 154 + did: parsed.repo as Did, 155 + collection: 'community.lexicon.calendar.event', 156 + rkey: parsed.rkey 157 + }), 158 + resolveProfile(parsed.repo, cache).catch(() => null) 159 + ]); 160 + 161 + if (!record?.value) return null; 162 + 163 + return { 164 + event: record.value as EventData, 165 + rkey: parsed.rkey, 166 + hostDid: parsed.repo, 167 + hostProfile, 168 + status: (rsvp.status?.endsWith('#going') ? 'going' : 'interested') as 169 + | 'going' 170 + | 'interested', 171 + eventUri: rsvp.subject.uri 172 + }; 173 + } catch { 174 + return null; 175 + } 176 + }) 177 + ); 178 + 179 + return results.filter((r) => r !== null); 180 + }
+18 -1
src/routes/[[actor=actor]]/events/+page.server.ts
··· 15 15 } 16 16 17 17 try { 18 + // Try cache first 19 + if (cache) { 20 + const cached = await cache.getJSON<{ 21 + events: (EventData & { rkey: string })[]; 22 + did: string; 23 + hostProfile: CachedProfile | null; 24 + }>('events', did); 25 + if (cached) return cached; 26 + } 27 + 18 28 const [records, hostProfile] = await Promise.all([ 19 29 listRecords({ 20 30 did: did as Did, ··· 42 52 rkey: r.uri.split('/').pop() as string 43 53 })); 44 54 45 - return { 55 + const result = { 46 56 events, 47 57 did, 48 58 hostProfile: hostProfile ?? null 49 59 }; 60 + 61 + // Cache the result 62 + if (cache) { 63 + await cache.putJSON('events', did, result).catch(() => {}); 64 + } 65 + 66 + return result; 50 67 } catch (e) { 51 68 if (e && typeof e === 'object' && 'status' in e) throw e; 52 69 throw error(404, 'Events not found');
+62 -63
src/routes/[[actor=actor]]/events/[rkey]/+page.svelte
··· 217 217 218 218 <!-- Two-column layout: image left, details right --> 219 219 <div 220 - class="grid grid-cols-1 gap-8 md:grid-cols-[14rem_1fr] md:gap-x-10 md:gap-y-6 lg:grid-cols-[16rem_1fr]" 220 + class="grid grid-cols-1 gap-8 md:grid-cols-[14rem_1fr] md:grid-rows-[auto_1fr] md:gap-x-10 md:gap-y-6 lg:grid-cols-[16rem_1fr]" 221 221 > 222 222 <!-- Thumbnail image (left column) --> 223 223 {#if !isBannerOnly} ··· 245 245 {/if} 246 246 247 247 <!-- Right column: event details --> 248 - <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> 248 + <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-2 md:row-start-1"> 249 249 <div class="mb-2 flex items-start justify-between gap-4"> 250 250 <h1 class="text-base-900 dark:text-base-50 text-4xl leading-tight font-bold sm:text-5xl"> 251 251 {eventData.name} ··· 337 337 > 338 338 About 339 339 </p> 340 - <p class="text-base-700 dark:text-base-300 leading-relaxed"> 340 + <p class="text-base-700 dark:text-base-300 leading-relaxed wrap-break-word"> 341 341 {@html descriptionHtml} 342 342 </p> 343 343 </div> 344 344 {/if} 345 345 </div> 346 346 347 - <!-- Hosted By --> 348 - <div class="order-3 md:order-0 md:col-start-1"> 349 - <p 350 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 351 - > 352 - Hosted By 353 - </p> 354 - <a 355 - href={hostUrl} 356 - target={hostProfile?.hasBlento ? undefined : '_blank'} 357 - rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 358 - class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium hover:underline" 359 - > 360 - <FoxAvatar 361 - src={hostProfile?.avatar} 362 - alt={hostProfile?.displayName || hostProfile?.handle || did} 363 - class="size-8 shrink-0" 364 - /> 365 - <span class="truncate text-sm"> 366 - {hostProfile?.displayName || hostProfile?.handle || did} 367 - </span> 368 - </a> 369 - </div> 370 - 371 - {#if eventData.uris && eventData.uris.length > 0} 372 - <!-- Links --> 373 - <div class="order-5 md:order-0 md:col-start-1"> 347 + <!-- Left column: sidebar info --> 348 + <div class="order-3 space-y-6 md:order-0 md:col-start-1"> 349 + <!-- Hosted By --> 350 + <div> 374 351 <p 375 - class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 352 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 376 353 > 377 - Links 354 + Hosted By 378 355 </p> 379 - <div class="space-y-3"> 380 - {#each eventData.uris as link (link.name + link.uri)} 381 - <a 382 - href={link.uri} 383 - target="_blank" 384 - rel="noopener noreferrer" 385 - class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex items-center gap-1.5 text-sm transition-colors" 386 - > 387 - <svg 388 - xmlns="http://www.w3.org/2000/svg" 389 - fill="none" 390 - viewBox="0 0 24 24" 391 - stroke-width="1.5" 392 - stroke="currentColor" 393 - class="size-3.5 shrink-0" 356 + <a 357 + href={hostUrl} 358 + target={hostProfile?.hasBlento ? undefined : '_blank'} 359 + rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 360 + class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium hover:underline" 361 + > 362 + <FoxAvatar 363 + src={hostProfile?.avatar} 364 + alt={hostProfile?.displayName || hostProfile?.handle || did} 365 + class="size-8 shrink-0" 366 + /> 367 + <span class="truncate text-sm"> 368 + {hostProfile?.displayName || hostProfile?.handle || did} 369 + </span> 370 + </a> 371 + </div> 372 + 373 + {#if eventData.uris && eventData.uris.length > 0} 374 + <!-- Links --> 375 + <div> 376 + <p 377 + class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 378 + > 379 + Links 380 + </p> 381 + <div class="space-y-3"> 382 + {#each eventData.uris as link (link.name + link.uri)} 383 + <a 384 + href={link.uri} 385 + target="_blank" 386 + rel="noopener noreferrer" 387 + class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex items-center gap-1.5 text-sm transition-colors" 394 388 > 395 - <path 396 - stroke-linecap="round" 397 - stroke-linejoin="round" 398 - d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 399 - /> 400 - </svg> 401 - <span class="truncate">{link.name || link.uri.replace(/^https?:\/\//, '')}</span> 402 - </a> 403 - {/each} 389 + <svg 390 + xmlns="http://www.w3.org/2000/svg" 391 + fill="none" 392 + viewBox="0 0 24 24" 393 + stroke-width="1.5" 394 + stroke="currentColor" 395 + class="size-3.5 shrink-0" 396 + > 397 + <path 398 + stroke-linecap="round" 399 + stroke-linejoin="round" 400 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 401 + /> 402 + </svg> 403 + <span class="truncate">{link.name || link.uri.replace(/^https?:\/\//, '')}</span> 404 + </a> 405 + {/each} 406 + </div> 404 407 </div> 405 - </div> 406 - {/if} 408 + {/if} 407 409 408 - <!-- Add to Calendar --> 409 - <div class="order-5 md:order-0 md:col-start-1"> 410 + <!-- Add to Calendar --> 410 411 <button 411 412 onclick={downloadIcs} 412 413 class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex cursor-pointer items-center gap-2 text-sm font-medium transition-colors" ··· 427 428 </svg> 428 429 Add to Calendar 429 430 </button> 430 - </div> 431 431 432 - <!-- Attendees --> 433 - <div class="order-5 md:order-0 md:col-start-1"> 432 + <!-- Attendees --> 434 433 <EventAttendees bind:this={attendeesRef} {eventUri} /> 435 434 </div> 436 435
+1 -1
src/routes/[[actor=actor]]/events/[rkey]/EventAttendees.svelte
··· 166 166 </div> 167 167 {/if} 168 168 169 - <Modal bind:open={modalOpen} closeButton onOpenAutoFocus={(e) => e.preventDefault()} class="p-0"> 169 + <Modal bind:open={modalOpen} closeButton onOpenAutoFocus={(e: Event) => e.preventDefault()} class="p-0"> 170 170 <p class="text-base-900 dark:text-base-50 px-4 pt-4 text-lg font-semibold"> 171 171 {modalTitle} 172 172 <span class="text-base-500 dark:text-base-400 text-sm font-normal">
+12
src/routes/[[actor=actor]]/events/[rkey]/EventRsvp.svelte
··· 94 94 rsvpStatus = status; 95 95 rsvpRkey = key; 96 96 onrsvp?.(status); 97 + refreshRsvpCache(); 97 98 } 98 99 } catch (e) { 99 100 console.error('Failed to submit RSVP:', e); ··· 113 114 rsvpStatus = null; 114 115 rsvpRkey = null; 115 116 oncancel?.(); 117 + refreshRsvpCache(); 116 118 } catch (e) { 117 119 console.error('Failed to cancel RSVP:', e); 118 120 } finally { 119 121 rsvpSubmitting = false; 122 + } 123 + } 124 + 125 + function refreshRsvpCache() { 126 + const handle = 127 + user.profile?.handle && user.profile.handle !== 'handle.invalid' 128 + ? user.profile.handle 129 + : user.did; 130 + if (handle) { 131 + fetch(`/${handle}/rsvp/api/refresh`).catch(() => {}); 120 132 } 121 133 } 122 134 </script>
+4 -12
src/routes/[[actor=actor]]/events/[rkey]/edit/+page.svelte
··· 564 564 user.profile?.handle && user.profile.handle !== 'handle.invalid' 565 565 ? user.profile.handle 566 566 : user.did; 567 + fetch(`/${handle}/events/api/refresh`).catch(() => {}); 567 568 goto(`/${handle}/events/${rkey}`); 568 569 } else { 569 570 error = `Failed to ${isNew ? 'create' : 'save'} event. Please try again.`; ··· 592 593 user.profile?.handle && user.profile.handle !== 'handle.invalid' 593 594 ? user.profile.handle 594 595 : user.did; 596 + fetch(`/${handle}/events/api/refresh`).catch(() => {}); 595 597 goto(`/${handle}/events`); 596 598 } catch (e) { 597 599 console.error('Failed to delete event:', e); ··· 1165 1167 > 1166 1168 Cancel 1167 1169 </Button> 1168 - <Button 1169 - size="sm" 1170 - onclick={handleDelete} 1171 - disabled={deleting} 1172 - variant="red" 1173 - > 1170 + <Button size="sm" onclick={handleDelete} disabled={deleting} variant="red"> 1174 1171 {deleting ? 'Deleting...' : 'Delete'} 1175 1172 </Button> 1176 1173 </div> 1177 1174 {:else} 1178 - <Button 1179 - variant="red" 1180 - onclick={() => (showDeleteConfirm = true)} 1181 - > 1182 - Delete event 1183 - </Button> 1175 + <Button variant="red" onclick={() => (showDeleteConfirm = true)}>Delete event</Button> 1184 1176 {/if} 1185 1177 </div> 1186 1178 {/if}
+47
src/routes/[[actor=actor]]/events/api/refresh/+server.ts
··· 1 + import { createCache } from '$lib/cache'; 2 + import { error, json } from '@sveltejs/kit'; 3 + import { getActor } from '$lib/actor'; 4 + import { listRecords } from '$lib/atproto/methods.js'; 5 + import type { EventData } from '$lib/cards/social/EventCard'; 6 + import type { Did } from '@atcute/lexicons'; 7 + 8 + export async function GET({ params, platform, request }) { 9 + const cache = createCache(platform); 10 + if (!cache) return json('no cache'); 11 + 12 + const did = await getActor({ request, paramActor: params.actor, platform, blockBoth: false }); 13 + 14 + if (!did) { 15 + throw error(404, 'Not found'); 16 + } 17 + 18 + // Delete stale caches 19 + await Promise.all([cache.delete('events', did), cache.delete('ical', `${did}:calendar`)]).catch( 20 + () => {} 21 + ); 22 + 23 + // Re-fetch and cache 24 + const [records, hostProfile] = await Promise.all([ 25 + listRecords({ 26 + did: did as Did, 27 + collection: 'community.lexicon.calendar.event', 28 + limit: 100 29 + }), 30 + cache.getProfile(did as Did).catch(() => null) 31 + ]); 32 + 33 + const events = records.map((r) => ({ 34 + ...(r.value as EventData), 35 + rkey: r.uri.split('/').pop() as string 36 + })); 37 + 38 + const result = { 39 + events, 40 + did, 41 + hostProfile: hostProfile ?? null 42 + }; 43 + 44 + await cache.putJSON('events', did, result).catch(() => {}); 45 + 46 + return json(result); 47 + }
+1 -1
src/routes/[[actor=actor]]/events/calendar/+server.ts
··· 4 4 import { createCache } from '$lib/cache'; 5 5 import type { Did } from '@atcute/lexicons'; 6 6 import { getActor } from '$lib/actor'; 7 - import { generateICalFeed, type ICalEvent } from '$lib/ical'; 7 + import { generateICalFeed, type ICalAttendee, type ICalEvent } from '$lib/ical'; 8 8 import { fetchEventRsvps, getProfileUrl, resolveProfile } from '$lib/events/fetch-attendees'; 9 9 10 10 export async function GET({ params, platform, request }) {
-135
src/routes/[[actor=actor]]/events/rsvp-calendar/+server.ts
··· 1 - import { error } from '@sveltejs/kit'; 2 - import type { EventData } from '$lib/cards/social/EventCard'; 3 - import { getCDNImageBlobUrl, getRecord, listRecords, parseUri } from '$lib/atproto/methods.js'; 4 - import { createCache } from '$lib/cache'; 5 - import type { Did } from '@atcute/lexicons'; 6 - import { getActor } from '$lib/actor'; 7 - import { generateICalFeed, type ICalAttendee, type ICalEvent } from '$lib/ical'; 8 - import { fetchEventRsvps, getProfileUrl, resolveProfile } from '$lib/events/fetch-attendees'; 9 - 10 - interface RsvpRecord { 11 - $type: string; 12 - status: string; 13 - subject: { uri: string; cid?: string }; 14 - createdAt: string; 15 - } 16 - 17 - export async function GET({ params, platform, request }) { 18 - const cache = createCache(platform); 19 - 20 - const did = await getActor({ request, paramActor: params.actor, platform }); 21 - 22 - if (!did) { 23 - throw error(404, 'Not found'); 24 - } 25 - 26 - try { 27 - // Check cache first 28 - const cacheKey = `${did}:rsvp-calendar`; 29 - if (cache) { 30 - const cached = await cache.get('ical', cacheKey); 31 - if (cached) { 32 - return new Response(cached, { 33 - headers: { 34 - 'Content-Type': 'text/calendar; charset=utf-8', 35 - 'Cache-Control': 'public, max-age=3600' 36 - } 37 - }); 38 - } 39 - } 40 - 41 - const [rsvpRecords, hostProfile] = await Promise.all([ 42 - listRecords({ 43 - did: did as Did, 44 - collection: 'community.lexicon.calendar.rsvp', 45 - limit: 100 46 - }), 47 - resolveProfile(did, cache) 48 - ]); 49 - 50 - // Filter to only going and interested RSVPs 51 - const activeRsvps = rsvpRecords.filter((r) => { 52 - const rsvp = r.value as unknown as RsvpRecord; 53 - return rsvp.status?.endsWith('#going') || rsvp.status?.endsWith('#interested'); 54 - }); 55 - 56 - // Fetch each referenced event in parallel 57 - const eventResults = await Promise.all( 58 - activeRsvps.map(async (r) => { 59 - const rsvp = r.value as unknown as RsvpRecord; 60 - const parsed = parseUri(rsvp.subject.uri); 61 - if (!parsed?.rkey || !parsed?.repo) return null; 62 - 63 - try { 64 - const [record, organizerProfile] = await Promise.all([ 65 - getRecord({ 66 - did: parsed.repo as Did, 67 - collection: 'community.lexicon.calendar.event', 68 - rkey: parsed.rkey 69 - }), 70 - resolveProfile(parsed.repo, cache).catch(() => null) 71 - ]); 72 - if (!record?.value) return null; 73 - const eventData = record.value as EventData; 74 - const actor = organizerProfile?.handle || parsed.repo; 75 - const thumbnail = eventData.media?.find((m) => m.role === 'thumbnail'); 76 - const imageUrl = thumbnail?.content 77 - ? getCDNImageBlobUrl({ 78 - did: parsed.repo, 79 - blob: thumbnail.content, 80 - type: 'jpeg' 81 - }) 82 - : undefined; 83 - 84 - // Fetch RSVPs and resolve handles 85 - const rsvpMap = await fetchEventRsvps(rsvp.subject.uri).catch( 86 - () => new Map<string, 'going' | 'interested'>() 87 - ); 88 - const attendees: ICalAttendee[] = []; 89 - await Promise.all( 90 - Array.from(rsvpMap.entries()).map(async ([attendeeDid, status]) => { 91 - const profile = await resolveProfile(attendeeDid, cache).catch(() => null); 92 - attendees.push({ 93 - name: profile?.handle || attendeeDid, 94 - status, 95 - url: getProfileUrl(attendeeDid, profile) 96 - }); 97 - }) 98 - ); 99 - 100 - return { 101 - eventData, 102 - uid: rsvp.subject.uri, 103 - url: `https://blento.app/${actor}/events/${parsed.rkey}`, 104 - organizer: actor, 105 - imageUrl, 106 - attendees 107 - } satisfies ICalEvent; 108 - } catch { 109 - return null; 110 - } 111 - }) 112 - ); 113 - 114 - const events: ICalEvent[] = eventResults.filter((e) => e !== null); 115 - 116 - const actor = hostProfile?.handle || did; 117 - const calendarName = `${hostProfile?.displayName || actor}'s RSVP Events`; 118 - const ical = generateICalFeed(events, calendarName); 119 - 120 - // Store in cache 121 - if (cache) { 122 - await cache.put('ical', cacheKey, ical).catch(() => {}); 123 - } 124 - 125 - return new Response(ical, { 126 - headers: { 127 - 'Content-Type': 'text/calendar; charset=utf-8', 128 - 'Cache-Control': 'public, max-age=3600' 129 - } 130 - }); 131 - } catch (e) { 132 - if (e && typeof e === 'object' && 'status' in e) throw e; 133 - throw error(500, 'Failed to generate calendar'); 134 - } 135 - }
+28
src/routes/[[actor=actor]]/rsvp/+layout.server.ts
··· 1 + import { getRecord } from '$lib/atproto/methods.js'; 2 + import type { Did } from '@atcute/lexicons'; 3 + import { getActor } from '$lib/actor.js'; 4 + 5 + export async function load({ params, platform, request }) { 6 + const did = await getActor({ request, paramActor: params.actor, platform }); 7 + 8 + if (!did) return { accentColor: undefined, baseColor: undefined }; 9 + 10 + try { 11 + const publication = await getRecord({ 12 + did: did as Did, 13 + collection: 'site.standard.publication', 14 + rkey: 'blento.self' 15 + }); 16 + 17 + const preferences = publication?.value?.preferences as 18 + | { accentColor?: string; baseColor?: string } 19 + | undefined; 20 + 21 + return { 22 + accentColor: preferences?.accentColor, 23 + baseColor: preferences?.baseColor 24 + }; 25 + } catch { 26 + return { accentColor: undefined, baseColor: undefined }; 27 + } 28 + }
+9
src/routes/[[actor=actor]]/rsvp/+layout.svelte
··· 1 + <script lang="ts"> 2 + import ThemeScript from '$lib/website/ThemeScript.svelte'; 3 + 4 + let { data, children } = $props(); 5 + </script> 6 + 7 + <ThemeScript accentColor={data.accentColor} baseColor={data.baseColor} /> 8 + 9 + {@render children()}
+48
src/routes/[[actor=actor]]/rsvp/+page.server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { createCache } from '$lib/cache'; 3 + import type { CachedProfile } from '$lib/cache'; 4 + import { getActor } from '$lib/actor.js'; 5 + import { fetchUserRsvps, resolveProfile, type ResolvedRsvp } from '$lib/events/fetch-attendees'; 6 + 7 + export async function load({ params, platform, request }) { 8 + const cache = createCache(platform); 9 + 10 + const did = await getActor({ request, paramActor: params.actor, platform }); 11 + 12 + if (!did) { 13 + throw error(404, 'RSVPs not found'); 14 + } 15 + 16 + try { 17 + // Try cache first 18 + if (cache) { 19 + const cached = await cache.getJSON<{ 20 + rsvps: ResolvedRsvp[]; 21 + did: string; 22 + userProfile: CachedProfile | null; 23 + }>('rsvps', did); 24 + if (cached) return cached; 25 + } 26 + 27 + const [rsvps, userProfile] = await Promise.all([ 28 + fetchUserRsvps(did, cache), 29 + resolveProfile(did, cache) 30 + ]); 31 + 32 + const result = { 33 + rsvps, 34 + did, 35 + userProfile: userProfile ?? null 36 + }; 37 + 38 + // Cache the result 39 + if (cache) { 40 + await cache.putJSON('rsvps', did, result).catch(() => {}); 41 + } 42 + 43 + return result; 44 + } catch (e) { 45 + if (e && typeof e === 'object' && 'status' in e) throw e; 46 + throw error(404, 'RSVPs not found'); 47 + } 48 + }
+200
src/routes/[[actor=actor]]/rsvp/+page.svelte
··· 1 + <script lang="ts"> 2 + import type { EventData } from '$lib/cards/social/EventCard'; 3 + import { getCDNImageBlobUrl } from '$lib/atproto'; 4 + import { Avatar as FoxAvatar, Badge, Button, toast } from '@foxui/core'; 5 + import { page } from '$app/state'; 6 + import Avatar from 'svelte-boring-avatars'; 7 + import type { CachedProfile } from '$lib/cache'; 8 + 9 + let { data } = $props(); 10 + 11 + let rsvps: Array<{ 12 + event: EventData; 13 + rkey: string; 14 + hostDid: string; 15 + hostProfile: CachedProfile | null; 16 + status: string; 17 + eventUri: string; 18 + }> = $derived(data.rsvps); 19 + let did: string = $derived(data.did); 20 + let userProfile = $derived(data.userProfile); 21 + 22 + let userName = $derived(userProfile?.displayName || userProfile?.handle || did); 23 + 24 + function formatDate(dateStr: string): string { 25 + const date = new Date(dateStr); 26 + const options: Intl.DateTimeFormatOptions = { 27 + weekday: 'short', 28 + month: 'short', 29 + day: 'numeric' 30 + }; 31 + if (date.getFullYear() !== new Date().getFullYear()) { 32 + options.year = 'numeric'; 33 + } 34 + return date.toLocaleDateString('en-US', options); 35 + } 36 + 37 + function formatTime(dateStr: string): string { 38 + return new Date(dateStr).toLocaleTimeString('en-US', { 39 + hour: 'numeric', 40 + minute: '2-digit' 41 + }); 42 + } 43 + 44 + function getModeLabel(mode: string): string { 45 + if (mode.includes('virtual')) return 'Virtual'; 46 + if (mode.includes('hybrid')) return 'Hybrid'; 47 + if (mode.includes('inperson')) return 'In-Person'; 48 + return 'Event'; 49 + } 50 + 51 + function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' { 52 + if (mode.includes('virtual')) return 'cyan'; 53 + if (mode.includes('hybrid')) return 'purple'; 54 + if (mode.includes('inperson')) return 'amber'; 55 + return 'secondary'; 56 + } 57 + 58 + function getThumbnail(event: EventData, hostDid: string): { url: string; alt: string } | null { 59 + if (!event.media || event.media.length === 0) return null; 60 + const media = event.media.find((m) => m.role === 'thumbnail'); 61 + if (!media?.content) return null; 62 + const url = getCDNImageBlobUrl({ did: hostDid, blob: media.content, type: 'jpeg' }); 63 + if (!url) return null; 64 + return { url, alt: media.alt || event.name }; 65 + } 66 + 67 + function getStatusLabel(status: string): string { 68 + return status === 'going' ? 'Going' : 'Interested'; 69 + } 70 + 71 + function getStatusColor(status: string): 'green' | 'blue' { 72 + return status === 'going' ? 'green' : 'blue'; 73 + } 74 + 75 + let showPast: boolean = $state(false); 76 + let now = $derived(new Date()); 77 + let filteredRsvps = $derived( 78 + rsvps.filter((r) => { 79 + const endOrStart = r.event.endsAt || r.event.startsAt; 80 + const eventDate = new Date(endOrStart); 81 + return showPast ? eventDate < now : eventDate >= now; 82 + }) 83 + ); 84 + </script> 85 + 86 + <svelte:head> 87 + <title>{userName} - RSVPs</title> 88 + <meta name="description" content="Events {userName} is attending" /> 89 + <meta property="og:title" content="{userName} - RSVPs" /> 90 + <meta property="og:description" content="Events {userName} is attending" /> 91 + <meta name="twitter:card" content="summary" /> 92 + <meta name="twitter:title" content="{userName} - RSVPs" /> 93 + <meta name="twitter:description" content="Events {userName} is attending" /> 94 + </svelte:head> 95 + 96 + <div class="min-h-screen px-6 py-12 sm:py-12"> 97 + <div class="mx-auto max-w-3xl"> 98 + <!-- Header --> 99 + <div class="mb-8 flex items-start justify-between"> 100 + <div> 101 + <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl"> 102 + {showPast ? 'Past' : 'Upcoming'} RSVPs 103 + </h1> 104 + <div class="mt-4 flex items-center gap-2"> 105 + <FoxAvatar src={userProfile?.avatar} alt={userName} class="size-5 shrink-0" /> 106 + <span class="text-base-900 dark:text-base-100 text-sm font-medium">{userName}</span> 107 + </div> 108 + </div> 109 + <Button 110 + variant="secondary" 111 + onclick={async () => { 112 + const calendarUrl = `${page.url.origin}${page.url.pathname.replace(/\/$/, '')}/calendar`; 113 + await navigator.clipboard.writeText(calendarUrl); 114 + toast.success('Subscription link copied to clipboard'); 115 + }}>Subscribe</Button 116 + > 117 + </div> 118 + 119 + <!-- Toggle --> 120 + <div class="mb-6 flex gap-1"> 121 + <button 122 + class="rounded-xl px-3 py-1.5 text-sm font-medium transition-colors {!showPast 123 + ? 'bg-base-200 dark:bg-base-800 text-base-900 dark:text-base-50' 124 + : 'text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 cursor-pointer'}" 125 + onclick={() => (showPast = false)}>Upcoming</button 126 + > 127 + <button 128 + class="rounded-xl px-3 py-1.5 text-sm font-medium transition-colors {showPast 129 + ? 'bg-base-200 dark:bg-base-800 text-base-900 dark:text-base-50' 130 + : 'text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 cursor-pointer'}" 131 + onclick={() => (showPast = true)}>Past</button 132 + > 133 + </div> 134 + 135 + {#if filteredRsvps.length === 0} 136 + <p class="text-base-500 dark:text-base-400 py-12 text-center"> 137 + No {showPast ? 'past' : 'upcoming'} RSVPs. 138 + </p> 139 + {:else} 140 + <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> 141 + {#each filteredRsvps as rsvp (rsvp.eventUri)} 142 + {@const thumbnail = getThumbnail(rsvp.event, rsvp.hostDid)} 143 + {@const hostHandle = rsvp.hostProfile?.handle || rsvp.hostDid} 144 + <a 145 + href="/{hostHandle}/events/{rsvp.rkey}" 146 + class="border-base-200 dark:border-base-800 hover:border-base-300 dark:hover:border-base-700 group bg-base-100 dark:bg-base-950 block overflow-hidden rounded-2xl border transition-colors" 147 + > 148 + <!-- Thumbnail --> 149 + <div class="p-4"> 150 + {#if thumbnail} 151 + <img 152 + src={thumbnail.url} 153 + alt={thumbnail.alt} 154 + class="aspect-square w-full rounded-2xl object-cover" 155 + /> 156 + {:else} 157 + <div 158 + class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full" 159 + > 160 + <Avatar 161 + size={400} 162 + name={rsvp.rkey} 163 + variant="marble" 164 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 165 + square 166 + /> 167 + </div> 168 + {/if} 169 + </div> 170 + 171 + <!-- Content --> 172 + <div class="p-4"> 173 + <h2 174 + class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mb-1 leading-snug font-semibold" 175 + > 176 + {rsvp.event.name} 177 + </h2> 178 + 179 + <p class="text-base-500 dark:text-base-400 mb-2 text-sm"> 180 + {formatDate(rsvp.event.startsAt)} &middot; {formatTime(rsvp.event.startsAt)} 181 + </p> 182 + 183 + <div class="flex flex-wrap items-center gap-2"> 184 + {#if rsvp.event.mode} 185 + <Badge size="sm" variant={getModeColor(rsvp.event.mode)} 186 + >{getModeLabel(rsvp.event.mode)}</Badge 187 + > 188 + {/if} 189 + 190 + <Badge size="sm" variant={getStatusColor(rsvp.status)} 191 + >{getStatusLabel(rsvp.status)}</Badge 192 + > 193 + </div> 194 + </div> 195 + </a> 196 + {/each} 197 + </div> 198 + {/if} 199 + </div> 200 + </div>
+37
src/routes/[[actor=actor]]/rsvp/api/refresh/+server.ts
··· 1 + import { createCache } from '$lib/cache'; 2 + import { error, json } from '@sveltejs/kit'; 3 + import { getActor } from '$lib/actor'; 4 + import { fetchUserRsvps, resolveProfile } from '$lib/events/fetch-attendees'; 5 + 6 + export async function GET({ params, platform, request }) { 7 + const cache = createCache(platform); 8 + if (!cache) return json('no cache'); 9 + 10 + const did = await getActor({ request, paramActor: params.actor, platform, blockBoth: false }); 11 + 12 + if (!did) { 13 + throw error(404, 'Not found'); 14 + } 15 + 16 + // Delete stale caches 17 + await Promise.all([ 18 + cache.delete('rsvps', did), 19 + cache.delete('ical', `${did}:rsvp-calendar`) 20 + ]).catch(() => {}); 21 + 22 + // Re-fetch and cache 23 + const [rsvps, userProfile] = await Promise.all([ 24 + fetchUserRsvps(did, cache), 25 + resolveProfile(did, cache) 26 + ]); 27 + 28 + const result = { 29 + rsvps, 30 + did, 31 + userProfile: userProfile ?? null 32 + }; 33 + 34 + await cache.putJSON('rsvps', did, result).catch(() => {}); 35 + 36 + return json(result); 37 + }
+106
src/routes/[[actor=actor]]/rsvp/calendar/+server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { getCDNImageBlobUrl } from '$lib/atproto/methods.js'; 3 + import { createCache } from '$lib/cache'; 4 + import { getActor } from '$lib/actor'; 5 + import { generateICalFeed, type ICalAttendee, type ICalEvent } from '$lib/ical'; 6 + import { 7 + fetchEventRsvps, 8 + fetchUserRsvps, 9 + getProfileUrl, 10 + resolveProfile 11 + } from '$lib/events/fetch-attendees'; 12 + 13 + export async function GET({ params, platform, request }) { 14 + const cache = createCache(platform); 15 + 16 + const did = await getActor({ request, paramActor: params.actor, platform }); 17 + 18 + if (!did) { 19 + throw error(404, 'Not found'); 20 + } 21 + 22 + try { 23 + // Check cache first 24 + const cacheKey = `${did}:rsvp-calendar`; 25 + if (cache) { 26 + const cached = await cache.get('ical', cacheKey); 27 + if (cached) { 28 + return new Response(cached, { 29 + headers: { 30 + 'Content-Type': 'text/calendar; charset=utf-8', 31 + 'Cache-Control': 'public, max-age=3600' 32 + } 33 + }); 34 + } 35 + } 36 + 37 + const [rsvps, userProfile] = await Promise.all([ 38 + fetchUserRsvps(did, cache), 39 + resolveProfile(did, cache) 40 + ]); 41 + 42 + // Enrich each RSVP with attendees and image URLs for the iCal feed 43 + const events: ICalEvent[] = ( 44 + await Promise.all( 45 + rsvps.map(async (rsvp) => { 46 + try { 47 + const actor = rsvp.hostProfile?.handle || rsvp.hostDid; 48 + const thumbnail = rsvp.event.media?.find((m) => m.role === 'thumbnail'); 49 + const imageUrl = thumbnail?.content 50 + ? getCDNImageBlobUrl({ 51 + did: rsvp.hostDid, 52 + blob: thumbnail.content, 53 + type: 'jpeg' 54 + }) 55 + : undefined; 56 + 57 + const rsvpMap = await fetchEventRsvps(rsvp.eventUri).catch( 58 + () => new Map<string, 'going' | 'interested'>() 59 + ); 60 + const attendees: ICalAttendee[] = []; 61 + await Promise.all( 62 + Array.from(rsvpMap.entries()).map(async ([attendeeDid, status]) => { 63 + const profile = await resolveProfile(attendeeDid, cache).catch(() => null); 64 + attendees.push({ 65 + name: profile?.handle || attendeeDid, 66 + status, 67 + url: getProfileUrl(attendeeDid, profile) 68 + }); 69 + }) 70 + ); 71 + 72 + return { 73 + eventData: rsvp.event, 74 + uid: rsvp.eventUri, 75 + url: `https://blento.app/${actor}/events/${rsvp.rkey}`, 76 + organizer: actor, 77 + imageUrl, 78 + attendees 79 + } satisfies ICalEvent; 80 + } catch { 81 + return null; 82 + } 83 + }) 84 + ) 85 + ).filter((e) => e !== null); 86 + 87 + const actor = userProfile?.handle || did; 88 + const calendarName = `${userProfile?.displayName || actor}'s RSVP Events`; 89 + const ical = generateICalFeed(events, calendarName); 90 + 91 + // Store in cache 92 + if (cache) { 93 + await cache.put('ical', cacheKey, ical).catch(() => {}); 94 + } 95 + 96 + return new Response(ical, { 97 + headers: { 98 + 'Content-Type': 'text/calendar; charset=utf-8', 99 + 'Cache-Control': 'public, max-age=3600' 100 + } 101 + }); 102 + } catch (e) { 103 + if (e && typeof e === 'object' && 'status' in e) throw e; 104 + throw error(500, 'Failed to generate calendar'); 105 + } 106 + }