your personal website on atproto - mirror blento.app

allow rsvping

Florian 2f563e21 ea22373f

+353 -104
+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
+1
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;
+10 -4
src/routes/[[actor=actor]]/e/[rkey]/+page.server.ts
··· 1 1 import { error } from '@sveltejs/kit'; 2 2 import type { EventData } from '$lib/cards/social/EventCard'; 3 - import { getBlentoOrBskyProfile, resolveHandle } from '$lib/atproto/methods.js'; 3 + import { getBlentoOrBskyProfile, getRecord, resolveHandle } from '$lib/atproto/methods.js'; 4 4 import { isHandle } from '@atcute/lexicons/syntax'; 5 5 import { createCache, type CachedProfile } from '$lib/cache'; 6 6 import type { Did } from '@atcute/lexicons'; ··· 20 20 `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}` 21 21 ); 22 22 23 - const [eventResponse, hostProfile] = await Promise.all([ 23 + const [eventResponse, hostProfile, eventRecord] = await Promise.all([ 24 24 fetch( 25 25 `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}` 26 26 ), ··· 37 37 url: p.url 38 38 }) 39 39 ) 40 - .catch(() => null) 40 + .catch(() => null), 41 + getRecord({ 42 + did: did as Did, 43 + collection: 'community.lexicon.calendar.event', 44 + rkey 45 + }).catch(() => null) 41 46 ]); 42 47 43 48 if (!eventResponse.ok) { ··· 50 55 eventData, 51 56 did, 52 57 rkey, 53 - hostProfile: hostProfile ?? null 58 + hostProfile: hostProfile ?? null, 59 + eventCid: (eventRecord?.cid as string) ?? null 54 60 }; 55 61 } catch (e) { 56 62 if (e && typeof e === 'object' && 'status' in e) throw e;
+109 -99
src/routes/[[actor=actor]]/e/[rkey]/+page.svelte
··· 2 2 import type { EventData } from '$lib/cards/social/EventCard'; 3 3 import { Avatar as FoxAvatar, Badge } from '@foxui/core'; 4 4 import Avatar from 'svelte-boring-avatars'; 5 + import EventRsvp from './EventRsvp.svelte'; 5 6 6 7 let { data } = $props(); 7 8 ··· 50 51 return 'Event'; 51 52 } 52 53 53 - function getModeColor(mode: string): string { 54 + function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' { 54 55 if (mode.includes('virtual')) return 'cyan'; 55 56 if (mode.includes('hybrid')) return 'purple'; 56 57 if (mode.includes('inperson')) return 'amber'; 57 - return 'gray'; 58 + return 'secondary'; 58 59 } 59 60 60 61 function getLocationString(locations: EventData['locations']): string | undefined { 61 62 if (!locations || locations.length === 0) return undefined; 62 63 63 - const loc = locations.find((v => v.$type === "community.lexicon.location.address")); 64 + const loc = locations.find((v) => v.$type === 'community.lexicon.location.address'); 64 65 if (!loc) return undefined; 65 66 66 67 // Handle both flat location objects (name, street, locality, country) ··· 89 90 }); 90 91 91 92 let eventUrl = $derived(eventData.url || `https://smokesignal.events/${did}/${rkey}`); 93 + let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`); 92 94 </script> 93 95 94 96 <svelte:head> ··· 96 98 <meta name="description" content={eventData.description || `Event: ${eventData.name}`} /> 97 99 </svelte:head> 98 100 99 - <div class="bg-base-50 dark:bg-base-950 min-h-screen px-4 py-8 sm:py-12"> 101 + <div class="bg-base-50 dark:bg-base-950 min-h-screen px-8 py-8 sm:py-12"> 100 102 <div class="mx-auto max-w-4xl"> 101 103 <!-- Two-column layout: image left, details right --> 102 - <div class="flex flex-col gap-8 md:flex-row md:gap-10"> 103 - <!-- Left column: image --> 104 - <div class="shrink-0 md:w-56 lg:w-64 max-w-sm mx-auto md:max-w-none"> 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"> 105 109 {#if headerImage} 106 110 <img 107 111 src={headerImage.url} ··· 121 125 /> 122 126 </div> 123 127 {/if} 124 - 125 - <!-- Hosted By section (below image, like Luma) --> 126 - <div class="mt-6"> 127 - <p 128 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 129 - > 130 - Hosted By 131 - </p> 132 - <a 133 - href={hostUrl} 134 - target={hostProfile?.hasBlento ? undefined : '_blank'} 135 - rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 136 - class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium hover:underline" 137 - > 138 - <FoxAvatar 139 - src={hostProfile?.avatar} 140 - alt={hostProfile?.displayName || hostProfile?.handle || did} 141 - class="size-8 shrink-0" 142 - /> 143 - <span class="truncate text-sm"> 144 - {hostProfile?.displayName || hostProfile?.handle || did} 145 - </span> 146 - </a> 147 - </div> 148 - 149 - {#if (eventData.countGoing && eventData.countGoing > 0) || (eventData.countInterested && eventData.countInterested > 0)} 150 - <div class="text-base-900 dark:text-base-100 mt-8 space-y-2.5 text-base font-medium"> 151 - {#if eventData.countGoing && eventData.countGoing > 0} 152 - <p>{eventData.countGoing} Going</p> 153 - {/if} 154 - {#if eventData.countInterested && eventData.countInterested > 0} 155 - <p>{eventData.countInterested} Interested</p> 156 - {/if} 157 - </div> 158 - {/if} 159 - 160 - {#if eventData.uris && eventData.uris.length > 0} 161 - <div class="mt-8"> 162 - <p 163 - class="text-base-500 dark:text-base-400 mb-2 text-xs font-semibold tracking-wider uppercase" 164 - > 165 - Links 166 - </p> 167 - <div class="space-y-1.5"> 168 - {#each eventData.uris as link} 169 - <a 170 - href={link.uri} 171 - target="_blank" 172 - rel="noopener noreferrer" 173 - 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" 174 - > 175 - <svg 176 - xmlns="http://www.w3.org/2000/svg" 177 - fill="none" 178 - viewBox="0 0 24 24" 179 - stroke-width="1.5" 180 - stroke="currentColor" 181 - class="size-3.5 shrink-0" 182 - > 183 - <path 184 - stroke-linecap="round" 185 - stroke-linejoin="round" 186 - 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" 187 - /> 188 - </svg> 189 - <span class="truncate">{link.name || link.uri.replace(/^https?:\/\//, '')}</span> 190 - </a> 191 - {/each} 192 - </div> 193 - </div> 194 - {/if} 195 128 </div> 196 129 197 130 <!-- Right column: event details --> 198 - <div class="min-w-0 flex-1"> 131 + <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> 199 132 <h1 200 133 class="text-base-900 dark:text-base-50 mb-2 text-4xl leading-tight font-bold sm:text-5xl" 201 134 > ··· 265 198 </div> 266 199 {/if} 267 200 201 + <EventRsvp {eventUri} eventCid={data.eventCid} /> 202 + 268 203 <!-- About Event --> 269 204 {#if eventData.description} 270 205 <div class="mt-8 mb-8"> ··· 278 213 </p> 279 214 </div> 280 215 {/if} 216 + </div> 281 217 282 - <!-- View on Smoke Signal link --> 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> 283 225 <a 284 - href={eventUrl} 285 - target="_blank" 286 - rel="noopener noreferrer" 287 - class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 inline-flex items-center gap-1.5 text-sm transition-colors" 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" 288 230 > 289 - View on Smoke Signal 290 - <svg 291 - xmlns="http://www.w3.org/2000/svg" 292 - fill="none" 293 - viewBox="0 0 24 24" 294 - stroke-width="2" 295 - stroke="currentColor" 296 - class="size-3.5" 297 - > 298 - <path 299 - stroke-linecap="round" 300 - stroke-linejoin="round" 301 - d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 302 - /> 303 - </svg> 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> 304 239 </a> 305 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> 306 316 </div> 307 317 </div> 308 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>