your personal website on atproto - mirror blento.app
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #271 from flo-bit/fix/small-improvements

some small improvements

authored by

Florian and committed by
GitHub
e84ef259 568cd438

+33 -3466
+2 -2
src/lib/cards/_base/BaseCard/BaseCard.svelte
··· 45 45 draggable={false} 46 46 class={[ 47 47 fillPage 48 - ? 'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card relative isolate z-0 h-full w-full outline-offset-2 transition-all duration-200 focus-within:outline-2' 49 - : 'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card absolute isolate z-0 rounded-3xl outline-offset-2 transition-all duration-200 focus-within:outline-2', 48 + ? 'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card relative isolate z-0 h-full w-full outline-offset-2 transition-[outline] duration-200 focus-within:outline-2' 49 + : 'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card absolute isolate z-0 rounded-3xl outline-offset-2 transition-[outline] duration-200 focus-within:outline-2', 50 50 !fillPage ? (color ? (colors[color] ?? colors.accent) : colors.base) : '', 51 51 color !== 'accent' && item.color !== 'base' && item.color !== 'transparent' ? color : '', 52 52 showOutline ? 'outline-2' : '',
+7 -15
src/lib/cards/core/MapCard/Map.svelte
··· 5 5 6 6 let { item = $bindable(), isEditing = false }: { item: Item; isEditing?: boolean } = $props(); 7 7 8 - let center = $state({ lng: parseFloat(item.cardData.lon), lat: parseFloat(item.cardData.lat) }); 8 + const center = { lng: parseFloat(item.cardData.lon), lat: parseFloat(item.cardData.lat) }; 9 9 let showAttribution = $state(false); 10 10 let map: maplibregl.Map | undefined = $state(); 11 11 12 - const fixedCenter = { lng: parseFloat(item.cardData.lon), lat: parseFloat(item.cardData.lat) }; 13 - 14 12 function handleZoom() { 15 - if (!isEditing && map) { 16 - map.setCenter(fixedCenter); 13 + if (map) { 14 + map.setCenter(center); 17 15 } 18 16 } 19 - 20 - $effect(() => { 21 - if (!isEditing && map) { 22 - map.getCanvas().style.touchAction = 'pan-x pan-y'; 23 - } 24 - }); 25 17 </script> 26 18 27 19 <div ··· 38 30 zoom={item.cardData.zoom} 39 31 {center} 40 32 attributionControl={false} 41 - dragPan={isEditing} 33 + dragPan={false} 42 34 dragRotate={false} 43 35 keyboard={false} 44 - touchZoomRotate={true} 45 - scrollZoom={true} 36 + touchZoomRotate={isEditing} 37 + scrollZoom={isEditing} 46 38 boxZoom={false} 47 39 pitchWithRotate={false} 48 40 touchPitch={false} ··· 50 42 > 51 43 <Projection type="globe" /> 52 44 53 - <Marker bind:lnglat={center}> 45 + <Marker lnglat={center}> 54 46 {#snippet content()} 55 47 <div class="from-accent-400 size-10 rounded-full bg-radial via-transparent p-3"> 56 48 <div class="bg-accent-500 size-4 rounded-full ring-2 ring-white"></div>
+1 -1
src/lib/cards/social/EventCard/EventCard.svelte
··· 91 91 92 92 let eventUrl = $derived(() => { 93 93 if (parsedUri) { 94 - return `https://blento.app/${parsedUri.repo}/events/${parsedUri.rkey}`; 94 + return `https://atmo.rsvp/p/${parsedUri.repo}/e/${parsedUri.rkey}`; 95 95 } 96 96 return '#'; 97 97 });
+22 -8
src/lib/cards/social/UpcomingEventsCard/UpcomingEventsCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 - import { Badge } from '@foxui/core'; 3 + import { Badge, Button, Modal } from '@foxui/core'; 4 4 import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 5 5 import type { ContentComponentProps } from '../../types'; 6 6 import { UpcomingEventsCardDefinition } from '.'; 7 7 import type { EventData } from '../EventCard'; 8 8 import { user } from '$lib/atproto'; 9 9 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 10 - import * as TID from '@atcute/tid'; 10 + let showCreateModal = $state(false); 11 11 12 12 let { item }: ContentComponentProps = $props(); 13 13 ··· 143 143 /> 144 144 </svg> 145 145 </button> 146 - <a 147 - href="/{handle}/events/{TID.now()}/edit" 148 - target="_blank" 146 + <button 147 + onclick={() => (showCreateModal = true)} 149 148 title="Create new event" 150 - class="bg-base-100 hover:bg-base-200 dark:bg-base-800 dark:hover:bg-base-700 accent:bg-accent-400/30 accent:hover:bg-accent-400/50 text-base-700 dark:text-base-300 z-50 flex size-7 items-center justify-center rounded-lg transition-colors" 149 + class="bg-base-100 hover:bg-base-200 dark:bg-base-800 dark:hover:bg-base-700 accent:bg-accent-400/30 accent:hover:bg-accent-400/50 text-base-700 dark:text-base-300 z-50 flex size-7 cursor-pointer items-center justify-center rounded-lg transition-colors" 151 150 > 152 151 <svg 153 152 xmlns="http://www.w3.org/2000/svg" ··· 159 158 > 160 159 <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 161 160 </svg> 162 - </a> 161 + </button> 163 162 </div> 164 163 {/if} 165 164 </div> ··· 170 169 <div class="flex flex-col gap-2"> 171 170 {#each events as event (event.rkey)} 172 171 <a 173 - href="https://blento.app/{did}/events/{event.rkey}" 172 + href="https://atmo.rsvp/p/{did}/e/{event.rkey}" 174 173 target="_blank" 175 174 class="hover:bg-base-100 dark:hover:bg-base-800 accent:hover:bg-accent-400/20 flex flex-col gap-1 rounded-lg p-2 transition-colors" 176 175 use:qrOverlay={{ context: { title: event.name } }} ··· 252 251 {/if} 253 252 </div> 254 253 </div> 254 + 255 + <Modal bind:open={showCreateModal}> 256 + <div class="flex flex-col gap-4"> 257 + <h3 class="text-lg font-semibold">Create Event</h3> 258 + <p class="text-base-600 dark:text-base-400 text-sm"> 259 + Create events on atmo.rsvp, these events will show up here (might take a few minutes). 260 + </p> 261 + <div class="flex justify-end gap-2"> 262 + <Button variant="ghost" onclick={() => (showCreateModal = false)}>Cancel</Button> 263 + <Button href="https://atmo.rsvp" target="_blank" onclick={() => (showCreateModal = false)}> 264 + Go to atmo.rsvp 265 + </Button> 266 + </div> 267 + </div> 268 + </Modal>
+1 -1
src/lib/cards/social/UpcomingRsvpsCard/UpcomingRsvpsCard.svelte
··· 85 85 <div class="flex flex-col gap-2"> 86 86 {#each rsvps as rsvp, i (`${rsvp.eventUri}-${i}`)} 87 87 <a 88 - href="https://blento.app/{rsvp.hostDid}/events/{rsvp.rkey}" 88 + href="https://atmo.rsvp/p/{rsvp.hostDid}/e/{rsvp.rkey}" 89 89 target="_blank" 90 90 class="hover:bg-base-100 dark:hover:bg-base-800 accent:hover:bg-accent-400/20 flex flex-col gap-1 rounded-lg p-2 transition-colors" 91 91 use:qrOverlay={{ context: { title: rsvp.event.name } }}
-28
src/routes/[[actor=actor]]/events/+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]]/events/+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()}
-71
src/routes/[[actor=actor]]/events/+page.server.ts
··· 1 - import { error } from '@sveltejs/kit'; 2 - import type { EventData } from '$lib/cards/social/EventCard'; 3 - import { getBlentoOrBskyProfile, listRecords } from '$lib/atproto/methods.js'; 4 - import { createCache, type CachedProfile } from '$lib/cache'; 5 - import type { Did } from '@atcute/lexicons'; 6 - import { getActor } from '$lib/actor.js'; 7 - 8 - export async function load({ params, platform, request }) { 9 - const cache = createCache(platform); 10 - 11 - const did = await getActor({ request, paramActor: params.actor, platform }); 12 - 13 - if (!did) { 14 - throw error(404, 'Events not found'); 15 - } 16 - 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 - 28 - const [records, hostProfile] = await Promise.all([ 29 - listRecords({ 30 - did: did as Did, 31 - collection: 'community.lexicon.calendar.event', 32 - limit: 100 33 - }), 34 - cache 35 - ? cache.getProfile(did as Did).catch(() => null) 36 - : getBlentoOrBskyProfile({ did: did as Did }) 37 - .then( 38 - (p): CachedProfile => ({ 39 - did: p.did as string, 40 - handle: p.handle as string, 41 - displayName: p.displayName as string | undefined, 42 - avatar: p.avatar as string | undefined, 43 - hasBlento: p.hasBlento, 44 - url: p.url 45 - }) 46 - ) 47 - .catch(() => null) 48 - ]); 49 - 50 - const events = records.map((r) => ({ 51 - ...(r.value as EventData), 52 - rkey: r.uri.split('/').pop() as string 53 - })); 54 - 55 - const result = { 56 - events, 57 - did, 58 - hostProfile: hostProfile ?? null 59 - }; 60 - 61 - // Cache the result 62 - if (cache) { 63 - await cache.putJSON('events', did, result).catch(() => {}); 64 - } 65 - 66 - return result; 67 - } catch (e) { 68 - if (e && typeof e === 'object' && 'status' in e) throw e; 69 - throw error(404, 'Events not found'); 70 - } 71 - }
-232
src/routes/[[actor=actor]]/events/+page.svelte
··· 1 - <script lang="ts"> 2 - import type { EventData } from '$lib/cards/social/EventCard'; 3 - import { getCDNImageBlobUrl } from '$lib/atproto'; 4 - import { user } from '$lib/atproto/auth.svelte'; 5 - import { Avatar as FoxAvatar, Badge, Button, toast } from '@foxui/core'; 6 - import { page } from '$app/state'; 7 - import Avatar from 'svelte-boring-avatars'; 8 - import * as TID from '@atcute/tid'; 9 - import { goto } from '$app/navigation'; 10 - 11 - let { data } = $props(); 12 - 13 - let events: (EventData & { rkey: string })[] = $derived(data.events); 14 - let did: string = $derived(data.did); 15 - let hostProfile = $derived(data.hostProfile); 16 - 17 - let hostName = $derived(hostProfile?.displayName || hostProfile?.handle || did); 18 - let hostUrl = $derived( 19 - hostProfile?.url ?? `https://bsky.app/profile/${hostProfile?.handle || did}` 20 - ); 21 - 22 - function formatDate(dateStr: string): string { 23 - const date = new Date(dateStr); 24 - const options: Intl.DateTimeFormatOptions = { 25 - weekday: 'short', 26 - month: 'short', 27 - day: 'numeric' 28 - }; 29 - if (date.getFullYear() !== new Date().getFullYear()) { 30 - options.year = 'numeric'; 31 - } 32 - return date.toLocaleDateString('en-US', options); 33 - } 34 - 35 - function formatTime(dateStr: string): string { 36 - return new Date(dateStr).toLocaleTimeString('en-US', { 37 - hour: 'numeric', 38 - minute: '2-digit' 39 - }); 40 - } 41 - 42 - function getModeLabel(mode: string): string { 43 - if (mode.includes('virtual')) return 'Virtual'; 44 - if (mode.includes('hybrid')) return 'Hybrid'; 45 - if (mode.includes('inperson')) return 'In-Person'; 46 - return 'Event'; 47 - } 48 - 49 - function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' { 50 - if (mode.includes('virtual')) return 'cyan'; 51 - if (mode.includes('hybrid')) return 'purple'; 52 - if (mode.includes('inperson')) return 'amber'; 53 - return 'secondary'; 54 - } 55 - 56 - function getLocationString(locations: EventData['locations']): string | undefined { 57 - if (!locations || locations.length === 0) return undefined; 58 - 59 - const loc = locations.find((v) => v.$type === 'community.lexicon.location.address'); 60 - if (!loc) return undefined; 61 - 62 - const flat = loc as Record<string, unknown>; 63 - const nested = loc.address; 64 - 65 - const locality = (flat.locality as string) || nested?.locality; 66 - const region = (flat.region as string) || nested?.region; 67 - 68 - const parts = [locality, region].filter(Boolean); 69 - return parts.length > 0 ? parts.join(', ') : undefined; 70 - } 71 - 72 - function getThumbnail(event: EventData): { url: string; alt: string } | null { 73 - if (!event.media || event.media.length === 0) return null; 74 - const media = event.media.find((m) => m.role === 'thumbnail'); 75 - if (!media?.content) return null; 76 - const url = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' }); 77 - if (!url) return null; 78 - return { url, alt: media.alt || event.name }; 79 - } 80 - 81 - let isOwner = $derived(user.isLoggedIn && user.did === did); 82 - 83 - let showPast: boolean = $state(false); 84 - let now = $derived(new Date()); 85 - let filteredEvents = $derived( 86 - events.filter((e) => { 87 - const endOrStart = e.endsAt || e.startsAt; 88 - const eventDate = new Date(endOrStart); 89 - return showPast ? eventDate < now : eventDate >= now; 90 - }) 91 - ); 92 - </script> 93 - 94 - <svelte:head> 95 - <title>{hostName} - Events</title> 96 - <meta name="description" content="Events hosted by {hostName}" /> 97 - <meta property="og:title" content="{hostName} - Events" /> 98 - <meta property="og:description" content="Events hosted by {hostName}" /> 99 - <meta name="twitter:card" content="summary" /> 100 - <meta name="twitter:title" content="{hostName} - Events" /> 101 - <meta name="twitter:description" content="Events hosted by {hostName}" /> 102 - </svelte:head> 103 - 104 - <div class="min-h-screen px-6 py-12 sm:py-12"> 105 - <div class="mx-auto max-w-3xl"> 106 - <!-- Header --> 107 - <div class="mb-8 flex items-start justify-between"> 108 - <div> 109 - <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl"> 110 - {showPast ? 'Past' : 'Upcoming'} events 111 - </h1> 112 - <div class="mt-4 flex items-center gap-2"> 113 - <span class="text-base-500 dark:text-base-400 text-sm">Hosted by</span> 114 - <a 115 - href={hostUrl} 116 - target={hostProfile?.hasBlento ? undefined : '_blank'} 117 - rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 118 - class="flex items-center gap-1.5 hover:underline" 119 - > 120 - <FoxAvatar src={hostProfile?.avatar} alt={hostName} class="size-5 shrink-0" /> 121 - <span class="text-base-900 dark:text-base-100 text-sm font-medium">{hostName}</span> 122 - </a> 123 - </div> 124 - </div> 125 - <div class="flex flex-col items-end gap-2"> 126 - {#if isOwner} 127 - <Button 128 - variant="primary" 129 - onclick={() => { 130 - const rkey = TID.now(); 131 - const handle = 132 - user.profile?.handle && user.profile.handle !== 'handle.invalid' 133 - ? user.profile.handle 134 - : user.did; 135 - goto(`/${handle}/events/${rkey}/edit`); 136 - }}>New event</Button 137 - > 138 - {/if} 139 - <Button 140 - variant="secondary" 141 - onclick={async () => { 142 - const calendarUrl = `${page.url.origin}${page.url.pathname.replace(/\/$/, '')}/calendar`; 143 - await navigator.clipboard.writeText(calendarUrl); 144 - toast.success('Subscription link copied to clipboard'); 145 - }}>Subscribe</Button 146 - > 147 - </div> 148 - </div> 149 - 150 - <!-- Toggle --> 151 - <div class="mb-6 flex gap-1"> 152 - <button 153 - class="rounded-xl px-3 py-1.5 text-sm font-medium transition-colors {!showPast 154 - ? 'bg-base-200 dark:bg-base-800 text-base-900 dark:text-base-50' 155 - : 'text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 cursor-pointer'}" 156 - onclick={() => (showPast = false)}>Upcoming</button 157 - > 158 - <button 159 - class="rounded-xl px-3 py-1.5 text-sm font-medium transition-colors {showPast 160 - ? 'bg-base-200 dark:bg-base-800 text-base-900 dark:text-base-50' 161 - : 'text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 cursor-pointer'}" 162 - onclick={() => (showPast = true)}>Past</button 163 - > 164 - </div> 165 - 166 - {#if filteredEvents.length === 0} 167 - <p class="text-base-500 dark:text-base-400 py-12 text-center"> 168 - No {showPast ? 'past' : 'upcoming'} events. 169 - </p> 170 - {:else} 171 - <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> 172 - {#each filteredEvents as event (event.rkey)} 173 - {@const thumbnail = getThumbnail(event)} 174 - {@const location = getLocationString(event.locations)} 175 - {@const rkey = event.rkey} 176 - <a 177 - href="./events/{rkey}" 178 - 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" 179 - > 180 - <!-- Thumbnail --> 181 - <div class="p-4"> 182 - {#if thumbnail} 183 - <img 184 - src={thumbnail.url} 185 - alt={thumbnail.alt} 186 - class="aspect-square w-full rounded-2xl object-cover" 187 - /> 188 - {:else} 189 - <div 190 - class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full" 191 - > 192 - <Avatar 193 - size={400} 194 - name={rkey} 195 - variant="marble" 196 - colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 197 - square 198 - /> 199 - </div> 200 - {/if} 201 - </div> 202 - 203 - <!-- Content --> 204 - <div class="p-4"> 205 - <h2 206 - 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" 207 - > 208 - {event.name} 209 - </h2> 210 - 211 - <p class="text-base-500 dark:text-base-400 mb-2 text-sm"> 212 - {formatDate(event.startsAt)} &middot; {formatTime(event.startsAt)} 213 - </p> 214 - 215 - <div class="flex flex-wrap items-center gap-2"> 216 - {#if event.mode} 217 - <Badge size="sm" variant={getModeColor(event.mode)} 218 - >{getModeLabel(event.mode)}</Badge 219 - > 220 - {/if} 221 - 222 - {#if location} 223 - <span class="text-base-500 dark:text-base-400 truncate text-xs">{location}</span> 224 - {/if} 225 - </div> 226 - </div> 227 - </a> 228 - {/each} 229 - </div> 230 - {/if} 231 - </div> 232 - </div>
-60
src/routes/[[actor=actor]]/events/[rkey]/+page.server.ts
··· 1 - import { error } from '@sveltejs/kit'; 2 - import type { EventData } from '$lib/cards/social/EventCard'; 3 - import { getBlentoOrBskyProfile, getRecord } from '$lib/atproto/methods.js'; 4 - import { createCache, type CachedProfile } from '$lib/cache'; 5 - import type { Did } from '@atcute/lexicons'; 6 - import { getActor } from '$lib/actor'; 7 - 8 - export async function load({ params, platform, request }) { 9 - const { rkey } = params; 10 - 11 - const cache = createCache(platform); 12 - 13 - const did = await getActor({ request, paramActor: params.actor, platform }); 14 - 15 - if (!did || !rkey) { 16 - throw error(404, 'Event not found'); 17 - } 18 - 19 - try { 20 - const [eventRecord, hostProfile] = await Promise.all([ 21 - getRecord({ 22 - did: did as Did, 23 - collection: 'community.lexicon.calendar.event', 24 - rkey 25 - }), 26 - cache 27 - ? cache.getProfile(did as Did).catch(() => null) 28 - : getBlentoOrBskyProfile({ did: did as Did }) 29 - .then( 30 - (p): CachedProfile => ({ 31 - did: p.did as string, 32 - handle: p.handle as string, 33 - displayName: p.displayName as string | undefined, 34 - avatar: p.avatar as string | undefined, 35 - hasBlento: p.hasBlento, 36 - url: p.url 37 - }) 38 - ) 39 - .catch(() => null) 40 - ]); 41 - 42 - if (!eventRecord?.value) { 43 - throw error(404, 'Event not found'); 44 - } 45 - 46 - const eventData: EventData = eventRecord.value as EventData; 47 - console.log(eventData); 48 - 49 - return { 50 - eventData, 51 - did, 52 - rkey, 53 - hostProfile: hostProfile ?? null, 54 - eventCid: (eventRecord.cid as string) ?? null 55 - }; 56 - } catch (e) { 57 - if (e && typeof e === 'object' && 'status' in e) throw e; 58 - throw error(404, 'Event not found'); 59 - } 60 - }
-490
src/routes/[[actor=actor]]/events/[rkey]/+page.svelte
··· 1 - <script lang="ts"> 2 - import type { EventData } from '$lib/cards/social/EventCard'; 3 - import { getCDNImageBlobUrl } from '$lib/atproto'; 4 - import { user } from '$lib/atproto/auth.svelte'; 5 - import { Avatar as FoxAvatar, Badge, Button } from '@foxui/core'; 6 - import Avatar from 'svelte-boring-avatars'; 7 - import EventRsvp from './EventRsvp.svelte'; 8 - import EventAttendees from './EventAttendees.svelte'; 9 - import { page } from '$app/state'; 10 - import { marked } from 'marked'; 11 - import { sanitize } from '$lib/sanitize'; 12 - import { generateICalEvent } from '$lib/ical'; 13 - 14 - let { data } = $props(); 15 - 16 - let eventData: EventData = $derived(data.eventData); 17 - let did: string = $derived(data.did); 18 - let rkey: string = $derived(data.rkey); 19 - let hostProfile = $derived(data.hostProfile); 20 - 21 - let hostUrl = $derived( 22 - hostProfile?.url ?? `https://bsky.app/profile/${hostProfile?.handle || did}` 23 - ); 24 - 25 - let startDate = $derived(new Date(eventData.startsAt)); 26 - let endDate = $derived(eventData.endsAt ? new Date(eventData.endsAt) : null); 27 - 28 - function formatMonth(date: Date): string { 29 - return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); 30 - } 31 - 32 - function formatDay(date: Date): number { 33 - return date.getDate(); 34 - } 35 - 36 - function formatWeekday(date: Date): string { 37 - return date.toLocaleDateString('en-US', { weekday: 'long' }); 38 - } 39 - 40 - function formatFullDate(date: Date): string { 41 - const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' }; 42 - if (date.getFullYear() !== new Date().getFullYear()) { 43 - options.year = 'numeric'; 44 - } 45 - return date.toLocaleDateString('en-US', options); 46 - } 47 - 48 - function formatTime(date: Date): string { 49 - return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); 50 - } 51 - 52 - function getModeLabel(mode: string): string { 53 - if (mode.includes('virtual')) return 'Virtual'; 54 - if (mode.includes('hybrid')) return 'Hybrid'; 55 - if (mode.includes('inperson')) return 'In-Person'; 56 - return 'Event'; 57 - } 58 - 59 - function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' { 60 - if (mode.includes('virtual')) return 'cyan'; 61 - if (mode.includes('hybrid')) return 'purple'; 62 - if (mode.includes('inperson')) return 'amber'; 63 - return 'secondary'; 64 - } 65 - 66 - function getLocationString(locations: EventData['locations']): string | undefined { 67 - if (!locations || locations.length === 0) return undefined; 68 - 69 - const loc = locations.find((v) => v.$type === 'community.lexicon.location.address'); 70 - if (!loc) return undefined; 71 - 72 - // Handle both flat location objects (name, street, locality, country) 73 - // and nested address objects 74 - const flat = loc as Record<string, unknown>; 75 - const nested = loc.address; 76 - 77 - const street = (flat.street as string) || undefined; 78 - const locality = (flat.locality as string) || nested?.locality; 79 - const region = (flat.region as string) || nested?.region; 80 - 81 - const parts = [street, locality, region].filter(Boolean); 82 - return parts.length > 0 ? parts.join(', ') : undefined; 83 - } 84 - 85 - let location = $derived(getLocationString(eventData.locations)); 86 - 87 - let thumbnailImage = $derived.by(() => { 88 - if (!eventData.media || eventData.media.length === 0) return null; 89 - const media = eventData.media.find((m) => m.role === 'thumbnail'); 90 - if (!media?.content) return null; 91 - const url = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' }); 92 - if (!url) return null; 93 - return { url, alt: media.alt || eventData.name }; 94 - }); 95 - 96 - let bannerImage = $derived.by(() => { 97 - if (!eventData.media || eventData.media.length === 0) return null; 98 - const media = eventData.media.find((m) => m.role === 'header'); 99 - if (!media?.content) return null; 100 - const url = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' }); 101 - if (!url) return null; 102 - return { url, alt: media.alt || eventData.name }; 103 - }); 104 - 105 - // Prefer thumbnail; fall back to header/banner image 106 - let displayImage = $derived(thumbnailImage ?? bannerImage); 107 - let isBannerOnly = $derived(!thumbnailImage && !!bannerImage); 108 - 109 - let isSameDay = $derived( 110 - endDate && 111 - startDate.getFullYear() === endDate.getFullYear() && 112 - startDate.getMonth() === endDate.getMonth() && 113 - startDate.getDate() === endDate.getDate() 114 - ); 115 - 116 - const renderer = new marked.Renderer(); 117 - renderer.link = ({ href, text }) => 118 - `<a target="_blank" rel="noopener noreferrer nofollow" href="${href}" class="text-accent-600 dark:text-accent-400 hover:underline">${text}</a>`; 119 - 120 - function renderDescription( 121 - text: string, 122 - facets?: { 123 - index: { byteStart: number; byteEnd: number }; 124 - features: { $type: string; did?: string; uri?: string; tag?: string }[]; 125 - }[] 126 - ): string { 127 - let result = text; 128 - 129 - if (facets && facets.length > 0) { 130 - const encoder = new TextEncoder(); 131 - const encoded = encoder.encode(text); 132 - const decoder = new TextDecoder(); 133 - 134 - // Sort facets in reverse order by byteStart so replacements don't shift positions 135 - const sorted = [...facets].sort((a, b) => b.index.byteStart - a.index.byteStart); 136 - 137 - for (const facet of sorted) { 138 - const feature = facet.features?.[0]; 139 - if (!feature) continue; 140 - 141 - const segmentBytes = encoded.slice(facet.index.byteStart, facet.index.byteEnd); 142 - const segmentText = decoder.decode(segmentBytes); 143 - 144 - let mdLink: string | null = null; 145 - switch (feature.$type) { 146 - case 'app.bsky.richtext.facet#mention': 147 - mdLink = `[${segmentText}](https://bsky.app/profile/${feature.did})`; 148 - break; 149 - case 'app.bsky.richtext.facet#link': 150 - mdLink = `[${segmentText}](${feature.uri})`; 151 - break; 152 - case 'app.bsky.richtext.facet#tag': 153 - mdLink = `[${segmentText}](https://bsky.app/hashtag/${feature.tag})`; 154 - break; 155 - } 156 - 157 - if (mdLink) { 158 - // Convert byte offsets to character offsets for string replacement 159 - const before = decoder.decode(encoded.slice(0, facet.index.byteStart)); 160 - const after = decoder.decode(encoded.slice(facet.index.byteEnd)); 161 - result = before + mdLink + after; 162 - } 163 - } 164 - } 165 - 166 - return marked.parse(result, { renderer }) as string; 167 - } 168 - 169 - let descriptionHtml = $derived( 170 - eventData.description 171 - ? sanitize( 172 - renderDescription( 173 - eventData.description, 174 - eventData.facets as 175 - | { 176 - index: { byteStart: number; byteEnd: number }; 177 - features: { $type: string; did?: string; uri?: string; tag?: string }[]; 178 - }[] 179 - | undefined 180 - ), 181 - { ADD_ATTR: ['target'] } 182 - ) 183 - : null 184 - ); 185 - 186 - // let smokesignalUrl = $derived(`https://smokesignal.events/${did}/${rkey}`); 187 - let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`); 188 - 189 - let ogImageUrl = $derived(`${page.url.origin}${page.url.pathname}/og.png`); 190 - 191 - let isOwner = $derived(user.isLoggedIn && user.did === did); 192 - 193 - let attendeesRef: EventAttendees | undefined = $state(); 194 - 195 - function handleRsvp(status: 'going' | 'interested') { 196 - if (!user.did) return; 197 - attendeesRef?.addAttendee({ 198 - did: user.did, 199 - status, 200 - avatar: user.profile?.avatar, 201 - name: user.profile?.displayName || user.profile?.handle || user.did 202 - }); 203 - } 204 - 205 - function handleRsvpCancel() { 206 - if (!user.did) return; 207 - attendeesRef?.removeAttendee(user.did); 208 - } 209 - 210 - function downloadIcs() { 211 - const ical = generateICalEvent(eventData, eventUri, page.url.href); 212 - const blob = new Blob([ical], { type: 'text/calendar;charset=utf-8' }); 213 - const url = URL.createObjectURL(blob); 214 - const a = document.createElement('a'); 215 - a.href = url; 216 - a.download = `${eventData.name.replace(/[^a-zA-Z0-9]/g, '-')}.ics`; 217 - a.click(); 218 - URL.revokeObjectURL(url); 219 - } 220 - </script> 221 - 222 - <svelte:head> 223 - <title>{eventData.name}</title> 224 - <meta name="description" content={eventData.description || `Event: ${eventData.name}`} /> 225 - <meta property="og:title" content={eventData.name} /> 226 - <meta property="og:description" content={eventData.description || `Event: ${eventData.name}`} /> 227 - <meta property="og:image" content={ogImageUrl} /> 228 - <meta name="twitter:card" content="summary_large_image" /> 229 - <meta name="twitter:title" content={eventData.name} /> 230 - <meta name="twitter:description" content={eventData.description || `Event: ${eventData.name}`} /> 231 - <meta name="twitter:image" content={ogImageUrl} /> 232 - </svelte:head> 233 - 234 - <div class="min-h-screen px-6 py-12 sm:py-12"> 235 - <div class="mx-auto max-w-3xl"> 236 - <!-- Banner image (full width, only when no thumbnail) --> 237 - {#if isBannerOnly && displayImage} 238 - <img 239 - src={displayImage.url} 240 - alt={displayImage.alt} 241 - class="border-base-200 dark:border-base-800 mb-8 aspect-3/1 w-full rounded-2xl border object-cover" 242 - /> 243 - {/if} 244 - 245 - <!-- Two-column layout: image left, details right --> 246 - <div 247 - 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]" 248 - > 249 - <!-- Thumbnail image (left column) --> 250 - {#if !isBannerOnly} 251 - <div class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none"> 252 - {#if displayImage} 253 - <img 254 - src={displayImage.url} 255 - alt={displayImage.alt} 256 - class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 257 - /> 258 - {:else} 259 - <div 260 - class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border [&>svg]:h-full [&>svg]:w-full" 261 - > 262 - <Avatar 263 - size={256} 264 - name={data.rkey} 265 - variant="marble" 266 - colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 267 - square 268 - /> 269 - </div> 270 - {/if} 271 - </div> 272 - {/if} 273 - 274 - <!-- Right column: event details --> 275 - <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-2 md:row-start-1"> 276 - <div class="mb-2 flex items-start justify-between gap-4"> 277 - <h1 class="text-base-900 dark:text-base-50 text-4xl leading-tight font-bold sm:text-5xl"> 278 - {eventData.name} 279 - </h1> 280 - {#if isOwner} 281 - <Button href="./{rkey}/edit" size="sm" class="shrink-0">Edit</Button> 282 - {/if} 283 - </div> 284 - 285 - <!-- Mode badge --> 286 - {#if eventData.mode} 287 - <div class="mb-8"> 288 - <Badge size="md" variant={getModeColor(eventData.mode)} 289 - >{getModeLabel(eventData.mode)}</Badge 290 - > 291 - </div> 292 - {/if} 293 - 294 - <!-- Date row --> 295 - <div class="mb-4 flex items-center gap-4"> 296 - <div 297 - class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 298 - > 299 - <span class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold"> 300 - {formatMonth(startDate)} 301 - </span> 302 - <span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold"> 303 - {formatDay(startDate)} 304 - </span> 305 - </div> 306 - <div> 307 - <p class="text-base-900 dark:text-base-50 font-semibold"> 308 - {formatWeekday(startDate)}, {formatFullDate(startDate)} 309 - {#if endDate && !isSameDay} 310 - - {formatWeekday(endDate)}, {formatFullDate(endDate)} 311 - {/if} 312 - </p> 313 - <p class="text-base-500 dark:text-base-400 text-sm"> 314 - {formatTime(startDate)} 315 - {#if endDate && isSameDay} 316 - - {formatTime(endDate)} 317 - {/if} 318 - </p> 319 - </div> 320 - </div> 321 - 322 - <!-- Location row --> 323 - {#if location} 324 - <div class="mb-6 flex items-center gap-4"> 325 - <div 326 - class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border" 327 - > 328 - <svg 329 - xmlns="http://www.w3.org/2000/svg" 330 - fill="none" 331 - viewBox="0 0 24 24" 332 - stroke-width="1.5" 333 - stroke="currentColor" 334 - class="text-base-900 dark:text-base-200 size-5" 335 - > 336 - <path 337 - stroke-linecap="round" 338 - stroke-linejoin="round" 339 - d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 340 - /> 341 - <path 342 - stroke-linecap="round" 343 - stroke-linejoin="round" 344 - 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" 345 - /> 346 - </svg> 347 - </div> 348 - <p class="text-base-900 dark:text-base-50 font-semibold">{location}</p> 349 - </div> 350 - {/if} 351 - 352 - <EventRsvp 353 - {eventUri} 354 - eventCid={data.eventCid} 355 - onrsvp={handleRsvp} 356 - oncancel={handleRsvpCancel} 357 - /> 358 - 359 - <!-- About Event --> 360 - {#if descriptionHtml} 361 - <div class="mt-8 mb-8"> 362 - <p 363 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 364 - > 365 - About 366 - </p> 367 - <div 368 - class="text-base-700 dark:text-base-300 prose dark:prose-invert prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-a:hover:underline prose-a:no-underline max-w-none leading-relaxed wrap-break-word" 369 - > 370 - {@html descriptionHtml} 371 - </div> 372 - </div> 373 - {/if} 374 - </div> 375 - 376 - <!-- Left column: sidebar info --> 377 - <div class="order-3 space-y-6 md:order-0 md:col-start-1"> 378 - <!-- Hosted By --> 379 - <div> 380 - <p 381 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 382 - > 383 - Hosted By 384 - </p> 385 - <a 386 - href={hostUrl} 387 - target={hostProfile?.hasBlento ? undefined : '_blank'} 388 - rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 389 - class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium hover:underline" 390 - > 391 - <FoxAvatar 392 - src={hostProfile?.avatar} 393 - alt={hostProfile?.displayName || hostProfile?.handle || did} 394 - class="size-8 shrink-0" 395 - /> 396 - <span class="truncate text-sm"> 397 - {hostProfile?.displayName || hostProfile?.handle || did} 398 - </span> 399 - </a> 400 - </div> 401 - 402 - {#if eventData.uris && eventData.uris.length > 0} 403 - <!-- Links --> 404 - <div> 405 - <p 406 - class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 407 - > 408 - Links 409 - </p> 410 - <div class="space-y-3"> 411 - {#each eventData.uris as link (link.name + link.uri)} 412 - <a 413 - href={link.uri} 414 - target="_blank" 415 - rel="noopener noreferrer" 416 - 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" 417 - > 418 - <svg 419 - xmlns="http://www.w3.org/2000/svg" 420 - fill="none" 421 - viewBox="0 0 24 24" 422 - stroke-width="1.5" 423 - stroke="currentColor" 424 - class="size-3.5 shrink-0" 425 - > 426 - <path 427 - stroke-linecap="round" 428 - stroke-linejoin="round" 429 - 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" 430 - /> 431 - </svg> 432 - <span class="truncate">{link.name || link.uri.replace(/^https?:\/\//, '')}</span> 433 - </a> 434 - {/each} 435 - </div> 436 - </div> 437 - {/if} 438 - 439 - <!-- Add to Calendar --> 440 - <button 441 - onclick={downloadIcs} 442 - 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" 443 - > 444 - <svg 445 - xmlns="http://www.w3.org/2000/svg" 446 - fill="none" 447 - viewBox="0 0 24 24" 448 - stroke-width="1.5" 449 - stroke="currentColor" 450 - class="size-4" 451 - > 452 - <path 453 - stroke-linecap="round" 454 - stroke-linejoin="round" 455 - d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" 456 - /> 457 - </svg> 458 - Add to Calendar 459 - </button> 460 - 461 - <!-- Attendees --> 462 - <EventAttendees bind:this={attendeesRef} {eventUri} /> 463 - </div> 464 - 465 - <!-- View on Smoke Signal link, currently disabled as some events dont work on smokesignal --> 466 - <!-- <a 467 - href={smokesignalUrl} 468 - target="_blank" 469 - rel="noopener noreferrer" 470 - 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" 471 - > 472 - View on Smoke Signal 473 - <svg 474 - xmlns="http://www.w3.org/2000/svg" 475 - fill="none" 476 - viewBox="0 0 24 24" 477 - stroke-width="2" 478 - stroke="currentColor" 479 - class="size-3.5" 480 - > 481 - <path 482 - stroke-linecap="round" 483 - stroke-linejoin="round" 484 - d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 485 - /> 486 - </svg> 487 - </a> --> 488 - </div> 489 - </div> 490 - </div>
-205
src/routes/[[actor=actor]]/events/[rkey]/EventAttendees.svelte
··· 1 - <script lang="ts"> 2 - import { Avatar as FoxAvatar } from '@foxui/core'; 3 - import { onMount } from 'svelte'; 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'; 8 - 9 - let { eventUri }: { eventUri: string } = $props(); 10 - 11 - let goingCount = $state(0); 12 - let interestedCount = $state(0); 13 - let goingAttendees: AttendeeInfo[] = $state([]); 14 - let interestedAttendees: AttendeeInfo[] = $state([]); 15 - let loading = $state(true); 16 - 17 - let modalOpen = $state(false); 18 - let modalGroup: 'going' | 'interested' = $state('going'); 19 - 20 - const MAX_AVATARS = 18; 21 - 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 - } 35 - }); 36 - 37 - let totalCount = $derived(goingCount + interestedCount); 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 - function thumbnail(url: string | undefined) { 68 - return url?.replace('/avatar/', '/avatar_thumbnail/'); 69 - } 70 - 71 - export function removeAttendee(did: string) { 72 - const wasGoing = goingAttendees.some((a) => a.did === did); 73 - const wasInterested = interestedAttendees.some((a) => a.did === did); 74 - goingAttendees = goingAttendees.filter((a) => a.did !== did); 75 - interestedAttendees = interestedAttendees.filter((a) => a.did !== did); 76 - if (wasGoing) goingCount = goingAttendees.length; 77 - if (wasInterested) interestedCount = interestedAttendees.length; 78 - } 79 - </script> 80 - 81 - {#if loading} 82 - <div class="flex items-center gap-3"> 83 - <div class="bg-base-300 dark:bg-base-700 h-3 w-24 animate-pulse rounded"></div> 84 - </div> 85 - {:else if totalCount > 0} 86 - <div class="mb-2"> 87 - {#if goingCount > 0} 88 - <button 89 - type="button" 90 - class="hover:bg-base-100 dark:hover:bg-base-800/50 -mx-2 block w-full cursor-pointer rounded-xl px-2 py-2 text-left transition-colors" 91 - onclick={() => openModal('going')} 92 - > 93 - <p class="text-base-900 dark:text-base-50 mb-2 text-sm"> 94 - <span class="font-bold">{goingCount}</span> 95 - <span 96 - class="text-base-500 dark:text-base-400 text-xs font-semibold tracking-wider uppercase" 97 - >Going</span 98 - > 99 - </p> 100 - <div class="flex items-center"> 101 - <div class="flex flex-wrap -space-y-2 -space-x-4 pr-4"> 102 - {#each goingDisplay as person (person.did)} 103 - <div 104 - animate:flip={{ duration: 300 }} 105 - in:scale={{ duration: 300, start: 0.5 }} 106 - out:scale={{ duration: 200, start: 0.5 }} 107 - > 108 - <FoxAvatar 109 - src={thumbnail(person.avatar)} 110 - alt={person.name} 111 - class="border-base-100 dark:border-base-900 size-12 border-2" 112 - /> 113 - </div> 114 - {/each} 115 - {#if goingOverflow > 0} 116 - <span 117 - 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" 118 - > 119 - +{goingOverflow} 120 - </span> 121 - {/if} 122 - </div> 123 - </div> 124 - </button> 125 - {/if} 126 - 127 - {#if interestedCount > 0} 128 - <button 129 - type="button" 130 - class="hover:bg-base-100 dark:hover:bg-base-800/50 -mx-2 mt-4 block w-full cursor-pointer rounded-xl px-2 py-2 text-left transition-colors" 131 - onclick={() => openModal('interested')} 132 - > 133 - <p class="text-base-900 dark:text-base-50 mb-2 text-sm"> 134 - <span class="font-bold">{interestedCount}</span> 135 - <span 136 - class="text-base-500 dark:text-base-400 text-xs font-semibold tracking-wider uppercase" 137 - >Interested</span 138 - > 139 - </p> 140 - <div class="flex items-center"> 141 - <div class="flex flex-wrap -space-y-2 -space-x-4 pr-4"> 142 - {#each interestedDisplay as person (person.did)} 143 - <div 144 - animate:flip={{ duration: 300 }} 145 - in:scale={{ duration: 300, start: 0.5 }} 146 - out:scale={{ duration: 200, start: 0.5 }} 147 - > 148 - <FoxAvatar 149 - src={thumbnail(person.avatar)} 150 - alt={person.name} 151 - class="border-base-100 dark:border-base-900 size-12 border-2" 152 - /> 153 - </div> 154 - {/each} 155 - {#if interestedOverflow > 0} 156 - <span 157 - 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" 158 - > 159 - +{interestedOverflow} 160 - </span> 161 - {/if} 162 - </div> 163 - </div> 164 - </button> 165 - {/if} 166 - </div> 167 - {/if} 168 - 169 - <Modal 170 - bind:open={modalOpen} 171 - closeButton 172 - onOpenAutoFocus={(e: Event) => e.preventDefault()} 173 - class="p-0" 174 - > 175 - <p class="text-base-900 dark:text-base-50 px-4 pt-4 text-lg font-semibold"> 176 - {modalTitle} 177 - <span class="text-base-500 dark:text-base-400 text-sm font-normal"> 178 - ({modalAttendees.length}) 179 - </span> 180 - </p> 181 - <div 182 - class="dark:bg-base-900/50 bg-base-200/30 mx-4 mb-4 max-h-80 space-y-1 overflow-y-auto rounded-xl p-2" 183 - > 184 - {#each modalAttendees as person (person.did)} 185 - <a 186 - href={person.url} 187 - target={person.url?.startsWith('/') ? undefined : '_blank'} 188 - rel={person.url?.startsWith('/') ? undefined : 'noopener noreferrer'} 189 - class="hover:bg-base-200 dark:hover:bg-base-900 flex items-center gap-3 rounded-xl px-2 py-2 transition-colors" 190 - > 191 - <FoxAvatar src={thumbnail(person.avatar)} alt={person.name} class="size-10 shrink-0" /> 192 - <div class="min-w-0"> 193 - <p class="text-base-900 dark:text-base-50 truncate text-sm font-medium"> 194 - {person.name} 195 - </p> 196 - {#if person.handle} 197 - <p class="text-base-500 dark:text-base-400 truncate text-xs"> 198 - @{person.handle} 199 - </p> 200 - {/if} 201 - </div> 202 - </a> 203 - {/each} 204 - </div> 205 - </Modal>
-245
src/routes/[[actor=actor]]/events/[rkey]/EventRsvp.svelte
··· 1 - <script lang="ts"> 2 - import { user } from '$lib/atproto/auth.svelte'; 3 - import { getRecord, putRecord, deleteRecord, createTID } from '$lib/atproto/methods'; 4 - import { atProtoLoginModalState } from '@foxui/social'; 5 - import { Avatar, Button } from '@foxui/core'; 6 - import type { Did } from '@atcute/lexicons'; 7 - 8 - let { 9 - eventUri, 10 - eventCid, 11 - onrsvp, 12 - oncancel 13 - }: { 14 - eventUri: string; 15 - eventCid: string | null; 16 - onrsvp?: (status: 'going' | 'interested') => void; 17 - oncancel?: () => void; 18 - } = $props(); 19 - 20 - let rsvpStatus: 'going' | 'interested' | 'notgoing' | null = $state(null); 21 - let rsvpRkey: string | null = $state(null); 22 - let rsvpLoading = $state(false); 23 - let rsvpSubmitting = $state(false); 24 - 25 - $effect(() => { 26 - const userDid = user.did; 27 - if (!userDid || user.isInitializing) { 28 - rsvpStatus = null; 29 - rsvpRkey = null; 30 - return; 31 - } 32 - 33 - rsvpLoading = true; 34 - 35 - const url = `https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(eventUri)}&source=community.lexicon.calendar.rsvp:subject.uri&did=${encodeURIComponent(userDid)}&limit=1`; 36 - 37 - fetch(url) 38 - .then((res) => { 39 - if (!res.ok) throw new Error('Failed to fetch backlinks'); 40 - return res.json(); 41 - }) 42 - .then(async (data) => { 43 - if (!data?.records?.length) { 44 - rsvpStatus = null; 45 - rsvpRkey = null; 46 - return; 47 - } 48 - 49 - const backlink = data.records[0]; 50 - rsvpRkey = backlink.rkey; 51 - 52 - const recordData = await getRecord({ 53 - did: backlink.did as Did, 54 - collection: backlink.collection, 55 - rkey: backlink.rkey 56 - }); 57 - 58 - const status = recordData?.value?.status as string; 59 - if (status?.includes('#going')) rsvpStatus = 'going'; 60 - else if (status?.includes('#interested')) rsvpStatus = 'interested'; 61 - else if (status?.includes('#notgoing')) rsvpStatus = 'notgoing'; 62 - else rsvpStatus = null; 63 - }) 64 - .catch(() => { 65 - rsvpStatus = null; 66 - rsvpRkey = null; 67 - }) 68 - .finally(() => { 69 - rsvpLoading = false; 70 - }); 71 - }); 72 - 73 - async function submitRsvp(status: 'going' | 'interested') { 74 - if (!user.client || !user.did) return; 75 - rsvpSubmitting = true; 76 - try { 77 - const key = rsvpRkey ?? createTID(); 78 - 79 - const response = await putRecord({ 80 - collection: 'community.lexicon.calendar.rsvp', 81 - rkey: key, 82 - record: { 83 - $type: 'community.lexicon.calendar.rsvp', 84 - status: `community.lexicon.calendar.rsvp#${status}`, 85 - subject: { 86 - uri: eventUri, 87 - ...(eventCid ? { cid: eventCid } : {}) 88 - }, 89 - createdAt: new Date().toISOString() 90 - } 91 - }); 92 - 93 - if (response.ok) { 94 - rsvpStatus = status; 95 - rsvpRkey = key; 96 - onrsvp?.(status); 97 - refreshRsvpCache(); 98 - } 99 - } catch (e) { 100 - console.error('Failed to submit RSVP:', e); 101 - } finally { 102 - rsvpSubmitting = false; 103 - } 104 - } 105 - 106 - async function cancelRsvp() { 107 - if (!user.client || !user.did || !rsvpRkey) return; 108 - rsvpSubmitting = true; 109 - try { 110 - await deleteRecord({ 111 - collection: 'community.lexicon.calendar.rsvp', 112 - rkey: rsvpRkey 113 - }); 114 - rsvpStatus = null; 115 - rsvpRkey = null; 116 - oncancel?.(); 117 - refreshRsvpCache(); 118 - } catch (e) { 119 - console.error('Failed to cancel RSVP:', e); 120 - } finally { 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(() => {}); 132 - } 133 - } 134 - </script> 135 - 136 - <div 137 - class="border-base-200 dark:border-base-800 bg-base-100 items-between dark:bg-base-950/50 mt-8 mb-2 flex h-25 flex-col justify-center rounded-2xl border p-4" 138 - > 139 - {#if user.isInitializing || rsvpLoading} 140 - <div class="flex items-center gap-3"> 141 - <div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div> 142 - <div class="bg-base-300 dark:bg-base-700 h-4 w-32 animate-pulse rounded"></div> 143 - </div> 144 - {:else if !user.isLoggedIn} 145 - <div class="flex items-center justify-between gap-4"> 146 - <p class="text-base-600 dark:text-base-400 text-sm">Log in to RSVP to this event</p> 147 - 148 - <Button onclick={() => atProtoLoginModalState.show()}>Log in to RSVP</Button> 149 - </div> 150 - {:else if rsvpStatus === 'going'} 151 - <div class="flex items-center justify-between"> 152 - <div class="flex items-center gap-3"> 153 - <div 154 - class="flex size-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30" 155 - > 156 - <svg 157 - xmlns="http://www.w3.org/2000/svg" 158 - viewBox="0 0 20 20" 159 - fill="currentColor" 160 - class="size-4 text-green-600 dark:text-green-400" 161 - > 162 - <path 163 - fill-rule="evenodd" 164 - 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" 165 - clip-rule="evenodd" 166 - /> 167 - </svg> 168 - </div> 169 - <p class="text-base-900 dark:text-base-50 font-semibold">You're Going</p> 170 - </div> 171 - <Button onclick={cancelRsvp} disabled={rsvpSubmitting} variant="ghost">Remove</Button> 172 - </div> 173 - {:else if rsvpStatus === 'interested'} 174 - <div class="flex items-center justify-between"> 175 - <div class="flex items-center gap-3"> 176 - <div 177 - class="flex size-8 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/30" 178 - > 179 - <svg 180 - xmlns="http://www.w3.org/2000/svg" 181 - viewBox="0 0 20 20" 182 - fill="currentColor" 183 - class="size-4 text-amber-600 dark:text-amber-400" 184 - > 185 - <path 186 - fill-rule="evenodd" 187 - 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" 188 - clip-rule="evenodd" 189 - /> 190 - </svg> 191 - </div> 192 - <p class="text-base-900 dark:text-base-50 font-semibold">You're Interested</p> 193 - </div> 194 - <Button onclick={cancelRsvp} disabled={rsvpSubmitting} variant="ghost">Remove</Button> 195 - </div> 196 - {:else if rsvpStatus === 'notgoing'} 197 - <div class="flex items-center justify-between"> 198 - <div class="flex items-center gap-3"> 199 - <div 200 - class="flex size-8 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30" 201 - > 202 - <svg 203 - xmlns="http://www.w3.org/2000/svg" 204 - viewBox="0 0 20 20" 205 - fill="currentColor" 206 - class="size-4 text-red-600 dark:text-red-400" 207 - > 208 - <path 209 - 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" 210 - /> 211 - </svg> 212 - </div> 213 - <p class="text-base-900 dark:text-base-50 font-semibold">Not Going</p> 214 - </div> 215 - <Button onclick={cancelRsvp} disabled={rsvpSubmitting} variant="ghost">Remove</Button> 216 - </div> 217 - {:else} 218 - {#if user.profile} 219 - <div class="mb-4 flex items-center gap-2"> 220 - <span class="text-base-500 dark:text-base-400 text-sm">RSVPing as</span> 221 - <Avatar 222 - src={user.profile.avatar} 223 - alt={user.profile.displayName || user.profile.handle} 224 - class="size-5" 225 - /> 226 - <span class="text-base-700 dark:text-base-300 truncate text-sm font-medium"> 227 - {user.profile.displayName || user.profile.handle} 228 - </span> 229 - </div> 230 - {/if} 231 - <div class="flex gap-3"> 232 - <Button onclick={() => submitRsvp('going')} disabled={rsvpSubmitting} class="flex-1"> 233 - {rsvpSubmitting ? '...' : 'Going'} 234 - </Button> 235 - <Button 236 - onclick={() => submitRsvp('interested')} 237 - disabled={rsvpSubmitting} 238 - variant="secondary" 239 - class="flex-1" 240 - > 241 - {rsvpSubmitting ? '...' : 'Interested'} 242 - </Button> 243 - </div> 244 - {/if} 245 - </div>
-75
src/routes/[[actor=actor]]/events/[rkey]/api.remote.ts
··· 1 - import { query, getRequestEvent } from '$app/server'; 2 - import { createCache } from '$lib/cache'; 3 - import { fetchEventRsvps, resolveProfile } from '$lib/events/fetch-attendees'; 4 - 5 - export type AttendeeInfo = { 6 - did: string; 7 - status: 'going' | 'interested'; 8 - avatar?: string; 9 - name: string; 10 - handle?: string; 11 - url?: string; 12 - }; 13 - 14 - export type EventAttendeesResult = { 15 - going: AttendeeInfo[]; 16 - interested: AttendeeInfo[]; 17 - goingCount: number; 18 - interestedCount: number; 19 - }; 20 - 21 - export const fetchEventAttendees = query( 22 - 'unchecked', 23 - async (eventUri: string): Promise<EventAttendeesResult> => { 24 - const rsvpMap = await fetchEventRsvps(eventUri); 25 - 26 - const going: string[] = []; 27 - const interested: string[] = []; 28 - for (const [did, status] of rsvpMap) { 29 - if (status === 'going') going.push(did); 30 - else interested.push(did); 31 - } 32 - 33 - // Fetch profiles for attendees (with caching) 34 - const uniqueDids = [...new Set([...going, ...interested])]; 35 - const { platform } = getRequestEvent(); 36 - const cache = createCache(platform); 37 - 38 - const profileMap = new Map< 39 - string, 40 - { handle?: string; displayName?: string; avatar?: string; hasBlento?: boolean; url?: string } 41 - >(); 42 - 43 - await Promise.all( 44 - uniqueDids.map(async (did) => { 45 - const profile = await resolveProfile(did, cache).catch(() => null); 46 - if (profile) profileMap.set(did, profile); 47 - }) 48 - ); 49 - 50 - function toAttendeeInfo(did: string, status: 'going' | 'interested'): AttendeeInfo { 51 - const profile = profileMap.get(did); 52 - const handle = profile?.handle; 53 - const url = profile?.hasBlento 54 - ? profile.url || (handle ? `/${handle}` : undefined) 55 - : handle 56 - ? `https://bsky.app/profile/${handle}` 57 - : `https://bsky.app/profile/${did}`; 58 - return { 59 - did, 60 - status, 61 - avatar: profile?.avatar, 62 - name: profile?.displayName || handle || did, 63 - handle, 64 - url 65 - }; 66 - } 67 - 68 - return { 69 - going: going.map((did) => toAttendeeInfo(did, 'going')), 70 - interested: interested.map((did) => toAttendeeInfo(did, 'interested')), 71 - goingCount: going.length, 72 - interestedCount: interested.length 73 - }; 74 - } 75 - );
-65
src/routes/[[actor=actor]]/events/[rkey]/edit/+page.server.ts
··· 1 - import { error } from '@sveltejs/kit'; 2 - import type { EventData } from '$lib/cards/social/EventCard'; 3 - import { getBlentoOrBskyProfile, getRecord } from '$lib/atproto/methods.js'; 4 - import { createCache, type CachedProfile } from '$lib/cache'; 5 - import type { Did } from '@atcute/lexicons'; 6 - import { getActor } from '$lib/actor'; 7 - 8 - export async function load({ params, platform, request }) { 9 - const { rkey } = params; 10 - 11 - const cache = createCache(platform); 12 - 13 - const did = await getActor({ request, paramActor: params.actor, platform }); 14 - 15 - if (!did || !rkey) { 16 - throw error(404, 'Event not found'); 17 - } 18 - 19 - try { 20 - const [eventRecord, hostProfile] = await Promise.all([ 21 - getRecord({ 22 - did: did as Did, 23 - collection: 'community.lexicon.calendar.event', 24 - rkey 25 - }).catch(() => null), 26 - cache 27 - ? cache.getProfile(did as Did).catch(() => null) 28 - : getBlentoOrBskyProfile({ did: did as Did }) 29 - .then( 30 - (p): CachedProfile => ({ 31 - did: p.did as string, 32 - handle: p.handle as string, 33 - displayName: p.displayName as string | undefined, 34 - avatar: p.avatar as string | undefined, 35 - hasBlento: p.hasBlento, 36 - url: p.url 37 - }) 38 - ) 39 - .catch(() => null) 40 - ]); 41 - 42 - if (!eventRecord?.value) { 43 - return { 44 - eventData: null, 45 - did, 46 - rkey, 47 - hostProfile: hostProfile ?? null, 48 - eventCid: null 49 - }; 50 - } 51 - 52 - const eventData: EventData = eventRecord.value as EventData; 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 - }
-1259
src/routes/[[actor=actor]]/events/[rkey]/edit/+page.svelte
··· 1 - <script lang="ts"> 2 - import { user } from '$lib/atproto/auth.svelte'; 3 - import { atProtoLoginModalState } from '@foxui/social'; 4 - import { uploadBlob, putRecord, deleteRecord, resolveHandle } from '$lib/atproto/methods'; 5 - import { getCDNImageBlobUrl } from '$lib/atproto'; 6 - import { compressImage } from '$lib/atproto/image-helper'; 7 - import { validateLink } from '$lib/helper'; 8 - import { 9 - Avatar as FoxAvatar, 10 - Button, 11 - PopoverRoot, 12 - PopoverTrigger, 13 - PopoverContent, 14 - ToggleGroup, 15 - ToggleGroupItem, 16 - Input 17 - } from '@foxui/core'; 18 - import { goto } from '$app/navigation'; 19 - import { tokenize, type Token } from '@atcute/bluesky-richtext-parser'; 20 - import type { Handle } from '@atcute/lexicons'; 21 - import { onMount } from 'svelte'; 22 - import { browser } from '$app/environment'; 23 - import { putImage, getImage, deleteImage } from '$lib/components/image-store'; 24 - import Modal from '$lib/components/modal/Modal.svelte'; 25 - import Avatar from 'svelte-boring-avatars'; 26 - import DateTimePicker from '$lib/components/DateTimePicker.svelte'; 27 - 28 - let { data } = $props(); 29 - 30 - let rkey: string = $derived(data.rkey); 31 - let isNew = $derived(data.eventData === null); 32 - let DRAFT_KEY = $derived(`blento-event-edit-${rkey}`); 33 - 34 - type EventMode = 'inperson' | 'virtual' | 'hybrid'; 35 - 36 - interface EventLocation { 37 - street?: string; 38 - locality?: string; 39 - region?: string; 40 - country?: string; 41 - } 42 - 43 - interface EventDraft { 44 - name: string; 45 - description: string; 46 - startsAt: string; 47 - endsAt: string; 48 - links: Array<{ uri: string; name: string }>; 49 - mode?: EventMode; 50 - thumbnailKey?: string; 51 - thumbnailChanged?: boolean; 52 - location?: EventLocation | null; 53 - locationChanged?: boolean; 54 - } 55 - 56 - let thumbnailKey: string | null = $state(null); 57 - let thumbnailChanged = $state(false); 58 - 59 - let name = $state(''); 60 - let description = $state(''); 61 - let startsAt = $state(''); 62 - let endsAt = $state(''); 63 - let mode: EventMode = $state('inperson'); 64 - let thumbnailFile: File | null = $state(null); 65 - let thumbnailPreview: string | null = $state(null); 66 - let submitting = $state(false); 67 - let error: string | null = $state(null); 68 - let titleEl: HTMLTextAreaElement | undefined = $state(undefined); 69 - 70 - let location: EventLocation | null = $state(null); 71 - let locationChanged = $state(false); 72 - let showLocationModal = $state(false); 73 - let locationSearch = $state(''); 74 - let locationSearching = $state(false); 75 - let locationError = $state(''); 76 - let locationResult: { displayName: string; location: EventLocation } | null = $state(null); 77 - 78 - let links: Array<{ uri: string; name: string }> = $state([]); 79 - let editingDates = $state(false); 80 - let showLinkPopup = $state(false); 81 - let newLinkUri = $state(''); 82 - let newLinkName = $state(''); 83 - let linkError = $state(''); 84 - 85 - let draftLoaded = $state(false); 86 - 87 - function isoToDatetimeLocal(iso: string): string { 88 - const date = new Date(iso); 89 - const pad = (n: number) => n.toString().padStart(2, '0'); 90 - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; 91 - } 92 - 93 - function stripModePrefix(modeStr: string): EventMode { 94 - const stripped = modeStr.replace('community.lexicon.calendar.event#', ''); 95 - if (stripped === 'virtual' || stripped === 'hybrid' || stripped === 'inperson') return stripped; 96 - return 'inperson'; 97 - } 98 - 99 - function populateLocationFromEventData() { 100 - const eventData = data.eventData; 101 - if (!eventData) return; 102 - if (eventData.locations && eventData.locations.length > 0) { 103 - const loc = eventData.locations.find((v) => v.$type === 'community.lexicon.location.address'); 104 - if (loc) { 105 - const flat = loc as Record<string, unknown>; 106 - const nested = loc.address; 107 - const street = (flat.street as string) || undefined; 108 - const locality = (flat.locality as string) || nested?.locality; 109 - const region = (flat.region as string) || nested?.region; 110 - const country = (flat.country as string) || nested?.country; 111 - location = { 112 - ...(street && { street }), 113 - ...(locality && { locality }), 114 - ...(region && { region }), 115 - ...(country && { country }) 116 - }; 117 - } 118 - } 119 - locationChanged = false; 120 - } 121 - 122 - function populateThumbnailFromEventData() { 123 - const eventData = data.eventData; 124 - if (!eventData) return; 125 - if (eventData.media && eventData.media.length > 0) { 126 - const media = eventData.media.find((m) => m.role === 'thumbnail'); 127 - if (media?.content) { 128 - const url = getCDNImageBlobUrl({ did: data.did, blob: media.content, type: 'jpeg' }); 129 - if (url) { 130 - thumbnailPreview = url; 131 - thumbnailChanged = false; 132 - } 133 - } 134 - } 135 - } 136 - 137 - function populateFromEventData() { 138 - const eventData = data.eventData; 139 - if (!eventData) return; 140 - name = eventData.name || ''; 141 - description = eventData.description || ''; 142 - startsAt = eventData.startsAt ? isoToDatetimeLocal(eventData.startsAt) : ''; 143 - endsAt = eventData.endsAt ? isoToDatetimeLocal(eventData.endsAt) : ''; 144 - mode = eventData.mode ? stripModePrefix(eventData.mode) : 'inperson'; 145 - links = eventData.uris ? eventData.uris.map((l) => ({ uri: l.uri, name: l.name || '' })) : []; 146 - populateLocationFromEventData(); 147 - populateThumbnailFromEventData(); 148 - } 149 - 150 - onMount(async () => { 151 - // Migrate old creation draft if this is a new event 152 - if (isNew) { 153 - const oldDraft = localStorage.getItem('blento-event-draft'); 154 - if (oldDraft && !localStorage.getItem(DRAFT_KEY)) { 155 - localStorage.setItem(DRAFT_KEY, oldDraft); 156 - localStorage.removeItem('blento-event-draft'); 157 - } 158 - } 159 - 160 - const saved = localStorage.getItem(DRAFT_KEY); 161 - if (saved) { 162 - try { 163 - const draft: EventDraft = JSON.parse(saved); 164 - name = draft.name || ''; 165 - description = draft.description || ''; 166 - startsAt = draft.startsAt || ''; 167 - endsAt = draft.endsAt || ''; 168 - links = draft.links || []; 169 - mode = draft.mode || 'inperson'; 170 - locationChanged = draft.locationChanged || false; 171 - if (draft.locationChanged) { 172 - location = draft.location || null; 173 - } else if (!isNew) { 174 - // For edits without location changes, load from event data 175 - populateLocationFromEventData(); 176 - } 177 - thumbnailChanged = draft.thumbnailChanged || false; 178 - 179 - if (draft.thumbnailKey) { 180 - const img = await getImage(draft.thumbnailKey); 181 - if (img) { 182 - thumbnailKey = draft.thumbnailKey; 183 - thumbnailFile = new File([img.blob], img.name, { type: img.blob.type }); 184 - thumbnailPreview = URL.createObjectURL(img.blob); 185 - thumbnailChanged = true; 186 - } 187 - } else if (!thumbnailChanged && !isNew) { 188 - // No new thumbnail in draft, show existing one from event data 189 - populateThumbnailFromEventData(); 190 - } 191 - } catch { 192 - localStorage.removeItem(DRAFT_KEY); 193 - if (!isNew) populateFromEventData(); 194 - } 195 - } else if (!isNew) { 196 - populateFromEventData(); 197 - } 198 - draftLoaded = true; 199 - if (!startsAt) editingDates = true; 200 - titleEl?.focus(); 201 - }); 202 - 203 - let saveDraftTimeout: ReturnType<typeof setTimeout> | undefined; 204 - 205 - function saveDraft() { 206 - if (!draftLoaded || !browser) return; 207 - clearTimeout(saveDraftTimeout); 208 - saveDraftTimeout = setTimeout(() => { 209 - const draft: EventDraft = { 210 - name, 211 - description, 212 - startsAt, 213 - endsAt, 214 - links, 215 - mode, 216 - thumbnailChanged, 217 - locationChanged 218 - }; 219 - if (locationChanged) draft.location = location; 220 - if (thumbnailKey) draft.thumbnailKey = thumbnailKey; 221 - localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); 222 - }, 500); 223 - } 224 - 225 - $effect(() => { 226 - // track all draft fields by reading them 227 - void [ 228 - name, 229 - description, 230 - startsAt, 231 - endsAt, 232 - mode, 233 - JSON.stringify(links), 234 - JSON.stringify(location) 235 - ]; 236 - saveDraft(); 237 - }); 238 - 239 - async function searchLocation() { 240 - const q = locationSearch.trim(); 241 - if (!q) return; 242 - locationError = ''; 243 - locationSearching = true; 244 - locationResult = null; 245 - 246 - try { 247 - const response = await fetch('/api/geocoding?q=' + encodeURIComponent(q)); 248 - if (!response.ok) throw new Error('response not ok'); 249 - const data = await response.json(); 250 - if (!data || data.error) throw new Error('no results'); 251 - 252 - const addr = data.address || {}; 253 - const road = addr.road || ''; 254 - const houseNumber = addr.house_number || ''; 255 - const street = road ? (houseNumber ? `${road} ${houseNumber}` : road) : ''; 256 - const locality = 257 - addr.city || addr.town || addr.village || addr.municipality || addr.hamlet || ''; 258 - const region = addr.state || addr.county || ''; 259 - const country = addr.country || ''; 260 - 261 - locationResult = { 262 - displayName: data.display_name || q, 263 - location: { 264 - ...(street && { street }), 265 - ...(locality && { locality }), 266 - ...(region && { region }), 267 - ...(country && { country }) 268 - } 269 - }; 270 - } catch { 271 - locationError = "Couldn't find that location."; 272 - } finally { 273 - locationSearching = false; 274 - } 275 - } 276 - 277 - function confirmLocation() { 278 - if (locationResult) { 279 - location = locationResult.location; 280 - locationChanged = true; 281 - } 282 - showLocationModal = false; 283 - locationSearch = ''; 284 - locationResult = null; 285 - locationError = ''; 286 - } 287 - 288 - function removeLocation() { 289 - location = null; 290 - locationChanged = true; 291 - } 292 - 293 - function getLocationDisplayString(loc: EventLocation): string { 294 - return [loc.street, loc.locality, loc.region, loc.country].filter(Boolean).join(', '); 295 - } 296 - 297 - function addLink() { 298 - const raw = newLinkUri.trim(); 299 - if (!raw) return; 300 - const uri = validateLink(raw); 301 - if (!uri) { 302 - linkError = 'Please enter a valid URL'; 303 - return; 304 - } 305 - links.push({ uri, name: newLinkName.trim() }); 306 - newLinkUri = ''; 307 - newLinkName = ''; 308 - linkError = ''; 309 - showLinkPopup = false; 310 - } 311 - 312 - function removeLink(index: number) { 313 - links.splice(index, 1); 314 - } 315 - 316 - let fileInput: HTMLInputElement | undefined = $state(); 317 - 318 - let hostName = $derived(user.profile?.displayName || user.profile?.handle || user.did || ''); 319 - 320 - async function setThumbnail(file: File) { 321 - thumbnailFile = file; 322 - thumbnailChanged = true; 323 - if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 324 - thumbnailPreview = URL.createObjectURL(file); 325 - 326 - if (thumbnailKey) await deleteImage(thumbnailKey); 327 - thumbnailKey = crypto.randomUUID(); 328 - await putImage(thumbnailKey, file, file.name); 329 - saveDraft(); 330 - } 331 - 332 - async function onFileChange(e: Event) { 333 - const input = e.target as HTMLInputElement; 334 - const file = input.files?.[0]; 335 - if (!file) return; 336 - setThumbnail(file); 337 - } 338 - 339 - let isDragOver = $state(false); 340 - 341 - function onDragOver(e: DragEvent) { 342 - e.preventDefault(); 343 - isDragOver = true; 344 - } 345 - 346 - function onDragLeave(e: DragEvent) { 347 - e.preventDefault(); 348 - isDragOver = false; 349 - } 350 - 351 - function onDrop(e: DragEvent) { 352 - e.preventDefault(); 353 - isDragOver = false; 354 - const file = e.dataTransfer?.files?.[0]; 355 - if (file?.type.startsWith('image/')) { 356 - setThumbnail(file); 357 - } 358 - } 359 - 360 - function removeThumbnail() { 361 - thumbnailFile = null; 362 - thumbnailChanged = true; 363 - if (thumbnailPreview) { 364 - URL.revokeObjectURL(thumbnailPreview); 365 - thumbnailPreview = null; 366 - } 367 - if (thumbnailKey) { 368 - deleteImage(thumbnailKey); 369 - thumbnailKey = null; 370 - } 371 - if (fileInput) fileInput.value = ''; 372 - saveDraft(); 373 - } 374 - 375 - function formatMonth(date: Date): string { 376 - return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); 377 - } 378 - 379 - function formatDay(date: Date): number { 380 - return date.getDate(); 381 - } 382 - 383 - function formatWeekday(date: Date): string { 384 - return date.toLocaleDateString('en-US', { weekday: 'long' }); 385 - } 386 - 387 - function formatFullDate(date: Date): string { 388 - const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' }; 389 - if (date.getFullYear() !== new Date().getFullYear()) { 390 - options.year = 'numeric'; 391 - } 392 - return date.toLocaleDateString('en-US', options); 393 - } 394 - 395 - function formatTime(date: Date): string { 396 - return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); 397 - } 398 - 399 - let startDate = $derived(startsAt ? new Date(startsAt) : null); 400 - let endDate = $derived(endsAt ? new Date(endsAt) : null); 401 - let isSameDay = $derived( 402 - startDate && 403 - endDate && 404 - startDate.getFullYear() === endDate.getFullYear() && 405 - startDate.getMonth() === endDate.getMonth() && 406 - startDate.getDate() === endDate.getDate() 407 - ); 408 - 409 - // Auto-adjust end date if start moves past it 410 - $effect(() => { 411 - if (startsAt && endsAt) { 412 - const s = new Date(startsAt); 413 - const e = new Date(endsAt); 414 - if (s >= e) { 415 - // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary local, not reactive state 416 - const adjusted = new Date(s); 417 - adjusted.setHours(adjusted.getHours() + 1); 418 - endsAt = isoToDatetimeLocal(adjusted.toISOString()); 419 - } 420 - } 421 - }); 422 - 423 - async function tokensToFacets(tokens: Token[]): Promise<Record<string, unknown>[]> { 424 - const encoder = new TextEncoder(); 425 - const facets: Record<string, unknown>[] = []; 426 - let byteOffset = 0; 427 - 428 - for (const token of tokens) { 429 - const tokenBytes = encoder.encode(token.raw); 430 - const byteStart = byteOffset; 431 - const byteEnd = byteOffset + tokenBytes.length; 432 - 433 - if (token.type === 'mention') { 434 - try { 435 - const did = await resolveHandle({ handle: token.handle as Handle }); 436 - if (did) { 437 - facets.push({ 438 - index: { byteStart, byteEnd }, 439 - features: [{ $type: 'app.bsky.richtext.facet#mention', did }] 440 - }); 441 - } 442 - } catch { 443 - // skip unresolvable mentions 444 - } 445 - } else if (token.type === 'autolink') { 446 - facets.push({ 447 - index: { byteStart, byteEnd }, 448 - features: [{ $type: 'app.bsky.richtext.facet#link', uri: token.url }] 449 - }); 450 - } else if (token.type === 'topic') { 451 - facets.push({ 452 - index: { byteStart, byteEnd }, 453 - features: [{ $type: 'app.bsky.richtext.facet#tag', tag: token.name }] 454 - }); 455 - } 456 - 457 - byteOffset = byteEnd; 458 - } 459 - 460 - return facets; 461 - } 462 - 463 - async function handleSubmit() { 464 - error = null; 465 - 466 - if (!name.trim()) { 467 - error = 'Name is required.'; 468 - return; 469 - } 470 - if (!startsAt) { 471 - error = 'Start date is required.'; 472 - return; 473 - } 474 - if (!user.client || !user.did) { 475 - error = 'You must be logged in.'; 476 - return; 477 - } 478 - 479 - submitting = true; 480 - 481 - try { 482 - let media: Array<Record<string, unknown>> | undefined; 483 - 484 - if (isNew || thumbnailChanged) { 485 - if (thumbnailFile) { 486 - const compressed = await compressImage(thumbnailFile); 487 - const blobRef = await uploadBlob({ blob: compressed.blob }); 488 - if (blobRef) { 489 - media = [ 490 - { 491 - role: 'thumbnail', 492 - content: blobRef, 493 - aspect_ratio: { 494 - width: compressed.aspectRatio.width, 495 - height: compressed.aspectRatio.height 496 - } 497 - } 498 - ]; 499 - } 500 - } 501 - // If changed/new but no thumbnailFile, media stays undefined (thumbnail removed/absent) 502 - } else { 503 - // Thumbnail not changed — reuse original media from eventData 504 - if (data.eventData?.media && data.eventData.media.length > 0) { 505 - media = data.eventData.media as Array<Record<string, unknown>>; 506 - } 507 - } 508 - 509 - const createdAt = isNew 510 - ? new Date().toISOString() 511 - : ((data.eventData as Record<string, unknown>)?.createdAt as string) || 512 - new Date().toISOString(); 513 - 514 - const record: Record<string, unknown> = { 515 - $type: 'community.lexicon.calendar.event', 516 - name: name.trim(), 517 - mode: `community.lexicon.calendar.event#${mode}`, 518 - status: 'community.lexicon.calendar.event#scheduled', 519 - startsAt: new Date(startsAt).toISOString(), 520 - createdAt 521 - }; 522 - 523 - const trimmedDescription = description.trim(); 524 - if (trimmedDescription) { 525 - record.description = trimmedDescription; 526 - const tokens = tokenize(trimmedDescription); 527 - const facets = await tokensToFacets(tokens); 528 - if (facets.length > 0) { 529 - record.facets = facets; 530 - } 531 - } 532 - if (endsAt) { 533 - record.endsAt = new Date(endsAt).toISOString(); 534 - } 535 - if (media) { 536 - record.media = media; 537 - } 538 - if (links.length > 0) { 539 - record.uris = links; 540 - } 541 - if (isNew || locationChanged) { 542 - if (location) { 543 - record.locations = [ 544 - { 545 - $type: 'community.lexicon.location.address', 546 - ...location 547 - } 548 - ]; 549 - } 550 - // If changed/new but no location, locations stays undefined (removed/absent) 551 - } else if (data.eventData?.locations && data.eventData.locations.length > 0) { 552 - record.locations = data.eventData.locations; 553 - } 554 - 555 - const response = await putRecord({ 556 - collection: 'community.lexicon.calendar.event', 557 - rkey, 558 - record 559 - }); 560 - 561 - if (response.ok) { 562 - localStorage.removeItem(DRAFT_KEY); 563 - if (thumbnailKey) deleteImage(thumbnailKey); 564 - const handle = 565 - user.profile?.handle && user.profile.handle !== 'handle.invalid' 566 - ? user.profile.handle 567 - : user.did; 568 - fetch(`/${handle}/events/api/refresh`).catch(() => {}); 569 - goto(`/${handle}/events/${rkey}`); 570 - } else { 571 - error = `Failed to ${isNew ? 'create' : 'save'} event. Please try again.`; 572 - } 573 - } catch (e) { 574 - console.error(`Failed to ${isNew ? 'create' : 'save'} event:`, e); 575 - error = `Failed to ${isNew ? 'create' : 'save'} event. Please try again.`; 576 - } finally { 577 - submitting = false; 578 - } 579 - } 580 - 581 - let showDeleteConfirm = $state(false); 582 - let deleting = $state(false); 583 - 584 - async function handleDelete() { 585 - deleting = true; 586 - try { 587 - await deleteRecord({ 588 - collection: 'community.lexicon.calendar.event', 589 - rkey 590 - }); 591 - localStorage.removeItem(DRAFT_KEY); 592 - if (thumbnailKey) deleteImage(thumbnailKey); 593 - const handle = 594 - user.profile?.handle && user.profile.handle !== 'handle.invalid' 595 - ? user.profile.handle 596 - : user.did; 597 - fetch(`/${handle}/events/api/refresh`).catch(() => {}); 598 - goto(`/${handle}/events`); 599 - } catch (e) { 600 - console.error('Failed to delete event:', e); 601 - error = 'Failed to delete event. Please try again.'; 602 - } finally { 603 - deleting = false; 604 - showDeleteConfirm = false; 605 - } 606 - } 607 - </script> 608 - 609 - <svelte:head> 610 - <title>{isNew ? 'Create Event' : 'Edit Event'}</title> 611 - </svelte:head> 612 - 613 - <div class="min-h-screen px-6 py-12 sm:py-12"> 614 - <div class="mx-auto max-w-3xl"> 615 - {#if user.isInitializing} 616 - <div class="flex items-center gap-3"> 617 - <div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div> 618 - <div class="bg-base-300 dark:bg-base-700 h-4 w-48 animate-pulse rounded"></div> 619 - </div> 620 - {:else if !user.isLoggedIn} 621 - <div 622 - class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 rounded-2xl border p-8 text-center" 623 - > 624 - <p class="text-base-600 dark:text-base-400 mb-4"> 625 - Log in to {isNew ? 'create an event' : 'edit this event'}. 626 - </p> 627 - <Button onclick={() => atProtoLoginModalState.show()}>Log in</Button> 628 - </div> 629 - {:else} 630 - <form 631 - onsubmit={(e) => { 632 - e.preventDefault(); 633 - handleSubmit(); 634 - }} 635 - > 636 - <!-- Two-column layout mirroring detail page --> 637 - <div 638 - 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]" 639 - > 640 - <!-- Thumbnail (left column) --> 641 - <!-- svelte-ignore a11y_no_static_element_interactions --> 642 - <div 643 - class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none" 644 - ondragover={onDragOver} 645 - ondragleave={onDragLeave} 646 - ondrop={onDrop} 647 - > 648 - <input 649 - bind:this={fileInput} 650 - type="file" 651 - accept="image/*" 652 - onchange={onFileChange} 653 - class="hidden" 654 - /> 655 - <div class="group relative"> 656 - {#if thumbnailPreview} 657 - <img 658 - src={thumbnailPreview} 659 - alt="Thumbnail preview" 660 - class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 661 - /> 662 - {:else} 663 - <div 664 - class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full" 665 - > 666 - <Avatar 667 - size={400} 668 - name={rkey} 669 - variant="marble" 670 - colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 671 - square 672 - /> 673 - </div> 674 - {/if} 675 - <!-- Upload overlay on hover --> 676 - <button 677 - type="button" 678 - onclick={() => fileInput?.click()} 679 - class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-2xl bg-black/0 text-white/0 transition-colors group-hover:bg-black/40 group-hover:text-white/90 {isDragOver 680 - ? 'bg-black/40 text-white/90' 681 - : ''}" 682 - > 683 - <svg 684 - xmlns="http://www.w3.org/2000/svg" 685 - fill="none" 686 - viewBox="0 0 24 24" 687 - stroke-width="1.5" 688 - stroke="currentColor" 689 - class="size-6" 690 - > 691 - <path 692 - stroke-linecap="round" 693 - stroke-linejoin="round" 694 - d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" 695 - /> 696 - </svg> 697 - <span class="text-sm font-medium">Upload thumbnail</span> 698 - </button> 699 - {#if thumbnailPreview} 700 - <Button 701 - variant="ghost" 702 - size="iconSm" 703 - onclick={removeThumbnail} 704 - class="bg-base-900/70 absolute top-2 right-2 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600" 705 - > 706 - <svg 707 - xmlns="http://www.w3.org/2000/svg" 708 - viewBox="0 0 20 20" 709 - fill="currentColor" 710 - class="size-3.5" 711 - > 712 - <path 713 - 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" 714 - /> 715 - </svg> 716 - </Button> 717 - {/if} 718 - </div> 719 - </div> 720 - 721 - <!-- Right column: event details --> 722 - <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> 723 - <!-- Name + Save button --> 724 - <div class="mb-2 flex items-start justify-between gap-4"> 725 - <textarea 726 - bind:this={titleEl} 727 - bind:value={name} 728 - required 729 - placeholder="Event name" 730 - rows={1} 731 - class="text-base-900 dark:text-base-50 placeholder:text-base-500 dark:placeholder:text-base-500 w-full min-w-0 resize-none border-0 bg-transparent px-0 text-4xl leading-tight font-bold focus:border-0 focus:ring-0 focus:outline-none sm:text-5xl" 732 - style="field-sizing: content;" 733 - ></textarea> 734 - <Button 735 - type="submit" 736 - size="sm" 737 - class="shrink-0" 738 - disabled={submitting || !name.trim() || !startsAt} 739 - > 740 - {submitting ? (isNew ? 'Creating...' : 'Saving...') : isNew ? 'Create' : 'Save'} 741 - </Button> 742 - </div> 743 - 744 - <!-- Mode toggle --> 745 - <div class="mb-8"> 746 - <ToggleGroup 747 - type="single" 748 - bind:value={ 749 - () => { 750 - return mode; 751 - }, 752 - (val) => { 753 - if (val) mode = val; 754 - } 755 - } 756 - class="w-fit" 757 - > 758 - <ToggleGroupItem size="sm" value="inperson">In Person</ToggleGroupItem> 759 - <ToggleGroupItem size="sm" value="virtual">Virtual</ToggleGroupItem> 760 - <ToggleGroupItem size="sm" value="hybrid">Hybrid</ToggleGroupItem> 761 - </ToggleGroup> 762 - </div> 763 - 764 - <!-- Date row --> 765 - <div class="mb-4 flex items-start gap-4"> 766 - <div 767 - class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 768 - > 769 - {#if startDate} 770 - <span 771 - class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold" 772 - > 773 - {formatMonth(startDate)} 774 - </span> 775 - <span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold"> 776 - {formatDay(startDate)} 777 - </span> 778 - {:else} 779 - <svg 780 - xmlns="http://www.w3.org/2000/svg" 781 - fill="none" 782 - viewBox="0 0 24 24" 783 - stroke-width="1.5" 784 - stroke="currentColor" 785 - class="text-base-900 dark:text-base-200 size-5" 786 - > 787 - <path 788 - stroke-linecap="round" 789 - stroke-linejoin="round" 790 - d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" 791 - /> 792 - </svg> 793 - {/if} 794 - </div> 795 - <div class="flex-1"> 796 - {#if startDate && !editingDates} 797 - <!-- Display mode: show formatted date, click to edit --> 798 - <div class="flex items-start gap-2"> 799 - <button 800 - type="button" 801 - onclick={() => (editingDates = true)} 802 - class="cursor-pointer text-left" 803 - > 804 - <p class="text-base-900 dark:text-base-50 font-semibold"> 805 - {formatWeekday(startDate)}, {formatFullDate(startDate)} 806 - {#if endDate && !isSameDay} 807 - - {formatWeekday(endDate)}, {formatFullDate(endDate)} 808 - {/if} 809 - </p> 810 - <p class="text-base-500 dark:text-base-400 text-sm"> 811 - {formatTime(startDate)} 812 - {#if endDate && isSameDay} 813 - - {formatTime(endDate)} 814 - {/if} 815 - </p> 816 - </button> 817 - <Button variant="ghost" size="iconSm" onclick={() => (editingDates = true)}> 818 - <svg 819 - xmlns="http://www.w3.org/2000/svg" 820 - fill="none" 821 - viewBox="0 0 24 24" 822 - stroke-width="1.5" 823 - stroke="currentColor" 824 - class="size-3.5" 825 - > 826 - <path 827 - stroke-linecap="round" 828 - stroke-linejoin="round" 829 - d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" 830 - /> 831 - </svg> 832 - </Button> 833 - </div> 834 - {:else} 835 - <!-- Edit mode: show pickers --> 836 - <div class="flex flex-col gap-2"> 837 - <div class="flex items-center gap-2"> 838 - {#if endsAt} 839 - <span class="text-base-500 dark:text-base-400 w-9 text-xs">Start</span> 840 - {/if} 841 - <DateTimePicker bind:value={startsAt} required /> 842 - </div> 843 - {#if endsAt} 844 - <div class="flex items-center gap-2"> 845 - <span class="text-base-500 dark:text-base-400 w-9 text-xs">End</span> 846 - <DateTimePicker bind:value={endsAt} minValue={startsAt} /> 847 - <Button variant="ghost" size="iconSm" onclick={() => (endsAt = '')}> 848 - <svg 849 - xmlns="http://www.w3.org/2000/svg" 850 - fill="none" 851 - viewBox="0 0 24 24" 852 - stroke-width="1.5" 853 - stroke="currentColor" 854 - class="size-3.5" 855 - > 856 - <path 857 - stroke-linecap="round" 858 - stroke-linejoin="round" 859 - d="M6 18 18 6M6 6l12 12" 860 - /> 861 - </svg> 862 - </Button> 863 - </div> 864 - {:else} 865 - <Button 866 - variant="ghost" 867 - size="sm" 868 - class="w-fit" 869 - onclick={() => { 870 - if (startsAt) { 871 - const d = new Date(startsAt); 872 - d.setHours(d.getHours() + 1); 873 - endsAt = isoToDatetimeLocal(d.toISOString()); 874 - } else { 875 - endsAt = ''; 876 - } 877 - }} 878 - > 879 - <svg 880 - xmlns="http://www.w3.org/2000/svg" 881 - fill="none" 882 - viewBox="0 0 24 24" 883 - stroke-width="1.5" 884 - stroke="currentColor" 885 - class="size-3.5" 886 - > 887 - <path 888 - stroke-linecap="round" 889 - stroke-linejoin="round" 890 - d="M12 4.5v15m7.5-7.5h-15" 891 - /> 892 - </svg> 893 - Add end date 894 - </Button> 895 - {/if} 896 - {#if startDate} 897 - <Button size="sm" onclick={() => (editingDates = false)} class="mt-1 w-fit"> 898 - <svg 899 - xmlns="http://www.w3.org/2000/svg" 900 - fill="none" 901 - viewBox="0 0 24 24" 902 - stroke-width="2" 903 - stroke="currentColor" 904 - class="size-3.5" 905 - > 906 - <path 907 - stroke-linecap="round" 908 - stroke-linejoin="round" 909 - d="m4.5 12.75 6 6 9-13.5" 910 - /> 911 - </svg> 912 - Done 913 - </Button> 914 - {/if} 915 - </div> 916 - {/if} 917 - </div> 918 - </div> 919 - 920 - <!-- Location row --> 921 - {#if location} 922 - <div class="mb-6 flex items-center gap-4"> 923 - <div 924 - class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border" 925 - > 926 - <svg 927 - xmlns="http://www.w3.org/2000/svg" 928 - fill="none" 929 - viewBox="0 0 24 24" 930 - stroke-width="1.5" 931 - stroke="currentColor" 932 - class="text-base-900 dark:text-base-200 size-5" 933 - > 934 - <path 935 - stroke-linecap="round" 936 - stroke-linejoin="round" 937 - d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 938 - /> 939 - <path 940 - stroke-linecap="round" 941 - stroke-linejoin="round" 942 - 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" 943 - /> 944 - </svg> 945 - </div> 946 - <p class="text-base-900 dark:text-base-50 flex-1 font-semibold"> 947 - {getLocationDisplayString(location)} 948 - </p> 949 - <Button variant="ghost" size="iconSm" onclick={removeLocation} class="shrink-0"> 950 - <svg 951 - xmlns="http://www.w3.org/2000/svg" 952 - viewBox="0 0 20 20" 953 - fill="currentColor" 954 - class="size-3.5" 955 - > 956 - <path 957 - 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" 958 - /> 959 - </svg> 960 - </Button> 961 - </div> 962 - {:else} 963 - <div class="mb-6"> 964 - <Button variant="ghost" onclick={() => (showLocationModal = true)}> 965 - <svg 966 - xmlns="http://www.w3.org/2000/svg" 967 - fill="none" 968 - viewBox="0 0 24 24" 969 - stroke-width="1.5" 970 - stroke="currentColor" 971 - class="size-4" 972 - > 973 - <path 974 - stroke-linecap="round" 975 - stroke-linejoin="round" 976 - d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 977 - /> 978 - <path 979 - stroke-linecap="round" 980 - stroke-linejoin="round" 981 - 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" 982 - /> 983 - </svg> 984 - Add location 985 - </Button> 986 - </div> 987 - {/if} 988 - 989 - <!-- About Event --> 990 - <div class="mt-8 mb-8"> 991 - <p 992 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 993 - > 994 - About 995 - </p> 996 - <textarea 997 - bind:value={description} 998 - rows={4} 999 - placeholder="What's this event about? @mentions, #hashtags and links will be detected automatically." 1000 - 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 px-0 leading-relaxed focus:border-0 focus:ring-0 focus:outline-none" 1001 - style="field-sizing: content;" 1002 - ></textarea> 1003 - </div> 1004 - 1005 - {#if error} 1006 - <p class="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p> 1007 - {/if} 1008 - 1009 - <Button type="submit" disabled={submitting || !name.trim() || !startsAt}> 1010 - {submitting 1011 - ? isNew 1012 - ? 'Creating...' 1013 - : 'Saving...' 1014 - : isNew 1015 - ? 'Create Event' 1016 - : 'Save Changes'} 1017 - </Button> 1018 - </div> 1019 - 1020 - <!-- Hosted By --> 1021 - <div class="order-3 md:order-0 md:col-start-1"> 1022 - <p 1023 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 1024 - > 1025 - Hosted By 1026 - </p> 1027 - <div class="flex items-center gap-2.5"> 1028 - <FoxAvatar src={user.profile?.avatar} alt={hostName} class="size-8 shrink-0" /> 1029 - <span class="text-base-900 dark:text-base-100 truncate text-sm font-medium"> 1030 - {hostName} 1031 - </span> 1032 - </div> 1033 - </div> 1034 - 1035 - <!-- Links --> 1036 - <div class="order-4 md:order-0 md:col-start-1"> 1037 - <p 1038 - class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 1039 - > 1040 - Links 1041 - </p> 1042 - <div class="space-y-3"> 1043 - {#each links as link, i (i)} 1044 - <div class="group flex items-center gap-1.5"> 1045 - <svg 1046 - xmlns="http://www.w3.org/2000/svg" 1047 - fill="none" 1048 - viewBox="0 0 24 24" 1049 - stroke-width="1.5" 1050 - stroke="currentColor" 1051 - class="text-base-700 dark:text-base-300 size-3.5 shrink-0" 1052 - > 1053 - <path 1054 - stroke-linecap="round" 1055 - stroke-linejoin="round" 1056 - 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" 1057 - /> 1058 - </svg> 1059 - <span class="text-base-700 dark:text-base-300 truncate text-sm"> 1060 - {link.name || link.uri.replace(/^https?:\/\//, '')} 1061 - </span> 1062 - <Button 1063 - variant="ghost" 1064 - size="iconSm" 1065 - onclick={() => removeLink(i)} 1066 - class="ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100" 1067 - > 1068 - <svg 1069 - xmlns="http://www.w3.org/2000/svg" 1070 - viewBox="0 0 20 20" 1071 - fill="currentColor" 1072 - class="size-3.5" 1073 - > 1074 - <path 1075 - 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" 1076 - /> 1077 - </svg> 1078 - </Button> 1079 - </div> 1080 - {/each} 1081 - </div> 1082 - 1083 - <div class="mt-3"> 1084 - <PopoverRoot bind:open={showLinkPopup}> 1085 - <PopoverTrigger> 1086 - <Button size="sm"> 1087 - <svg 1088 - xmlns="http://www.w3.org/2000/svg" 1089 - fill="none" 1090 - viewBox="0 0 24 24" 1091 - stroke-width="1.5" 1092 - stroke="currentColor" 1093 - class="size-4" 1094 - > 1095 - <path 1096 - stroke-linecap="round" 1097 - stroke-linejoin="round" 1098 - d="M12 4.5v15m7.5-7.5h-15" 1099 - /> 1100 - </svg> 1101 - 1102 - Add link 1103 - </Button> 1104 - </PopoverTrigger> 1105 - <PopoverContent side="bottom" sideOffset={8} class="w-64 p-3"> 1106 - <Input 1107 - type="url" 1108 - bind:value={newLinkUri} 1109 - placeholder="https://..." 1110 - variant="secondary" 1111 - class="mb-2" 1112 - onkeydown={(e) => { 1113 - if (e.key === 'Enter') { 1114 - e.preventDefault(); 1115 - addLink(); 1116 - } 1117 - }} 1118 - /> 1119 - <Input 1120 - type="text" 1121 - bind:value={newLinkName} 1122 - placeholder="Label (optional)" 1123 - variant="secondary" 1124 - class="mb-2" 1125 - onkeydown={(e) => { 1126 - if (e.key === 'Enter') { 1127 - e.preventDefault(); 1128 - addLink(); 1129 - } 1130 - }} 1131 - /> 1132 - {#if linkError} 1133 - <p class="mb-2 text-xs text-red-500">{linkError}</p> 1134 - {/if} 1135 - <div class="flex justify-end gap-2"> 1136 - <Button 1137 - variant="ghost" 1138 - size="sm" 1139 - onclick={() => { 1140 - showLinkPopup = false; 1141 - linkError = ''; 1142 - newLinkUri = ''; 1143 - newLinkName = ''; 1144 - }} 1145 - > 1146 - Cancel 1147 - </Button> 1148 - <Button onclick={addLink} size="sm" disabled={!newLinkUri.trim()}>Add</Button> 1149 - </div> 1150 - </PopoverContent> 1151 - </PopoverRoot> 1152 - </div> 1153 - </div> 1154 - </div> 1155 - 1156 - {#if !isNew} 1157 - <div class="border-base-200 dark:border-base-800 mt-12 border-t pt-8"> 1158 - {#if showDeleteConfirm} 1159 - <div class="flex items-center gap-3"> 1160 - <p class="text-sm text-red-600 dark:text-red-400"> 1161 - Are you sure? This cannot be undone. 1162 - </p> 1163 - <Button 1164 - variant="secondary" 1165 - size="sm" 1166 - onclick={() => (showDeleteConfirm = false)} 1167 - disabled={deleting} 1168 - > 1169 - Cancel 1170 - </Button> 1171 - <Button size="sm" onclick={handleDelete} disabled={deleting} variant="red"> 1172 - {deleting ? 'Deleting...' : 'Delete'} 1173 - </Button> 1174 - </div> 1175 - {:else} 1176 - <Button variant="red" onclick={() => (showDeleteConfirm = true)}>Delete event</Button> 1177 - {/if} 1178 - </div> 1179 - {/if} 1180 - </form> 1181 - {/if} 1182 - </div> 1183 - </div> 1184 - 1185 - <!-- Location modal --> 1186 - <Modal bind:open={showLocationModal}> 1187 - <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Add location</p> 1188 - <form 1189 - onsubmit={(e) => { 1190 - e.preventDefault(); 1191 - searchLocation(); 1192 - }} 1193 - class="mt-2" 1194 - > 1195 - <div class="flex gap-2"> 1196 - <Input type="text" class="flex-1" bind:value={locationSearch} /> 1197 - <Button type="submit" disabled={locationSearching || !locationSearch.trim()}> 1198 - {locationSearching ? 'Searching...' : 'Search'} 1199 - </Button> 1200 - </div> 1201 - </form> 1202 - 1203 - {#if locationError} 1204 - <p class="mt-3 text-sm text-red-600 dark:text-red-400">{locationError}</p> 1205 - {/if} 1206 - 1207 - {#if locationResult} 1208 - <div 1209 - class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 mt-4 overflow-hidden rounded-xl border p-4" 1210 - > 1211 - <div class="flex items-start gap-3"> 1212 - <svg 1213 - xmlns="http://www.w3.org/2000/svg" 1214 - fill="none" 1215 - viewBox="0 0 24 24" 1216 - stroke-width="1.5" 1217 - stroke="currentColor" 1218 - class="text-base-500 mt-0.5 size-5 shrink-0" 1219 - > 1220 - <path 1221 - stroke-linecap="round" 1222 - stroke-linejoin="round" 1223 - d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 1224 - /> 1225 - <path 1226 - stroke-linecap="round" 1227 - stroke-linejoin="round" 1228 - 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" 1229 - /> 1230 - </svg> 1231 - <div class="min-w-0 flex-1"> 1232 - <p class="text-base-900 dark:text-base-50 font-medium"> 1233 - {getLocationDisplayString(locationResult.location)} 1234 - </p> 1235 - <p class="text-base-500 dark:text-base-400 mt-0.5 truncate text-xs"> 1236 - {locationResult.displayName} 1237 - </p> 1238 - </div> 1239 - </div> 1240 - <div class="mt-4 flex justify-end"> 1241 - <Button onclick={confirmLocation}>Use this location</Button> 1242 - </div> 1243 - </div> 1244 - {/if} 1245 - 1246 - <p class="text-base-400 dark:text-base-500 mt-4 text-xs"> 1247 - Geocoding by <a 1248 - href="https://nominatim.openstreetmap.org/" 1249 - class="hover:text-base-600 dark:hover:text-base-400 underline" 1250 - target="_blank">Nominatim</a 1251 - > 1252 - / &copy; 1253 - <a 1254 - href="https://www.openstreetmap.org/copyright" 1255 - class="hover:text-base-600 dark:hover:text-base-400 underline" 1256 - target="_blank">OpenStreetMap contributors</a 1257 - > 1258 - </p> 1259 - </Modal>
-66
src/routes/[[actor=actor]]/events/[rkey]/og.png/+server.ts
··· 1 - import { getCDNImageBlobUrl, getRecord } from '$lib/atproto/methods.js'; 2 - 3 - import type { Did } from '@atcute/lexicons'; 4 - import type { EventData } from '$lib/cards/social/EventCard'; 5 - import { ImageResponse } from '@ethercorps/sveltekit-og'; 6 - import { error } from '@sveltejs/kit'; 7 - import EventOgImage from './EventOgImage.svelte'; 8 - import { getActor } from '$lib/actor'; 9 - 10 - function formatDate(dateStr: string): string { 11 - const date = new Date(dateStr); 12 - const weekday = date.toLocaleDateString('en-US', { weekday: 'long' }); 13 - const month = date.toLocaleDateString('en-US', { month: 'long' }); 14 - const day = date.getDate(); 15 - return `${weekday}, ${month} ${day}`; 16 - } 17 - 18 - export async function GET({ params, platform, request }) { 19 - const { rkey } = params; 20 - 21 - const did = await getActor({ request, paramActor: params.actor, platform }); 22 - 23 - if (!did || !rkey) { 24 - throw error(404, 'Event not found'); 25 - } 26 - 27 - let eventData: EventData; 28 - 29 - try { 30 - const eventRecord = await getRecord({ 31 - did: did as Did, 32 - collection: 'community.lexicon.calendar.event', 33 - rkey 34 - }); 35 - 36 - if (!eventRecord?.value) { 37 - throw error(404, 'Event not found'); 38 - } 39 - 40 - eventData = eventRecord.value as EventData; 41 - } catch (e) { 42 - if (e && typeof e === 'object' && 'status' in e) throw e; 43 - throw error(404, 'Event not found'); 44 - } 45 - 46 - const dateStr = formatDate(eventData.startsAt); 47 - 48 - let thumbnailUrl: string | null = null; 49 - if (eventData.media && eventData.media.length > 0) { 50 - const media = eventData.media.find((m) => m.role === 'thumbnail'); 51 - if (media?.content) { 52 - thumbnailUrl = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' }) ?? null; 53 - } 54 - } 55 - 56 - return new ImageResponse( 57 - EventOgImage, 58 - { width: 1200, height: 630, debug: false }, 59 - { 60 - name: eventData.name, 61 - dateStr, 62 - thumbnailUrl, 63 - rkey 64 - } 65 - ); 66 - }
-62
src/routes/[[actor=actor]]/events/[rkey]/og.png/EventOgImage.svelte
··· 1 - <script lang="ts"> 2 - import Avatar from 'svelte-boring-avatars'; 3 - 4 - let { 5 - name, 6 - dateStr, 7 - thumbnailUrl, 8 - rkey 9 - }: { 10 - name: string; 11 - dateStr: string; 12 - thumbnailUrl: string | null; 13 - rkey: string; 14 - } = $props(); 15 - </script> 16 - 17 - <div class="flex h-full w-full bg-neutral-900 p-0"> 18 - <div class="flex h-full shrink-0 items-center px-8"> 19 - <div class="flex overflow-hidden rounded-3xl"> 20 - {#if thumbnailUrl} 21 - <img src={thumbnailUrl} alt={name} width="420" height="420" style="object-fit: cover;" /> 22 - {:else} 23 - <Avatar 24 - size={420} 25 - name={rkey} 26 - variant="marble" 27 - colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 28 - square 29 - /> 30 - {/if} 31 - </div> 32 - </div> 33 - 34 - <div class="flex min-w-0 flex-1 flex-col justify-center p-12"> 35 - <h1 36 - class="text-7xl leading-tight font-bold text-neutral-50" 37 - style="display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word;" 38 - > 39 - {name} 40 - </h1> 41 - 42 - <div class="mt-8 flex items-center"> 43 - <svg 44 - width="28" 45 - height="28" 46 - viewBox="0 0 24 24" 47 - fill="none" 48 - xmlns="http://www.w3.org/2000/svg" 49 - > 50 - <path 51 - d="M8 2v3M16 2v3M3.5 9.09h17M21 8.5v8c0 3-1.5 5-5 5H8c-3.5 0-5-2-5-5v-8c0-3 1.5-5 5-5h8c3.5 0 5 2 5 5Z" 52 - stroke="#a3a3a3" 53 - stroke-width="1.5" 54 - stroke-miterlimit="10" 55 - stroke-linecap="round" 56 - stroke-linejoin="round" 57 - /> 58 - </svg> 59 - <span class="ml-3 text-2xl text-neutral-300">{dateStr}</span> 60 - </div> 61 - </div> 62 - </div>
-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 - }
-97
src/routes/[[actor=actor]]/events/calendar/+server.ts
··· 1 - import { error } from '@sveltejs/kit'; 2 - import type { EventData } from '$lib/cards/social/EventCard'; 3 - import { getCDNImageBlobUrl, listRecords } 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 - export async function GET({ params, platform, request }) { 11 - const cache = createCache(platform); 12 - 13 - const did = await getActor({ request, paramActor: params.actor, platform }); 14 - 15 - if (!did) { 16 - throw error(404, 'Not found'); 17 - } 18 - 19 - try { 20 - // Check cache first 21 - const cacheKey = `${did}:calendar`; 22 - if (cache) { 23 - const cached = await cache.get('ical', cacheKey); 24 - if (cached) { 25 - return new Response(cached, { 26 - headers: { 27 - 'Content-Type': 'text/calendar; charset=utf-8', 28 - 'Cache-Control': 'public, max-age=3600' 29 - } 30 - }); 31 - } 32 - } 33 - 34 - const [records, hostProfile] = await Promise.all([ 35 - listRecords({ 36 - did: did as Did, 37 - collection: 'community.lexicon.calendar.event', 38 - limit: 100 39 - }), 40 - resolveProfile(did, cache) 41 - ]); 42 - 43 - const actor = hostProfile?.handle || did; 44 - 45 - // Fetch attendees for all events in parallel 46 - const events: ICalEvent[] = await Promise.all( 47 - records.map(async (r) => { 48 - const eventData = r.value as EventData; 49 - const thumbnail = eventData.media?.find((m) => m.role === 'thumbnail'); 50 - const imageUrl = thumbnail?.content 51 - ? getCDNImageBlobUrl({ did, blob: thumbnail.content, type: 'jpeg' }) 52 - : undefined; 53 - 54 - // Fetch RSVPs and resolve handles 55 - const rsvpMap = await fetchEventRsvps(r.uri).catch(() => new Map()); 56 - const attendees: ICalAttendee[] = []; 57 - await Promise.all( 58 - Array.from(rsvpMap.entries()).map(async ([attendeeDid, status]) => { 59 - const profile = await resolveProfile(attendeeDid, cache).catch(() => null); 60 - attendees.push({ 61 - name: profile?.handle || attendeeDid, 62 - status, 63 - url: getProfileUrl(attendeeDid, profile) 64 - }); 65 - }) 66 - ); 67 - 68 - return { 69 - eventData, 70 - uid: r.uri, 71 - url: `https://blento.app/${actor}/events/${r.uri.split('/').pop()}`, 72 - organizer: actor, 73 - imageUrl, 74 - attendees 75 - }; 76 - }) 77 - ); 78 - 79 - const calendarName = `${hostProfile?.displayName || actor}'s Events`; 80 - const ical = generateICalFeed(events, calendarName); 81 - 82 - // Store in cache 83 - if (cache) { 84 - await cache.put('ical', cacheKey, ical).catch(() => {}); 85 - } 86 - 87 - return new Response(ical, { 88 - headers: { 89 - 'Content-Type': 'text/calendar; charset=utf-8', 90 - 'Cache-Control': 'public, max-age=3600' 91 - } 92 - }); 93 - } catch (e) { 94 - if (e && typeof e === 'object' && 'status' in e) throw e; 95 - throw error(500, 'Failed to generate calendar'); 96 - } 97 - }
-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 - }