your personal website on atproto - mirror blento.app

Merge pull request #196 from flo-bit/events-pages

Events pages

authored by

Florian and committed by
GitHub
79cf9964 0fdc4eea

+665 -3
+1
package.json
··· 84 84 "qr-code-styling": "^1.8.6", 85 85 "react-grid-layout": "^2.2.2", 86 86 "simple-icons": "^16.6.0", 87 + "svelte-boring-avatars": "^1.2.6", 87 88 "svelte-sonner": "^1.0.7", 88 89 "tailwind-merge": "^3.4.0", 89 90 "tailwind-variants": "^3.2.2",
+8
pnpm-lock.yaml
··· 140 140 simple-icons: 141 141 specifier: ^16.6.0 142 142 version: 16.6.0 143 + svelte-boring-avatars: 144 + specifier: ^1.2.6 145 + version: 1.2.6 143 146 svelte-sonner: 144 147 specifier: ^1.0.7 145 148 version: 1.0.7(svelte@5.48.0) ··· 2791 2794 supports-color@7.2.0: 2792 2795 resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} 2793 2796 engines: {node: '>=8'} 2797 + 2798 + svelte-boring-avatars@1.2.6: 2799 + resolution: {integrity: sha512-8+Z1DhsMUVI/V/5ik00Arw0PgbJcMdhTXq3YGqccBc5bYFeceCtMEMB0aWGhi8xFV+0aqZbWvS89Hcj16LlfHA==, tarball: https://registry.npmjs.org/svelte-boring-avatars/-/svelte-boring-avatars-1.2.6.tgz} 2794 2800 2795 2801 svelte-check@4.3.5: 2796 2802 resolution: {integrity: sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==} ··· 5609 5615 supports-color@7.2.0: 5610 5616 dependencies: 5611 5617 has-flag: 4.0.0 5618 + 5619 + svelte-boring-avatars@1.2.6: {} 5612 5620 5613 5621 svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.48.0)(typescript@5.9.3): 5614 5622 dependencies:
+5 -2
src/lib/atproto/methods.ts
··· 122 122 123 123 const response = await getDetailedProfile(data); 124 124 125 + const avatar = blentoProfile?.value?.icon 126 + ? getCDNImageBlobUrl({ did: data?.did, blob: blentoProfile?.value?.icon }) 127 + : response?.avatar; 128 + 125 129 return { 126 130 did: data.did, 127 131 handle: response?.handle, 128 132 displayName: blentoProfile?.value?.name || response?.displayName || response?.handle, 129 - avatar: (getCDNImageBlobUrl({ did: data?.did, blob: blentoProfile?.value?.icon }) || 130 - response?.avatar) as `${string}:${string}`, 133 + avatar: avatar as `${string}:${string}`, 131 134 hasBlento: Boolean(blentoProfile.value), 132 135 url: blentoProfile?.value?.url as string | undefined 133 136 };
+2 -1
src/lib/atproto/settings.ts
··· 23 23 'app.bsky.feed.post?action=create', 24 24 'site.standard.publication', 25 25 'site.standard.document', 26 - 'xyz.statusphere.status' 26 + 'xyz.statusphere.status', 27 + 'community.lexicon.calendar.rsvp' 27 28 ], 28 29 29 30 // what types of authenticated proxied requests you can make to services
+30
src/lib/cache.ts
··· 1 1 import type { ActorIdentifier, Did } from '@atcute/lexicons'; 2 2 import { isDid } from '@atcute/lexicons/syntax'; 3 3 import type { KVNamespace } from '@cloudflare/workers-types'; 4 + import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 4 5 5 6 /** TTL in seconds for each cache namespace */ 6 7 const NAMESPACE_TTL = { ··· 10 11 'gh-contrib': 60 * 60 * 12, // 12 hours 11 12 lastfm: 60 * 60, // 1 hour (default, overridable per-put) 12 13 npmx: 60 * 60 * 12, // 12 hours 14 + profile: 60 * 60 * 24, // 24 hours 13 15 meta: 0 // no auto-expiry 14 16 } as const; 15 17 ··· 89 91 async resolveHandle(did: Did): Promise<string | null> { 90 92 return this.get('identity', `d:${did}`); 91 93 } 94 + 95 + // === Profile cache (did → profile data) === 96 + async getProfile(did: Did): Promise<CachedProfile> { 97 + const cached = await this.getJSON<CachedProfile>('profile', did); 98 + if (cached) return cached; 99 + 100 + const profile = await getBlentoOrBskyProfile({ did }); 101 + const data: CachedProfile = { 102 + did: profile.did as string, 103 + handle: profile.handle as string, 104 + displayName: profile.displayName as string | undefined, 105 + avatar: profile.avatar as string | undefined, 106 + hasBlento: profile.hasBlento, 107 + url: profile.url 108 + }; 109 + 110 + await this.putJSON('profile', did, data); 111 + return data; 112 + } 92 113 } 114 + 115 + export type CachedProfile = { 116 + did: string; 117 + handle: string; 118 + displayName?: string; 119 + avatar?: string; 120 + hasBlento: boolean; 121 + url?: string; 122 + }; 93 123 94 124 export function createCache(platform?: App.Platform): CacheService | undefined { 95 125 const kv = platform?.env?.USER_DATA_CACHE;
+5
src/lib/cards/social/EventCard/index.ts
··· 13 13 endsAt?: string; 14 14 description?: string; 15 15 locations?: Array<{ 16 + $type: string; 16 17 address?: { 17 18 locality?: string; 18 19 region?: string; ··· 32 33 width: number; 33 34 height: number; 34 35 }; 36 + }>; 37 + uris?: Array<{ 38 + uri: string; 39 + name?: string; 35 40 }>; 36 41 countGoing?: number; 37 42 countInterested?: number;
+65
src/routes/[[actor=actor]]/e/[rkey]/+page.server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import type { EventData } from '$lib/cards/social/EventCard'; 3 + import { getBlentoOrBskyProfile, getRecord, resolveHandle } from '$lib/atproto/methods.js'; 4 + import { isHandle } from '@atcute/lexicons/syntax'; 5 + import { createCache, type CachedProfile } from '$lib/cache'; 6 + import type { Did } from '@atcute/lexicons'; 7 + 8 + export async function load({ params, platform }) { 9 + const { rkey } = params; 10 + const did = isHandle(params.actor) ? await resolveHandle({ handle: params.actor }) : params.actor; 11 + 12 + if (!did || !rkey) { 13 + throw error(404, 'Event not found'); 14 + } 15 + 16 + try { 17 + const cache = createCache(platform); 18 + 19 + console.log( 20 + `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}` 21 + ); 22 + 23 + const [eventResponse, hostProfile, eventRecord] = await Promise.all([ 24 + fetch( 25 + `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}` 26 + ), 27 + cache 28 + ? cache.getProfile(did as Did).catch(() => null) 29 + : getBlentoOrBskyProfile({ did: did as Did }) 30 + .then( 31 + (p): CachedProfile => ({ 32 + did: p.did as string, 33 + handle: p.handle as string, 34 + displayName: p.displayName as string | undefined, 35 + avatar: p.avatar as string | undefined, 36 + hasBlento: p.hasBlento, 37 + url: p.url 38 + }) 39 + ) 40 + .catch(() => null), 41 + getRecord({ 42 + did: did as Did, 43 + collection: 'community.lexicon.calendar.event', 44 + rkey 45 + }).catch(() => null) 46 + ]); 47 + 48 + if (!eventResponse.ok) { 49 + throw error(404, 'Event not found'); 50 + } 51 + 52 + const eventData: EventData = await eventResponse.json(); 53 + 54 + return { 55 + eventData, 56 + did, 57 + rkey, 58 + hostProfile: hostProfile ?? null, 59 + eventCid: (eventRecord?.cid as string) ?? null 60 + }; 61 + } catch (e) { 62 + if (e && typeof e === 'object' && 'status' in e) throw e; 63 + throw error(404, 'Event not found'); 64 + } 65 + }
+318
src/routes/[[actor=actor]]/e/[rkey]/+page.svelte
··· 1 + <script lang="ts"> 2 + import type { EventData } from '$lib/cards/social/EventCard'; 3 + import { Avatar as FoxAvatar, Badge } from '@foxui/core'; 4 + import Avatar from 'svelte-boring-avatars'; 5 + import EventRsvp from './EventRsvp.svelte'; 6 + 7 + let { data } = $props(); 8 + 9 + let eventData: EventData = $derived(data.eventData); 10 + let did: string = $derived(data.did); 11 + let rkey: string = $derived(data.rkey); 12 + let hostProfile = $derived(data.hostProfile); 13 + 14 + let hostUrl = $derived( 15 + hostProfile?.hasBlento 16 + ? `/${hostProfile.handle}` 17 + : `https://bsky.app/profile/${hostProfile?.handle || did}` 18 + ); 19 + 20 + let startDate = $derived(new Date(eventData.startsAt)); 21 + let endDate = $derived(eventData.endsAt ? new Date(eventData.endsAt) : null); 22 + 23 + function formatMonth(date: Date): string { 24 + return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); 25 + } 26 + 27 + function formatDay(date: Date): number { 28 + return date.getDate(); 29 + } 30 + 31 + function formatWeekday(date: Date): string { 32 + return date.toLocaleDateString('en-US', { weekday: 'long' }); 33 + } 34 + 35 + function formatFullDate(date: Date): string { 36 + const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' }; 37 + if (date.getFullYear() !== new Date().getFullYear()) { 38 + options.year = 'numeric'; 39 + } 40 + return date.toLocaleDateString('en-US', options); 41 + } 42 + 43 + function formatTime(date: Date): string { 44 + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); 45 + } 46 + 47 + function getModeLabel(mode: string): string { 48 + if (mode.includes('virtual')) return 'Virtual'; 49 + if (mode.includes('hybrid')) return 'Hybrid'; 50 + if (mode.includes('inperson')) return 'In-Person'; 51 + return 'Event'; 52 + } 53 + 54 + function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' { 55 + if (mode.includes('virtual')) return 'cyan'; 56 + if (mode.includes('hybrid')) return 'purple'; 57 + if (mode.includes('inperson')) return 'amber'; 58 + return 'secondary'; 59 + } 60 + 61 + function getLocationString(locations: EventData['locations']): string | undefined { 62 + if (!locations || locations.length === 0) return undefined; 63 + 64 + const loc = locations.find((v) => v.$type === 'community.lexicon.location.address'); 65 + if (!loc) return undefined; 66 + 67 + // Handle both flat location objects (name, street, locality, country) 68 + // and nested address objects 69 + const flat = loc as Record<string, unknown>; 70 + const nested = loc.address; 71 + 72 + const street = (flat.street as string) || undefined; 73 + const locality = (flat.locality as string) || nested?.locality; 74 + const region = (flat.region as string) || nested?.region; 75 + 76 + const parts = [street, locality, region].filter(Boolean); 77 + return parts.length > 0 ? parts.join(', ') : undefined; 78 + } 79 + 80 + let location = $derived(getLocationString(eventData.locations)); 81 + 82 + let headerImage = $derived.by(() => { 83 + if (!eventData.media || eventData.media.length === 0) return null; 84 + const media = eventData.media.find((m) => m.role === 'thumbnail'); 85 + if (!media?.content?.ref?.$link) return null; 86 + return { 87 + url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${media.content.ref.$link}@jpeg`, 88 + alt: media.alt || eventData.name 89 + }; 90 + }); 91 + 92 + let eventUrl = $derived(eventData.url || `https://smokesignal.events/${did}/${rkey}`); 93 + let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`); 94 + </script> 95 + 96 + <svelte:head> 97 + <title>{eventData.name}</title> 98 + <meta name="description" content={eventData.description || `Event: ${eventData.name}`} /> 99 + </svelte:head> 100 + 101 + <div class="bg-base-50 dark:bg-base-950 min-h-screen px-8 py-8 sm:py-12"> 102 + <div class="mx-auto max-w-4xl"> 103 + <!-- Two-column layout: image left, details right --> 104 + <div 105 + 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]" 106 + > 107 + <!-- Image --> 108 + <div class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none"> 109 + {#if headerImage} 110 + <img 111 + src={headerImage.url} 112 + alt={headerImage.alt} 113 + class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 114 + /> 115 + {:else} 116 + <div 117 + class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border [&>svg]:h-full [&>svg]:w-full" 118 + > 119 + <Avatar 120 + size={256} 121 + name={data.rkey} 122 + variant="marble" 123 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 124 + square 125 + /> 126 + </div> 127 + {/if} 128 + </div> 129 + 130 + <!-- Right column: event details --> 131 + <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> 132 + <h1 133 + class="text-base-900 dark:text-base-50 mb-2 text-4xl leading-tight font-bold sm:text-5xl" 134 + > 135 + {eventData.name} 136 + </h1> 137 + 138 + <!-- Mode badge --> 139 + {#if eventData.mode} 140 + <div class="mb-8"> 141 + <Badge size="md" variant={getModeColor(eventData.mode)} 142 + >{getModeLabel(eventData.mode)}</Badge 143 + > 144 + </div> 145 + {/if} 146 + 147 + <!-- Date row (Luma-style calendar icon) --> 148 + <div class="mb-4 flex items-center gap-4"> 149 + <div 150 + class="border-base-200 dark:border-base-700 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 151 + > 152 + <span class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold"> 153 + {formatMonth(startDate)} 154 + </span> 155 + <span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold"> 156 + {formatDay(startDate)} 157 + </span> 158 + </div> 159 + <div> 160 + <p class="text-base-900 dark:text-base-50 font-semibold"> 161 + {formatWeekday(startDate)}, {formatFullDate(startDate)} 162 + </p> 163 + <p class="text-base-500 dark:text-base-400 text-sm"> 164 + {formatTime(startDate)} 165 + {#if endDate} 166 + - {formatTime(endDate)}{/if} 167 + </p> 168 + </div> 169 + </div> 170 + 171 + <!-- Location row --> 172 + {#if location} 173 + <div class="mb-6 flex items-center gap-4"> 174 + <div 175 + class="border-base-200 dark:border-base-700 flex size-12 shrink-0 items-center justify-center rounded-xl border" 176 + > 177 + <svg 178 + xmlns="http://www.w3.org/2000/svg" 179 + fill="none" 180 + viewBox="0 0 24 24" 181 + stroke-width="1.5" 182 + stroke="currentColor" 183 + class="text-base-900 dark:text-base-200 size-5" 184 + > 185 + <path 186 + stroke-linecap="round" 187 + stroke-linejoin="round" 188 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 189 + /> 190 + <path 191 + stroke-linecap="round" 192 + stroke-linejoin="round" 193 + d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" 194 + /> 195 + </svg> 196 + </div> 197 + <p class="text-base-900 dark:text-base-50 font-semibold">{location}</p> 198 + </div> 199 + {/if} 200 + 201 + <EventRsvp {eventUri} eventCid={data.eventCid} /> 202 + 203 + <!-- About Event --> 204 + {#if eventData.description} 205 + <div class="mt-8 mb-8"> 206 + <p 207 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 208 + > 209 + About 210 + </p> 211 + <p class="text-base-700 dark:text-base-300 leading-relaxed whitespace-pre-wrap"> 212 + {eventData.description} 213 + </p> 214 + </div> 215 + {/if} 216 + </div> 217 + 218 + <!-- Hosted By --> 219 + <div class="order-3 md:order-0 md:col-start-1"> 220 + <p 221 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 222 + > 223 + Hosted By 224 + </p> 225 + <a 226 + href={hostUrl} 227 + target={hostProfile?.hasBlento ? undefined : '_blank'} 228 + rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 229 + class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium hover:underline" 230 + > 231 + <FoxAvatar 232 + src={hostProfile?.avatar} 233 + alt={hostProfile?.displayName || hostProfile?.handle || did} 234 + class="size-8 shrink-0" 235 + /> 236 + <span class="truncate text-sm"> 237 + {hostProfile?.displayName || hostProfile?.handle || did} 238 + </span> 239 + </a> 240 + </div> 241 + 242 + {#if (eventData.countGoing && eventData.countGoing > 0) || (eventData.countInterested && eventData.countInterested > 0)} 243 + <!-- Counts --> 244 + <div 245 + class="text-base-900 dark:text-base-100 order-4 space-y-2.5 text-base font-medium md:order-0 md:col-start-1" 246 + > 247 + {#if eventData.countGoing && eventData.countGoing > 0} 248 + <p>{eventData.countGoing} Going</p> 249 + {/if} 250 + {#if eventData.countInterested && eventData.countInterested > 0} 251 + <p>{eventData.countInterested} Interested</p> 252 + {/if} 253 + </div> 254 + {/if} 255 + 256 + {#if eventData.uris && eventData.uris.length > 0} 257 + <!-- Links --> 258 + <div class="order-5 md:order-0 md:col-start-1"> 259 + <p 260 + class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 261 + > 262 + Links 263 + </p> 264 + <div class="space-y-3"> 265 + {#each eventData.uris as link} 266 + <a 267 + href={link.uri} 268 + target="_blank" 269 + rel="noopener noreferrer" 270 + 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" 271 + > 272 + <svg 273 + xmlns="http://www.w3.org/2000/svg" 274 + fill="none" 275 + viewBox="0 0 24 24" 276 + stroke-width="1.5" 277 + stroke="currentColor" 278 + class="size-3.5 shrink-0" 279 + > 280 + <path 281 + stroke-linecap="round" 282 + stroke-linejoin="round" 283 + 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" 284 + /> 285 + </svg> 286 + <span class="truncate">{link.name || link.uri.replace(/^https?:\/\//, '')}</span> 287 + </a> 288 + {/each} 289 + </div> 290 + </div> 291 + {/if} 292 + 293 + <!-- View on Smoke Signal link --> 294 + <a 295 + href={eventUrl} 296 + target="_blank" 297 + rel="noopener noreferrer" 298 + class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 order-6 inline-flex items-center gap-1.5 text-sm transition-colors md:order-0 md:col-start-2" 299 + > 300 + View on Smoke Signal 301 + <svg 302 + xmlns="http://www.w3.org/2000/svg" 303 + fill="none" 304 + viewBox="0 0 24 24" 305 + stroke-width="2" 306 + stroke="currentColor" 307 + class="size-3.5" 308 + > 309 + <path 310 + stroke-linecap="round" 311 + stroke-linejoin="round" 312 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 313 + /> 314 + </svg> 315 + </a> 316 + </div> 317 + </div> 318 + </div>
+231
src/routes/[[actor=actor]]/e/[rkey]/EventRsvp.svelte
··· 1 + <script lang="ts"> 2 + import { user } from '$lib/atproto/auth.svelte'; 3 + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 4 + import { Avatar, Button } from '@foxui/core'; 5 + 6 + let { eventUri, eventCid }: { eventUri: string; eventCid: string | null } = $props(); 7 + 8 + let rsvpStatus: 'going' | 'interested' | 'notgoing' | null = $state(null); 9 + let rsvpRkey: string | null = $state(null); 10 + let rsvpLoading = $state(false); 11 + let rsvpSubmitting = $state(false); 12 + 13 + $effect(() => { 14 + const userDid = user.did; 15 + if (!userDid || user.isInitializing) { 16 + rsvpStatus = null; 17 + rsvpRkey = null; 18 + return; 19 + } 20 + 21 + rsvpLoading = true; 22 + 23 + fetch( 24 + `https://smokesignal.events/xrpc/community.lexicon.calendar.getRSVP?identity=${encodeURIComponent(userDid)}&event=${encodeURIComponent(eventUri)}` 25 + ) 26 + .then((res) => { 27 + if (!res.ok) { 28 + rsvpStatus = null; 29 + rsvpRkey = null; 30 + return; 31 + } 32 + return res.json(); 33 + }) 34 + .then((data) => { 35 + if (!data?.record?.status) { 36 + rsvpStatus = null; 37 + rsvpRkey = null; 38 + return; 39 + } 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 + else rsvpStatus = null; 49 + }) 50 + .catch(() => { 51 + rsvpStatus = null; 52 + rsvpRkey = null; 53 + }) 54 + .finally(() => { 55 + rsvpLoading = false; 56 + }); 57 + }); 58 + 59 + async function submitRsvp(status: 'going' | 'interested') { 60 + if (!user.client || !user.did) return; 61 + rsvpSubmitting = true; 62 + 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 + } 72 + 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 + } 86 + } 87 + }); 88 + 89 + if (response.ok) { 90 + rsvpStatus = status; 91 + const parts = response.data.uri.split('/'); 92 + rsvpRkey = parts[parts.length - 1]; 93 + } 94 + } catch (e) { 95 + console.error('Failed to submit RSVP:', e); 96 + } finally { 97 + rsvpSubmitting = false; 98 + } 99 + } 100 + 101 + async function cancelRsvp() { 102 + if (!user.client || !user.did || !rsvpRkey) return; 103 + rsvpSubmitting = true; 104 + 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 + } 111 + }); 112 + rsvpStatus = null; 113 + rsvpRkey = null; 114 + } catch (e) { 115 + console.error('Failed to cancel RSVP:', e); 116 + } finally { 117 + rsvpSubmitting = false; 118 + } 119 + } 120 + </script> 121 + 122 + <div 123 + class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 mt-8 mb-2 rounded-2xl border p-4" 124 + > 125 + {#if user.isInitializing || rsvpLoading} 126 + <div class="flex items-center gap-3"> 127 + <div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div> 128 + <div class="bg-base-300 dark:bg-base-700 h-4 w-32 animate-pulse rounded"></div> 129 + </div> 130 + {:else if !user.isLoggedIn} 131 + <div class="flex items-center justify-between gap-4"> 132 + <p class="text-base-600 dark:text-base-400 text-sm">Log in to RSVP to this event</p> 133 + 134 + <Button onclick={() => loginModalState.show()}>Log in to RSVP</Button> 135 + </div> 136 + {:else if rsvpStatus === 'going'} 137 + <div class="flex items-center justify-between"> 138 + <div class="flex items-center gap-3"> 139 + <div 140 + class="flex size-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30" 141 + > 142 + <svg 143 + xmlns="http://www.w3.org/2000/svg" 144 + viewBox="0 0 20 20" 145 + fill="currentColor" 146 + class="size-4 text-green-600 dark:text-green-400" 147 + > 148 + <path 149 + fill-rule="evenodd" 150 + 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" 151 + clip-rule="evenodd" 152 + /> 153 + </svg> 154 + </div> 155 + <p class="text-base-900 dark:text-base-50 font-semibold">You're Going</p> 156 + </div> 157 + <Button onclick={cancelRsvp} disabled={rsvpSubmitting} variant="ghost">Remove</Button> 158 + </div> 159 + {:else if rsvpStatus === 'interested'} 160 + <div class="flex items-center justify-between"> 161 + <div class="flex items-center gap-3"> 162 + <div 163 + class="flex size-8 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/30" 164 + > 165 + <svg 166 + xmlns="http://www.w3.org/2000/svg" 167 + viewBox="0 0 20 20" 168 + fill="currentColor" 169 + class="size-4 text-amber-600 dark:text-amber-400" 170 + > 171 + <path 172 + fill-rule="evenodd" 173 + 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" 174 + clip-rule="evenodd" 175 + /> 176 + </svg> 177 + </div> 178 + <p class="text-base-900 dark:text-base-50 font-semibold">You're Interested</p> 179 + </div> 180 + <Button onclick={cancelRsvp} disabled={rsvpSubmitting} variant="ghost">Remove</Button> 181 + </div> 182 + {:else if rsvpStatus === 'notgoing'} 183 + <div class="flex items-center justify-between"> 184 + <div class="flex items-center gap-3"> 185 + <div 186 + class="flex size-8 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30" 187 + > 188 + <svg 189 + xmlns="http://www.w3.org/2000/svg" 190 + viewBox="0 0 20 20" 191 + fill="currentColor" 192 + class="size-4 text-red-600 dark:text-red-400" 193 + > 194 + <path 195 + d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 196 + /> 197 + </svg> 198 + </div> 199 + <p class="text-base-900 dark:text-base-50 font-semibold">Not Going</p> 200 + </div> 201 + <Button onclick={cancelRsvp} disabled={rsvpSubmitting} variant="ghost">Remove</Button> 202 + </div> 203 + {:else} 204 + {#if user.profile} 205 + <div class="mb-4 flex items-center gap-2"> 206 + <span class="text-base-500 dark:text-base-400 text-sm">RSVPing as</span> 207 + <Avatar 208 + src={user.profile.avatar} 209 + alt={user.profile.displayName || user.profile.handle} 210 + class="size-5" 211 + /> 212 + <span class="text-base-700 dark:text-base-300 truncate text-sm font-medium"> 213 + {user.profile.displayName || user.profile.handle} 214 + </span> 215 + </div> 216 + {/if} 217 + <div class="flex gap-3"> 218 + <Button onclick={() => submitRsvp('going')} disabled={rsvpSubmitting} class="flex-1"> 219 + {rsvpSubmitting ? '...' : 'Going'} 220 + </Button> 221 + <Button 222 + onclick={() => submitRsvp('interested')} 223 + disabled={rsvpSubmitting} 224 + variant="secondary" 225 + class="flex-1" 226 + > 227 + {rsvpSubmitting ? '...' : 'Interested'} 228 + </Button> 229 + </div> 230 + {/if} 231 + </div>