your personal website on atproto - mirror blento.app

Merge pull request #214 from flo-bit/more-events-stuff-maybe-blogs

More events stuff maybe blogs

authored by

Florian and committed by
GitHub
7f0ef26b f18ade51

+1602 -26
+2 -2
src/routes/[[actor=actor]]/blog/+page.svelte
··· 45 45 <meta name="twitter:description" content="Blog posts by {hostName}" /> 46 46 </svelte:head> 47 47 48 - <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12"> 49 - <div class="mx-auto max-w-4xl"> 48 + <div class="min-h-screen px-6 py-12"> 49 + <div class="mx-auto max-w-2xl"> 50 50 <!-- Header --> 51 51 <div class="mb-8"> 52 52 <div class="flex items-center justify-between gap-4">
+2 -2
src/routes/[[actor=actor]]/blog/[rkey]/+page.svelte
··· 109 109 {/if} 110 110 </svelte:head> 111 111 112 - <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12"> 113 - <div class="mx-auto max-w-3xl"> 112 + <div class="min-h-screen px-6 py-12"> 113 + <div class="mx-auto max-w-2xl"> 114 114 <!-- Cover image --> 115 115 {#if coverUrl} 116 116 <img src={coverUrl} alt={title} class="mb-8 aspect-video w-full rounded-2xl object-cover" />
+2 -2
src/routes/[[actor=actor]]/blog/new/+page.svelte
··· 372 372 <title>Create Blog Post</title> 373 373 </svelte:head> 374 374 375 - <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12"> 376 - <div class="mx-auto max-w-3xl"> 375 + <div class="min-h-screen px-6 py-12"> 376 + <div class="mx-auto max-w-2xl"> 377 377 {#if user.isInitializing || !draftRestored} 378 378 <div class="flex items-center gap-3"> 379 379 <div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div>
+5 -6
src/routes/[[actor=actor]]/events/+page.svelte
··· 75 75 return { url, alt: media.alt || event.name }; 76 76 } 77 77 78 - let actorPrefix = $derived(data.hostProfile?.handle ? `/${data.hostProfile.handle}` : `/${did}`); 79 78 let isOwner = $derived(user.isLoggedIn && user.did === did); 80 79 </script> 81 80 ··· 89 88 <meta name="twitter:description" content="Events hosted by {hostName}" /> 90 89 </svelte:head> 91 90 92 - <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12 sm:py-12"> 93 - <div class="mx-auto max-w-4xl"> 91 + <div class="min-h-screen px-6 py-12 sm:py-12"> 92 + <div class="mx-auto max-w-2xl"> 94 93 <!-- Header --> 95 94 <div class="mb-8 flex items-start justify-between"> 96 95 <div> ··· 111 110 </div> 112 111 </div> 113 112 {#if isOwner} 114 - <Button href="{actorPrefix}/events/new" variant="primary">New event</Button> 113 + <Button href="./events/new" variant="primary">New event</Button> 115 114 {/if} 116 115 </div> 117 116 ··· 124 123 {@const location = getLocationString(event.locations)} 125 124 {@const rkey = event.rkey} 126 125 <a 127 - href="{actorPrefix}/events/{rkey}" 128 - class="border-base-200 dark:border-base-800 hover:border-base-300 dark:hover:border-base-700 group bg-base-100 dark:bg-base-900 block overflow-hidden rounded-2xl border transition-colors" 126 + href="./events/{rkey}" 127 + 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" 129 128 > 130 129 <!-- Thumbnail --> 131 130 <div class="p-4">
+28 -8
src/routes/[[actor=actor]]/events/[rkey]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import type { EventData } from '$lib/cards/social/EventCard'; 3 3 import { getCDNImageBlobUrl } from '$lib/atproto'; 4 - import { Avatar as FoxAvatar, Badge } from '@foxui/core'; 4 + import { user } from '$lib/atproto/auth.svelte'; 5 + import { Avatar as FoxAvatar, Badge, Button } from '@foxui/core'; 5 6 import Avatar from 'svelte-boring-avatars'; 6 7 import EventRsvp from './EventRsvp.svelte'; 8 + import EventAttendees from './EventAttendees.svelte'; 7 9 import { page } from '$app/state'; 8 10 import { segmentize, type Facet } from '@atcute/bluesky-richtext-segmenter'; 9 11 import { sanitize } from '$lib/sanitize'; ··· 157 159 let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`); 158 160 159 161 let ogImageUrl = $derived(`${page.url.origin}${page.url.pathname}/og.png`); 162 + 163 + let isOwner = $derived(user.isLoggedIn && user.did === did); 160 164 </script> 161 165 162 166 <svelte:head> ··· 171 175 <meta name="twitter:image" content={ogImageUrl} /> 172 176 </svelte:head> 173 177 174 - <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12 sm:py-12"> 175 - <div class="mx-auto max-w-4xl"> 178 + <div class="min-h-screen px-6 py-12 sm:py-12"> 179 + <div class="mx-auto max-w-2xl"> 176 180 <!-- Banner image (full width, only when no thumbnail) --> 177 181 {#if isBannerOnly && displayImage} 178 182 <img ··· 213 217 214 218 <!-- Right column: event details --> 215 219 <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> 216 - <h1 217 - class="text-base-900 dark:text-base-50 mb-2 text-4xl leading-tight font-bold sm:text-5xl" 218 - > 219 - {eventData.name} 220 - </h1> 220 + <div class="mb-2 flex items-start justify-between gap-4"> 221 + <h1 222 + class="text-base-900 dark:text-base-50 text-4xl leading-tight font-bold sm:text-5xl" 223 + > 224 + {eventData.name} 225 + </h1> 226 + {#if isOwner} 227 + <Button href="./edit" variant="ghost" size="sm" class="shrink-0">Edit</Button> 228 + {/if} 229 + </div> 221 230 222 231 <!-- Mode badge --> 223 232 {#if eventData.mode} ··· 288 297 289 298 <EventRsvp {eventUri} eventCid={data.eventCid} /> 290 299 300 + {#if isOwner} 301 + <div class="mt-4"> 302 + <Button href="./edit" variant="secondary" size="sm">Edit event</Button> 303 + </div> 304 + {/if} 305 + 291 306 <!-- About Event --> 292 307 {#if descriptionHtml} 293 308 <div class="mt-8 mb-8"> ··· 363 378 </div> 364 379 </div> 365 380 {/if} 381 + 382 + <!-- Attendees --> 383 + <!-- <div class="order-5 md:order-0 md:col-start-1"> 384 + <EventAttendees {eventUri} {did} /> 385 + </div> --> 366 386 367 387 <!-- View on Smoke Signal link --> 368 388 <a
+134
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 { fetchEventBacklinks } from './api.remote'; 5 + 6 + let { eventUri, did }: { eventUri: string; did: string } = $props(); 7 + 8 + let goingCount = $state(0); 9 + let interestedCount = $state(0); 10 + let goingAvatars: Array<{ did: string; avatar?: string; name: string }> = $state([]); 11 + let interestedAvatars: Array<{ did: string; avatar?: string; name: string }> = $state([]); 12 + let loading = $state(true); 13 + 14 + onMount(() => { 15 + fetchEventBacklinks(eventUri) 16 + .then((records) => { 17 + console.log(records); 18 + if (!records) return; 19 + let going = 0; 20 + let interested = 0; 21 + const goingAvatarList: Array<{ did: string; avatar?: string; name: string }> = []; 22 + const interestedAvatarList: Array<{ did: string; avatar?: string; name: string }> = []; 23 + 24 + for (const raw of records) { 25 + const record = raw as { 26 + did: string; 27 + value?: { status?: string }; 28 + author?: { avatar?: string; displayName?: string; handle?: string }; 29 + }; 30 + const status = record.value?.status || ''; 31 + const author = record.author; 32 + const avatarInfo = { 33 + did: record.did, 34 + avatar: author?.avatar, 35 + name: author?.displayName || author?.handle || record.did 36 + }; 37 + 38 + if (status.includes('#going')) { 39 + going++; 40 + goingAvatarList.push(avatarInfo); 41 + } else if (status.includes('#interested')) { 42 + interested++; 43 + interestedAvatarList.push(avatarInfo); 44 + } 45 + } 46 + 47 + goingCount = going; 48 + interestedCount = interested; 49 + goingAvatars = goingAvatarList; 50 + interestedAvatars = interestedAvatarList; 51 + }) 52 + .catch((err) => { 53 + console.error('Failed to fetch event attendees:', err); 54 + }) 55 + .finally(() => { 56 + loading = false; 57 + }); 58 + }); 59 + 60 + let totalCount = $derived(goingCount + interestedCount); 61 + let allAvatars = $derived([...goingAvatars, ...interestedAvatars]); 62 + let displayAvatars = $derived(allAvatars.slice(0, 8)); 63 + let overflowCount = $derived(allAvatars.length - displayAvatars.length); 64 + </script> 65 + 66 + {#if loading} 67 + <div class="flex items-center gap-3"> 68 + <div class="bg-base-300 dark:bg-base-700 h-3 w-24 animate-pulse rounded"></div> 69 + </div> 70 + {:else if totalCount > 0} 71 + <div> 72 + <p class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"> 73 + Attendees 74 + </p> 75 + 76 + <!-- Avatar stack --> 77 + {#if displayAvatars.length > 0} 78 + <div class="mb-3 flex items-center"> 79 + <div class="flex -space-x-2"> 80 + {#each displayAvatars as person (person.did)} 81 + <FoxAvatar 82 + src={person.avatar} 83 + alt={person.name} 84 + class="ring-base-50 dark:ring-base-950 size-7 ring-2" 85 + /> 86 + {/each} 87 + </div> 88 + {#if overflowCount > 0} 89 + <span class="text-base-500 dark:text-base-400 ml-2 text-xs"> 90 + +{overflowCount} 91 + </span> 92 + {/if} 93 + </div> 94 + {/if} 95 + 96 + <!-- Counts --> 97 + <div class="text-base-600 dark:text-base-400 flex items-center gap-3 text-sm"> 98 + {#if goingCount > 0} 99 + <span class="flex items-center gap-1.5"> 100 + <svg 101 + xmlns="http://www.w3.org/2000/svg" 102 + viewBox="0 0 20 20" 103 + fill="currentColor" 104 + class="size-3.5 text-green-500" 105 + > 106 + <path 107 + fill-rule="evenodd" 108 + d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" 109 + clip-rule="evenodd" 110 + /> 111 + </svg> 112 + {goingCount} going 113 + </span> 114 + {/if} 115 + {#if interestedCount > 0} 116 + <span class="flex items-center gap-1.5"> 117 + <svg 118 + xmlns="http://www.w3.org/2000/svg" 119 + viewBox="0 0 20 20" 120 + fill="currentColor" 121 + class="size-3.5 text-amber-500" 122 + > 123 + <path 124 + fill-rule="evenodd" 125 + d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401Z" 126 + clip-rule="evenodd" 127 + /> 128 + </svg> 129 + {interestedCount} interested 130 + </span> 131 + {/if} 132 + </div> 133 + </div> 134 + {/if}
+1 -1
src/routes/[[actor=actor]]/events/[rkey]/EventRsvp.svelte
··· 120 120 </script> 121 121 122 122 <div 123 - class="border-base-200 dark:border-base-800 bg-base-100 items-between dark:bg-base-900/50 mt-8 mb-2 flex h-25 flex-col justify-center rounded-2xl border p-4" 123 + 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" 124 124 > 125 125 {#if user.isInitializing || rsvpLoading} 126 126 <div class="flex items-center gap-3">
+47
src/routes/[[actor=actor]]/events/[rkey]/api.remote.ts
··· 1 + import { query } from '$app/server'; 2 + 3 + export const fetchEventBacklinks = query('unchecked', async (eventUri: string) => { 4 + const allRecords: Record<string, unknown>[] = []; 5 + let cursor: string | undefined; 6 + 7 + do { 8 + const params: Record<string, unknown> = { 9 + subject: eventUri, 10 + source: 'community.lexicon.calendar.rsvp:subject.uri' 11 + }; 12 + if (cursor) params.cursor = cursor; 13 + 14 + const res = await fetch( 15 + 'https://slingshot.microcosm.blue/xrpc/com.bad-example.proxy.hydrateQueryResponse', 16 + { 17 + method: 'POST', 18 + headers: { 'Content-Type': 'application/json' }, 19 + body: JSON.stringify({ 20 + atproto_proxy: 'did:web:constellation.microcosm.blue#constellation', 21 + hydration_sources: [ 22 + { 23 + path: 'records[]', 24 + shape: 'at-uri-parts' 25 + } 26 + ], 27 + params, 28 + xrpc: 'blue.microcosm.links.getBacklinks' 29 + }) 30 + } 31 + ); 32 + 33 + if (!res.ok) break; 34 + 35 + const data = await res.json(); 36 + const output = data.output; 37 + if (!output) break; 38 + 39 + if (output.records && Array.isArray(output.records)) { 40 + allRecords.push(...output.records); 41 + } 42 + 43 + cursor = output.cursor || undefined; 44 + } while (cursor); 45 + 46 + return allRecords; 47 + });
+59
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 + }), 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 + 48 + return { 49 + eventData, 50 + did, 51 + rkey, 52 + hostProfile: hostProfile ?? null, 53 + eventCid: (eventRecord.cid as string) ?? null 54 + }; 55 + } catch (e) { 56 + if (e && typeof e === 'object' && 'status' in e) throw e; 57 + throw error(404, 'Event not found'); 58 + } 59 + }
+1064
src/routes/[[actor=actor]]/events/[rkey]/edit/+page.svelte
··· 1 + <script lang="ts"> 2 + import { user } from '$lib/atproto/auth.svelte'; 3 + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 4 + import { uploadBlob, putRecord, resolveHandle } from '$lib/atproto/methods'; 5 + import { getCDNImageBlobUrl } from '$lib/atproto'; 6 + import { compressImage } from '$lib/atproto/image-helper'; 7 + import { Avatar as FoxAvatar, Badge, Button, ToggleGroup, ToggleGroupItem } from '@foxui/core'; 8 + import { goto } from '$app/navigation'; 9 + import { tokenize, type Token } from '@atcute/bluesky-richtext-parser'; 10 + import type { Handle } from '@atcute/lexicons'; 11 + import { onMount } from 'svelte'; 12 + import { browser } from '$app/environment'; 13 + import { putImage, getImage, deleteImage } from '$lib/components/image-store'; 14 + import Modal from '$lib/components/modal/Modal.svelte'; 15 + 16 + let { data } = $props(); 17 + 18 + let rkey: string = $derived(data.rkey); 19 + let DRAFT_KEY = $derived(`blento-event-edit-${rkey}`); 20 + 21 + type EventMode = 'inperson' | 'virtual' | 'hybrid'; 22 + 23 + interface EventLocation { 24 + street?: string; 25 + locality?: string; 26 + region?: string; 27 + country?: string; 28 + } 29 + 30 + interface EventDraft { 31 + name: string; 32 + description: string; 33 + startsAt: string; 34 + endsAt: string; 35 + links: Array<{ uri: string; name: string }>; 36 + mode?: EventMode; 37 + thumbnailKey?: string; 38 + thumbnailChanged?: boolean; 39 + location?: EventLocation | null; 40 + locationChanged?: boolean; 41 + } 42 + 43 + let thumbnailKey: string | null = $state(null); 44 + let thumbnailChanged = $state(false); 45 + 46 + let name = $state(''); 47 + let description = $state(''); 48 + let startsAt = $state(''); 49 + let endsAt = $state(''); 50 + let mode: EventMode = $state('inperson'); 51 + let thumbnailFile: File | null = $state(null); 52 + let thumbnailPreview: string | null = $state(null); 53 + let submitting = $state(false); 54 + let error: string | null = $state(null); 55 + 56 + let location: EventLocation | null = $state(null); 57 + let locationChanged = $state(false); 58 + let showLocationModal = $state(false); 59 + let locationSearch = $state(''); 60 + let locationSearching = $state(false); 61 + let locationError = $state(''); 62 + let locationResult: { displayName: string; location: EventLocation } | null = $state(null); 63 + 64 + let links: Array<{ uri: string; name: string }> = $state([]); 65 + let showLinkPopup = $state(false); 66 + let newLinkUri = $state(''); 67 + let newLinkName = $state(''); 68 + 69 + let hasDraft = $state(false); 70 + let draftLoaded = $state(false); 71 + 72 + function isoToDatetimeLocal(iso: string): string { 73 + const date = new Date(iso); 74 + const pad = (n: number) => n.toString().padStart(2, '0'); 75 + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; 76 + } 77 + 78 + function stripModePrefix(modeStr: string): EventMode { 79 + const stripped = modeStr.replace('community.lexicon.calendar.event#', ''); 80 + if (stripped === 'virtual' || stripped === 'hybrid' || stripped === 'inperson') return stripped; 81 + return 'inperson'; 82 + } 83 + 84 + function populateFromEventData() { 85 + const eventData = data.eventData; 86 + name = eventData.name || ''; 87 + description = eventData.description || ''; 88 + startsAt = eventData.startsAt ? isoToDatetimeLocal(eventData.startsAt) : ''; 89 + endsAt = eventData.endsAt ? isoToDatetimeLocal(eventData.endsAt) : ''; 90 + mode = eventData.mode ? stripModePrefix(eventData.mode) : 'inperson'; 91 + links = eventData.uris ? eventData.uris.map((l) => ({ uri: l.uri, name: l.name || '' })) : []; 92 + 93 + // Load existing location 94 + if (eventData.locations && eventData.locations.length > 0) { 95 + const loc = eventData.locations.find((v) => v.$type === 'community.lexicon.location.address'); 96 + if (loc) { 97 + const flat = loc as Record<string, unknown>; 98 + const nested = loc.address; 99 + const street = (flat.street as string) || undefined; 100 + const locality = (flat.locality as string) || nested?.locality; 101 + const region = (flat.region as string) || nested?.region; 102 + const country = (flat.country as string) || nested?.country; 103 + location = { 104 + ...(street && { street }), 105 + ...(locality && { locality }), 106 + ...(region && { region }), 107 + ...(country && { country }) 108 + }; 109 + } 110 + } 111 + locationChanged = false; 112 + 113 + // Load existing thumbnail from CDN 114 + if (eventData.media && eventData.media.length > 0) { 115 + const media = eventData.media.find((m) => m.role === 'thumbnail'); 116 + if (media?.content) { 117 + const url = getCDNImageBlobUrl({ did: data.did, blob: media.content, type: 'jpeg' }); 118 + if (url) { 119 + thumbnailPreview = url; 120 + thumbnailChanged = false; 121 + } 122 + } 123 + } 124 + } 125 + 126 + onMount(async () => { 127 + const saved = localStorage.getItem(DRAFT_KEY); 128 + if (saved) { 129 + try { 130 + const draft: EventDraft = JSON.parse(saved); 131 + name = draft.name || ''; 132 + description = draft.description || ''; 133 + startsAt = draft.startsAt || ''; 134 + endsAt = draft.endsAt || ''; 135 + links = draft.links || []; 136 + mode = draft.mode || 'inperson'; 137 + locationChanged = draft.locationChanged || false; 138 + if (draft.locationChanged) { 139 + location = draft.location || null; 140 + } 141 + thumbnailChanged = draft.thumbnailChanged || false; 142 + 143 + if (draft.thumbnailKey) { 144 + const img = await getImage(draft.thumbnailKey); 145 + if (img) { 146 + thumbnailKey = draft.thumbnailKey; 147 + thumbnailFile = new File([img.blob], img.name, { type: img.blob.type }); 148 + thumbnailPreview = URL.createObjectURL(img.blob); 149 + thumbnailChanged = true; 150 + } 151 + } else if (!thumbnailChanged) { 152 + // No new thumbnail in draft, show existing one from event data 153 + if (data.eventData.media && data.eventData.media.length > 0) { 154 + const media = data.eventData.media.find((m) => m.role === 'thumbnail'); 155 + if (media?.content) { 156 + const url = getCDNImageBlobUrl({ 157 + did: data.did, 158 + blob: media.content, 159 + type: 'jpeg' 160 + }); 161 + if (url) { 162 + thumbnailPreview = url; 163 + } 164 + } 165 + } 166 + } 167 + 168 + hasDraft = true; 169 + } catch { 170 + localStorage.removeItem(DRAFT_KEY); 171 + populateFromEventData(); 172 + } 173 + } else { 174 + populateFromEventData(); 175 + } 176 + draftLoaded = true; 177 + }); 178 + 179 + let saveDraftTimeout: ReturnType<typeof setTimeout> | undefined; 180 + 181 + function saveDraft() { 182 + if (!draftLoaded || !browser) return; 183 + clearTimeout(saveDraftTimeout); 184 + saveDraftTimeout = setTimeout(() => { 185 + const draft: EventDraft = { 186 + name, 187 + description, 188 + startsAt, 189 + endsAt, 190 + links, 191 + mode, 192 + thumbnailChanged, 193 + locationChanged 194 + }; 195 + if (locationChanged) draft.location = location; 196 + if (thumbnailKey) draft.thumbnailKey = thumbnailKey; 197 + localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); 198 + hasDraft = true; 199 + }, 500); 200 + } 201 + 202 + $effect(() => { 203 + // track all draft fields by reading them 204 + void [ 205 + name, 206 + description, 207 + startsAt, 208 + endsAt, 209 + mode, 210 + JSON.stringify(links), 211 + JSON.stringify(location) 212 + ]; 213 + saveDraft(); 214 + }); 215 + 216 + function deleteDraft() { 217 + localStorage.removeItem(DRAFT_KEY); 218 + if (thumbnailKey) deleteImage(thumbnailKey); 219 + thumbnailKey = null; 220 + thumbnailChanged = false; 221 + populateFromEventData(); 222 + hasDraft = false; 223 + } 224 + 225 + async function searchLocation() { 226 + const q = locationSearch.trim(); 227 + if (!q) return; 228 + locationError = ''; 229 + locationSearching = true; 230 + locationResult = null; 231 + 232 + try { 233 + const response = await fetch('/api/geocoding?q=' + encodeURIComponent(q)); 234 + if (!response.ok) throw new Error('response not ok'); 235 + const data = await response.json(); 236 + if (!data || data.error) throw new Error('no results'); 237 + 238 + const addr = data.address || {}; 239 + const road = addr.road || ''; 240 + const houseNumber = addr.house_number || ''; 241 + const street = road ? (houseNumber ? `${road} ${houseNumber}` : road) : ''; 242 + const locality = 243 + addr.city || addr.town || addr.village || addr.municipality || addr.hamlet || ''; 244 + const region = addr.state || addr.county || ''; 245 + const country = addr.country || ''; 246 + 247 + locationResult = { 248 + displayName: data.display_name || q, 249 + location: { 250 + ...(street && { street }), 251 + ...(locality && { locality }), 252 + ...(region && { region }), 253 + ...(country && { country }) 254 + } 255 + }; 256 + } catch { 257 + locationError = "Couldn't find that location."; 258 + } finally { 259 + locationSearching = false; 260 + } 261 + } 262 + 263 + function confirmLocation() { 264 + if (locationResult) { 265 + location = locationResult.location; 266 + locationChanged = true; 267 + } 268 + showLocationModal = false; 269 + locationSearch = ''; 270 + locationResult = null; 271 + locationError = ''; 272 + } 273 + 274 + function removeLocation() { 275 + location = null; 276 + locationChanged = true; 277 + } 278 + 279 + function getLocationDisplayString(loc: EventLocation): string { 280 + return [loc.street, loc.locality, loc.region, loc.country].filter(Boolean).join(', '); 281 + } 282 + 283 + function addLink() { 284 + const uri = newLinkUri.trim(); 285 + if (!uri) return; 286 + links.push({ uri, name: newLinkName.trim() }); 287 + newLinkUri = ''; 288 + newLinkName = ''; 289 + showLinkPopup = false; 290 + } 291 + 292 + function removeLink(index: number) { 293 + links.splice(index, 1); 294 + } 295 + 296 + let fileInput: HTMLInputElement | undefined = $state(); 297 + 298 + let hostName = $derived(user.profile?.displayName || user.profile?.handle || user.did || ''); 299 + 300 + async function setThumbnail(file: File) { 301 + thumbnailFile = file; 302 + thumbnailChanged = true; 303 + if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 304 + thumbnailPreview = URL.createObjectURL(file); 305 + 306 + if (thumbnailKey) await deleteImage(thumbnailKey); 307 + thumbnailKey = crypto.randomUUID(); 308 + await putImage(thumbnailKey, file, file.name); 309 + saveDraft(); 310 + } 311 + 312 + async function onFileChange(e: Event) { 313 + const input = e.target as HTMLInputElement; 314 + const file = input.files?.[0]; 315 + if (!file) return; 316 + setThumbnail(file); 317 + } 318 + 319 + let isDragOver = $state(false); 320 + 321 + function onDragOver(e: DragEvent) { 322 + e.preventDefault(); 323 + isDragOver = true; 324 + } 325 + 326 + function onDragLeave(e: DragEvent) { 327 + e.preventDefault(); 328 + isDragOver = false; 329 + } 330 + 331 + function onDrop(e: DragEvent) { 332 + e.preventDefault(); 333 + isDragOver = false; 334 + const file = e.dataTransfer?.files?.[0]; 335 + if (file?.type.startsWith('image/')) { 336 + setThumbnail(file); 337 + } 338 + } 339 + 340 + function removeThumbnail() { 341 + thumbnailFile = null; 342 + thumbnailChanged = true; 343 + if (thumbnailPreview) { 344 + URL.revokeObjectURL(thumbnailPreview); 345 + thumbnailPreview = null; 346 + } 347 + if (thumbnailKey) { 348 + deleteImage(thumbnailKey); 349 + thumbnailKey = null; 350 + } 351 + if (fileInput) fileInput.value = ''; 352 + saveDraft(); 353 + } 354 + 355 + function formatMonth(date: Date): string { 356 + return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); 357 + } 358 + 359 + function formatDay(date: Date): number { 360 + return date.getDate(); 361 + } 362 + 363 + function formatWeekday(date: Date): string { 364 + return date.toLocaleDateString('en-US', { weekday: 'long' }); 365 + } 366 + 367 + function formatFullDate(date: Date): string { 368 + const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' }; 369 + if (date.getFullYear() !== new Date().getFullYear()) { 370 + options.year = 'numeric'; 371 + } 372 + return date.toLocaleDateString('en-US', options); 373 + } 374 + 375 + function formatTime(date: Date): string { 376 + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); 377 + } 378 + 379 + let startDate = $derived(startsAt ? new Date(startsAt) : null); 380 + let endDate = $derived(endsAt ? new Date(endsAt) : null); 381 + let isSameDay = $derived( 382 + startDate && 383 + endDate && 384 + startDate.getFullYear() === endDate.getFullYear() && 385 + startDate.getMonth() === endDate.getMonth() && 386 + startDate.getDate() === endDate.getDate() 387 + ); 388 + 389 + async function tokensToFacets(tokens: Token[]): Promise<Record<string, unknown>[]> { 390 + const encoder = new TextEncoder(); 391 + const facets: Record<string, unknown>[] = []; 392 + let byteOffset = 0; 393 + 394 + for (const token of tokens) { 395 + const tokenBytes = encoder.encode(token.raw); 396 + const byteStart = byteOffset; 397 + const byteEnd = byteOffset + tokenBytes.length; 398 + 399 + if (token.type === 'mention') { 400 + try { 401 + const did = await resolveHandle({ handle: token.handle as Handle }); 402 + if (did) { 403 + facets.push({ 404 + index: { byteStart, byteEnd }, 405 + features: [{ $type: 'app.bsky.richtext.facet#mention', did }] 406 + }); 407 + } 408 + } catch { 409 + // skip unresolvable mentions 410 + } 411 + } else if (token.type === 'autolink') { 412 + facets.push({ 413 + index: { byteStart, byteEnd }, 414 + features: [{ $type: 'app.bsky.richtext.facet#link', uri: token.url }] 415 + }); 416 + } else if (token.type === 'topic') { 417 + facets.push({ 418 + index: { byteStart, byteEnd }, 419 + features: [{ $type: 'app.bsky.richtext.facet#tag', tag: token.name }] 420 + }); 421 + } 422 + 423 + byteOffset = byteEnd; 424 + } 425 + 426 + return facets; 427 + } 428 + 429 + async function handleSubmit() { 430 + error = null; 431 + 432 + if (!name.trim()) { 433 + error = 'Name is required.'; 434 + return; 435 + } 436 + if (!startsAt) { 437 + error = 'Start date is required.'; 438 + return; 439 + } 440 + if (!user.client || !user.did) { 441 + error = 'You must be logged in.'; 442 + return; 443 + } 444 + 445 + submitting = true; 446 + 447 + try { 448 + let media: Array<Record<string, unknown>> | undefined; 449 + 450 + if (thumbnailChanged) { 451 + if (thumbnailFile) { 452 + const compressed = await compressImage(thumbnailFile); 453 + const blobRef = await uploadBlob({ blob: compressed.blob }); 454 + if (blobRef) { 455 + media = [ 456 + { 457 + role: 'thumbnail', 458 + content: blobRef, 459 + aspect_ratio: { 460 + width: compressed.aspectRatio.width, 461 + height: compressed.aspectRatio.height 462 + } 463 + } 464 + ]; 465 + } 466 + } 467 + // If thumbnailChanged but no thumbnailFile, media stays undefined (thumbnail removed) 468 + } else { 469 + // Thumbnail not changed — reuse original media from eventData 470 + if (data.eventData.media && data.eventData.media.length > 0) { 471 + media = data.eventData.media as Array<Record<string, unknown>>; 472 + } 473 + } 474 + 475 + // Preserve original createdAt 476 + const originalCreatedAt = 477 + (data.eventData as Record<string, unknown>).createdAt || new Date().toISOString(); 478 + 479 + const record: Record<string, unknown> = { 480 + $type: 'community.lexicon.calendar.event', 481 + name: name.trim(), 482 + mode: `community.lexicon.calendar.event#${mode}`, 483 + status: 'community.lexicon.calendar.event#scheduled', 484 + startsAt: new Date(startsAt).toISOString(), 485 + createdAt: originalCreatedAt 486 + }; 487 + 488 + const trimmedDescription = description.trim(); 489 + if (trimmedDescription) { 490 + record.description = trimmedDescription; 491 + const tokens = tokenize(trimmedDescription); 492 + const facets = await tokensToFacets(tokens); 493 + if (facets.length > 0) { 494 + record.facets = facets; 495 + } 496 + } 497 + if (endsAt) { 498 + record.endsAt = new Date(endsAt).toISOString(); 499 + } 500 + if (media) { 501 + record.media = media; 502 + } 503 + if (links.length > 0) { 504 + record.uris = links; 505 + } 506 + if (locationChanged) { 507 + if (location) { 508 + record.locations = [ 509 + { 510 + $type: 'community.lexicon.location.address', 511 + ...location 512 + } 513 + ]; 514 + } 515 + // If locationChanged but no location, locations stays undefined (removed) 516 + } else if (data.eventData.locations && data.eventData.locations.length > 0) { 517 + record.locations = data.eventData.locations; 518 + } 519 + 520 + const response = await putRecord({ 521 + collection: 'community.lexicon.calendar.event', 522 + rkey, 523 + record 524 + }); 525 + 526 + if (response.ok) { 527 + localStorage.removeItem(DRAFT_KEY); 528 + if (thumbnailKey) deleteImage(thumbnailKey); 529 + const handle = 530 + user.profile?.handle && user.profile.handle !== 'handle.invalid' 531 + ? user.profile.handle 532 + : user.did; 533 + goto(`/${handle}/events/${rkey}`); 534 + } else { 535 + error = 'Failed to save event. Please try again.'; 536 + } 537 + } catch (e) { 538 + console.error('Failed to save event:', e); 539 + error = 'Failed to save event. Please try again.'; 540 + } finally { 541 + submitting = false; 542 + } 543 + } 544 + </script> 545 + 546 + <svelte:head> 547 + <title>Edit Event</title> 548 + </svelte:head> 549 + 550 + <div class="min-h-screen px-6 py-12 sm:py-12"> 551 + <div class="mx-auto max-w-2xl"> 552 + {#if user.isInitializing} 553 + <div class="flex items-center gap-3"> 554 + <div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div> 555 + <div class="bg-base-300 dark:bg-base-700 h-4 w-48 animate-pulse rounded"></div> 556 + </div> 557 + {:else if !user.isLoggedIn} 558 + <div 559 + class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 rounded-2xl border p-8 text-center" 560 + > 561 + <p class="text-base-600 dark:text-base-400 mb-4">Log in to edit this event.</p> 562 + <Button onclick={() => loginModalState.show()}>Log in</Button> 563 + </div> 564 + {:else} 565 + <div class="mb-6 flex items-center gap-3"> 566 + <Badge size="sm">Local edit</Badge> 567 + {#if hasDraft} 568 + <button 569 + type="button" 570 + onclick={deleteDraft} 571 + class="text-base-500 dark:text-base-400 cursor-pointer text-xs hover:text-red-500 hover:underline" 572 + > 573 + Discard changes 574 + </button> 575 + {/if} 576 + </div> 577 + 578 + <form 579 + onsubmit={(e) => { 580 + e.preventDefault(); 581 + handleSubmit(); 582 + }} 583 + > 584 + <!-- Two-column layout mirroring detail page --> 585 + <div 586 + 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]" 587 + > 588 + <!-- Thumbnail (left column) --> 589 + <!-- svelte-ignore a11y_no_static_element_interactions --> 590 + <div 591 + class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none" 592 + ondragover={onDragOver} 593 + ondragleave={onDragLeave} 594 + ondrop={onDrop} 595 + > 596 + <input 597 + bind:this={fileInput} 598 + type="file" 599 + accept="image/*" 600 + onchange={onFileChange} 601 + class="hidden" 602 + /> 603 + {#if thumbnailPreview} 604 + <div class="relative"> 605 + <button type="button" onclick={() => fileInput?.click()} class="w-full"> 606 + <img 607 + src={thumbnailPreview} 608 + alt="Thumbnail preview" 609 + class="border-base-200 dark:border-base-800 aspect-square w-full cursor-pointer rounded-2xl border object-cover" 610 + /> 611 + </button> 612 + <button 613 + type="button" 614 + onclick={removeThumbnail} 615 + aria-label="Remove thumbnail" 616 + class="bg-base-900/70 absolute top-2 right-2 flex size-7 items-center justify-center rounded-full text-white hover:bg-red-600" 617 + > 618 + <svg 619 + xmlns="http://www.w3.org/2000/svg" 620 + viewBox="0 0 20 20" 621 + fill="currentColor" 622 + class="size-4" 623 + > 624 + <path 625 + 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" 626 + /> 627 + </svg> 628 + </button> 629 + </div> 630 + {:else} 631 + <button 632 + type="button" 633 + onclick={() => fileInput?.click()} 634 + class="border-base-300 dark:border-base-700 hover:border-base-400 dark:hover:border-base-600 text-base-500 dark:text-base-400 flex aspect-square w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed transition-colors {isDragOver 635 + ? 'border-accent-500 bg-accent-50 dark:bg-accent-950 text-accent-500' 636 + : ''}" 637 + > 638 + <svg 639 + xmlns="http://www.w3.org/2000/svg" 640 + fill="none" 641 + viewBox="0 0 24 24" 642 + stroke-width="1.5" 643 + stroke="currentColor" 644 + class="mb-1 size-6" 645 + > 646 + <path 647 + stroke-linecap="round" 648 + stroke-linejoin="round" 649 + 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" 650 + /> 651 + </svg> 652 + <span class="text-sm">Add image</span> 653 + </button> 654 + {/if} 655 + </div> 656 + 657 + <!-- Right column: event details --> 658 + <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> 659 + <!-- Name --> 660 + <input 661 + type="text" 662 + bind:value={name} 663 + required 664 + placeholder="Event name" 665 + class="text-base-900 dark:text-base-50 placeholder:text-base-300 dark:placeholder:text-base-700 mb-2 w-full border-0 bg-transparent text-4xl leading-tight font-bold focus:border-0 focus:ring-0 focus:outline-none sm:text-5xl" 666 + /> 667 + 668 + <!-- Mode toggle --> 669 + <div class="mb-8"> 670 + <ToggleGroup 671 + type="single" 672 + bind:value={ 673 + () => { 674 + return mode; 675 + }, 676 + (val) => { 677 + if (val) mode = val; 678 + } 679 + } 680 + class="w-fit" 681 + > 682 + <ToggleGroupItem size="sm" value="inperson">In Person</ToggleGroupItem> 683 + <ToggleGroupItem size="sm" value="virtual">Virtual</ToggleGroupItem> 684 + <ToggleGroupItem size="sm" value="hybrid">Hybrid</ToggleGroupItem> 685 + </ToggleGroup> 686 + </div> 687 + 688 + <!-- Date row --> 689 + <div class="mb-4 flex items-center gap-4"> 690 + <div 691 + class="border-base-200 dark:border-base-700 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 692 + > 693 + {#if startDate} 694 + <span 695 + class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold" 696 + > 697 + {formatMonth(startDate)} 698 + </span> 699 + <span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold"> 700 + {formatDay(startDate)} 701 + </span> 702 + {:else} 703 + <svg 704 + xmlns="http://www.w3.org/2000/svg" 705 + fill="none" 706 + viewBox="0 0 24 24" 707 + stroke-width="1.5" 708 + stroke="currentColor" 709 + class="text-base-400 dark:text-base-500 size-5" 710 + > 711 + <path 712 + stroke-linecap="round" 713 + stroke-linejoin="round" 714 + 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" 715 + /> 716 + </svg> 717 + {/if} 718 + </div> 719 + <div class="flex-1"> 720 + {#if startDate} 721 + <p class="text-base-900 dark:text-base-50 font-semibold"> 722 + {formatWeekday(startDate)}, {formatFullDate(startDate)} 723 + {#if endDate && !isSameDay} 724 + - {formatWeekday(endDate)}, {formatFullDate(endDate)} 725 + {/if} 726 + </p> 727 + <p class="text-base-500 dark:text-base-400 text-sm"> 728 + {formatTime(startDate)} 729 + {#if endDate && isSameDay} 730 + - {formatTime(endDate)} 731 + {/if} 732 + </p> 733 + {/if} 734 + <div class="mt-1 flex flex-wrap gap-3"> 735 + <label class="flex items-center gap-1.5"> 736 + <span class="text-base-500 dark:text-base-400 text-xs">Start</span> 737 + <input 738 + type="datetime-local" 739 + bind:value={startsAt} 740 + required 741 + class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none" 742 + /> 743 + </label> 744 + <label class="flex items-center gap-1.5"> 745 + <span class="text-base-500 dark:text-base-400 text-xs">End</span> 746 + <input 747 + type="datetime-local" 748 + bind:value={endsAt} 749 + class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none" 750 + /> 751 + </label> 752 + </div> 753 + </div> 754 + </div> 755 + 756 + <!-- Location row --> 757 + {#if location} 758 + <div class="mb-6 flex items-center gap-4"> 759 + <div 760 + class="border-base-200 dark:border-base-700 flex size-12 shrink-0 items-center justify-center rounded-xl border" 761 + > 762 + <svg 763 + xmlns="http://www.w3.org/2000/svg" 764 + fill="none" 765 + viewBox="0 0 24 24" 766 + stroke-width="1.5" 767 + stroke="currentColor" 768 + class="text-base-900 dark:text-base-200 size-5" 769 + > 770 + <path 771 + stroke-linecap="round" 772 + stroke-linejoin="round" 773 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 774 + /> 775 + <path 776 + stroke-linecap="round" 777 + stroke-linejoin="round" 778 + 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" 779 + /> 780 + </svg> 781 + </div> 782 + <p class="text-base-900 dark:text-base-50 flex-1 font-semibold"> 783 + {getLocationDisplayString(location)} 784 + </p> 785 + <button 786 + type="button" 787 + onclick={removeLocation} 788 + class="text-base-400 shrink-0 hover:text-red-500" 789 + aria-label="Remove location" 790 + > 791 + <svg 792 + xmlns="http://www.w3.org/2000/svg" 793 + viewBox="0 0 20 20" 794 + fill="currentColor" 795 + class="size-4" 796 + > 797 + <path 798 + 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" 799 + /> 800 + </svg> 801 + </button> 802 + </div> 803 + {:else} 804 + <button 805 + type="button" 806 + onclick={() => (showLocationModal = true)} 807 + class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 mb-6 flex items-center gap-4 transition-colors" 808 + > 809 + <div 810 + class="border-base-200 dark:border-base-700 flex size-12 shrink-0 items-center justify-center rounded-xl border" 811 + > 812 + <svg 813 + xmlns="http://www.w3.org/2000/svg" 814 + fill="none" 815 + viewBox="0 0 24 24" 816 + stroke-width="1.5" 817 + stroke="currentColor" 818 + class="size-5" 819 + > 820 + <path 821 + stroke-linecap="round" 822 + stroke-linejoin="round" 823 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 824 + /> 825 + <path 826 + stroke-linecap="round" 827 + stroke-linejoin="round" 828 + 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" 829 + /> 830 + </svg> 831 + </div> 832 + <span class="text-sm">Add location</span> 833 + </button> 834 + {/if} 835 + 836 + <!-- About Event --> 837 + <div class="mt-8 mb-8"> 838 + <p 839 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 840 + > 841 + About 842 + </p> 843 + <textarea 844 + bind:value={description} 845 + rows={4} 846 + placeholder="What's this event about? @mentions, #hashtags and links will be detected automatically." 847 + class="text-base-700 dark:text-base-300 placeholder:text-base-300 dark:placeholder:text-base-700 w-full resize-none border-0 bg-transparent leading-relaxed focus:border-0 focus:ring-0 focus:outline-none" 848 + ></textarea> 849 + </div> 850 + 851 + {#if error} 852 + <p class="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p> 853 + {/if} 854 + 855 + <Button type="submit" disabled={submitting}> 856 + {submitting ? 'Saving...' : 'Save Changes'} 857 + </Button> 858 + </div> 859 + 860 + <!-- Hosted By --> 861 + <div class="order-3 md:order-0 md:col-start-1"> 862 + <p 863 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 864 + > 865 + Hosted By 866 + </p> 867 + <div class="flex items-center gap-2.5"> 868 + <FoxAvatar src={user.profile?.avatar} alt={hostName} class="size-8 shrink-0" /> 869 + <span class="text-base-900 dark:text-base-100 truncate text-sm font-medium"> 870 + {hostName} 871 + </span> 872 + </div> 873 + </div> 874 + 875 + <!-- Links --> 876 + <div class="order-4 md:order-0 md:col-start-1"> 877 + <p 878 + class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 879 + > 880 + Links 881 + </p> 882 + <div class="space-y-3"> 883 + {#each links as link, i (i)} 884 + <div class="group flex items-center gap-1.5"> 885 + <svg 886 + xmlns="http://www.w3.org/2000/svg" 887 + fill="none" 888 + viewBox="0 0 24 24" 889 + stroke-width="1.5" 890 + stroke="currentColor" 891 + class="text-base-700 dark:text-base-300 size-3.5 shrink-0" 892 + > 893 + <path 894 + stroke-linecap="round" 895 + stroke-linejoin="round" 896 + 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" 897 + /> 898 + </svg> 899 + <span class="text-base-700 dark:text-base-300 truncate text-sm"> 900 + {link.name || link.uri.replace(/^https?:\/\//, '')} 901 + </span> 902 + <button 903 + type="button" 904 + onclick={() => removeLink(i)} 905 + class="text-base-400 ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-500" 906 + aria-label="Remove link" 907 + > 908 + <svg 909 + xmlns="http://www.w3.org/2000/svg" 910 + viewBox="0 0 20 20" 911 + fill="currentColor" 912 + class="size-3.5" 913 + > 914 + <path 915 + 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" 916 + /> 917 + </svg> 918 + </button> 919 + </div> 920 + {/each} 921 + </div> 922 + 923 + <div class="relative mt-3"> 924 + <button 925 + type="button" 926 + onclick={() => (showLinkPopup = !showLinkPopup)} 927 + class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 flex items-center gap-1.5 text-sm transition-colors" 928 + > 929 + <svg 930 + xmlns="http://www.w3.org/2000/svg" 931 + fill="none" 932 + viewBox="0 0 24 24" 933 + stroke-width="1.5" 934 + stroke="currentColor" 935 + class="size-4" 936 + > 937 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 938 + </svg> 939 + Add link 940 + </button> 941 + 942 + {#if showLinkPopup} 943 + <div 944 + class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 absolute top-full left-0 z-10 mt-2 w-64 rounded-xl border p-3 shadow-lg" 945 + > 946 + <input 947 + type="url" 948 + bind:value={newLinkUri} 949 + placeholder="https://..." 950 + class="border-base-300 dark:border-base-700 bg-base-100 dark:bg-base-800 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 mb-2 w-full rounded-lg border px-2.5 py-1.5 text-sm focus:outline-none" 951 + /> 952 + <input 953 + type="text" 954 + bind:value={newLinkName} 955 + placeholder="Label (optional)" 956 + class="border-base-300 dark:border-base-700 bg-base-100 dark:bg-base-800 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 mb-3 w-full rounded-lg border px-2.5 py-1.5 text-sm focus:outline-none" 957 + /> 958 + <div class="flex justify-end gap-2"> 959 + <button 960 + type="button" 961 + onclick={() => (showLinkPopup = false)} 962 + class="text-base-500 dark:text-base-400 text-xs hover:underline" 963 + > 964 + Cancel 965 + </button> 966 + <button 967 + type="button" 968 + onclick={addLink} 969 + disabled={!newLinkUri.trim()} 970 + class="bg-base-900 dark:bg-base-100 text-base-50 dark:text-base-900 disabled:bg-base-300 dark:disabled:bg-base-700 rounded-lg px-3 py-1 text-xs font-medium disabled:cursor-not-allowed" 971 + > 972 + Add 973 + </button> 974 + </div> 975 + </div> 976 + {/if} 977 + </div> 978 + </div> 979 + </div> 980 + </form> 981 + {/if} 982 + </div> 983 + </div> 984 + 985 + <!-- Location modal --> 986 + <Modal bind:open={showLocationModal}> 987 + <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Add location</p> 988 + <form 989 + onsubmit={(e) => { 990 + e.preventDefault(); 991 + searchLocation(); 992 + }} 993 + class="mt-2" 994 + > 995 + <div class="flex gap-2"> 996 + <input 997 + type="text" 998 + bind:value={locationSearch} 999 + placeholder="Search for a city or address..." 1000 + class="border-base-300 dark:border-base-700 bg-base-100 dark:bg-base-800 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 flex-1 rounded-lg border px-3 py-2 text-sm focus:outline-none" 1001 + /> 1002 + <Button type="submit" disabled={locationSearching || !locationSearch.trim()}> 1003 + {locationSearching ? 'Searching...' : 'Search'} 1004 + </Button> 1005 + </div> 1006 + </form> 1007 + 1008 + {#if locationError} 1009 + <p class="mt-3 text-sm text-red-600 dark:text-red-400">{locationError}</p> 1010 + {/if} 1011 + 1012 + {#if locationResult} 1013 + <div 1014 + class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 mt-4 overflow-hidden rounded-xl border p-4" 1015 + > 1016 + <div class="flex items-start gap-3"> 1017 + <svg 1018 + xmlns="http://www.w3.org/2000/svg" 1019 + fill="none" 1020 + viewBox="0 0 24 24" 1021 + stroke-width="1.5" 1022 + stroke="currentColor" 1023 + class="text-base-500 mt-0.5 size-5 shrink-0" 1024 + > 1025 + <path 1026 + stroke-linecap="round" 1027 + stroke-linejoin="round" 1028 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 1029 + /> 1030 + <path 1031 + stroke-linecap="round" 1032 + stroke-linejoin="round" 1033 + 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" 1034 + /> 1035 + </svg> 1036 + <div class="min-w-0 flex-1"> 1037 + <p class="text-base-900 dark:text-base-50 font-medium"> 1038 + {getLocationDisplayString(locationResult.location)} 1039 + </p> 1040 + <p class="text-base-500 dark:text-base-400 mt-0.5 truncate text-xs"> 1041 + {locationResult.displayName} 1042 + </p> 1043 + </div> 1044 + </div> 1045 + <div class="mt-4 flex justify-end"> 1046 + <Button onclick={confirmLocation}>Use this location</Button> 1047 + </div> 1048 + </div> 1049 + {/if} 1050 + 1051 + <p class="text-base-400 dark:text-base-500 mt-4 text-xs"> 1052 + Geocoding by <a 1053 + href="https://nominatim.openstreetmap.org/" 1054 + class="hover:text-base-600 dark:hover:text-base-400 underline" 1055 + target="_blank">Nominatim</a 1056 + > 1057 + / &copy; 1058 + <a 1059 + href="https://www.openstreetmap.org/copyright" 1060 + class="hover:text-base-600 dark:hover:text-base-400 underline" 1061 + target="_blank">OpenStreetMap contributors</a 1062 + > 1063 + </p> 1064 + </Modal>
+256 -4
src/routes/[[actor=actor]]/events/new/+page.svelte
··· 10 10 import { onMount } from 'svelte'; 11 11 import { browser } from '$app/environment'; 12 12 import { putImage, getImage, deleteImage } from '$lib/components/image-store'; 13 + import Modal from '$lib/components/modal/Modal.svelte'; 13 14 14 15 const DRAFT_KEY = 'blento-event-draft'; 15 16 16 17 type EventMode = 'inperson' | 'virtual' | 'hybrid'; 17 18 19 + interface EventLocation { 20 + street?: string; 21 + locality?: string; 22 + region?: string; 23 + country?: string; 24 + } 25 + 18 26 interface EventDraft { 19 27 name: string; 20 28 description: string; ··· 23 31 links: Array<{ uri: string; name: string }>; 24 32 mode?: EventMode; 25 33 thumbnailKey?: string; 34 + location?: EventLocation; 26 35 } 27 36 28 37 let thumbnailKey: string | null = $state(null); ··· 37 46 let submitting = $state(false); 38 47 let error: string | null = $state(null); 39 48 49 + let location: EventLocation | null = $state(null); 50 + let showLocationModal = $state(false); 51 + let locationSearch = $state(''); 52 + let locationSearching = $state(false); 53 + let locationError = $state(''); 54 + let locationResult: { displayName: string; location: EventLocation } | null = $state(null); 55 + 40 56 let links: Array<{ uri: string; name: string }> = $state([]); 41 57 let showLinkPopup = $state(false); 42 58 let newLinkUri = $state(''); ··· 56 72 endsAt = draft.endsAt || ''; 57 73 links = draft.links || []; 58 74 mode = draft.mode || 'inperson'; 75 + location = draft.location || null; 59 76 60 77 if (draft.thumbnailKey) { 61 78 const img = await getImage(draft.thumbnailKey); ··· 82 99 saveDraftTimeout = setTimeout(() => { 83 100 const draft: EventDraft = { name, description, startsAt, endsAt, links, mode }; 84 101 if (thumbnailKey) draft.thumbnailKey = thumbnailKey; 85 - const hasContent = name || description || startsAt || endsAt || links.length > 0; 102 + if (location) draft.location = location; 103 + const hasContent = name || description || startsAt || endsAt || links.length > 0 || location; 86 104 if (hasContent) { 87 105 localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); 88 106 hasDraft = true; ··· 95 113 96 114 $effect(() => { 97 115 // track all draft fields by reading them 98 - void [name, description, startsAt, endsAt, mode, JSON.stringify(links)]; 116 + void [ 117 + name, 118 + description, 119 + startsAt, 120 + endsAt, 121 + mode, 122 + JSON.stringify(links), 123 + JSON.stringify(location) 124 + ]; 99 125 saveDraft(); 100 126 }); 101 127 ··· 108 134 endsAt = ''; 109 135 links = []; 110 136 mode = 'inperson'; 137 + location = null; 111 138 thumbnailFile = null; 112 139 if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 113 140 thumbnailPreview = null; ··· 115 142 hasDraft = false; 116 143 } 117 144 145 + async function searchLocation() { 146 + const q = locationSearch.trim(); 147 + if (!q) return; 148 + locationError = ''; 149 + locationSearching = true; 150 + locationResult = null; 151 + 152 + try { 153 + const response = await fetch('/api/geocoding?q=' + encodeURIComponent(q)); 154 + if (!response.ok) throw new Error('response not ok'); 155 + const data = await response.json(); 156 + if (!data || data.error) throw new Error('no results'); 157 + 158 + const addr = data.address || {}; 159 + const road = addr.road || ''; 160 + const houseNumber = addr.house_number || ''; 161 + const street = road ? (houseNumber ? `${road} ${houseNumber}` : road) : ''; 162 + const locality = 163 + addr.city || addr.town || addr.village || addr.municipality || addr.hamlet || ''; 164 + const region = addr.state || addr.county || ''; 165 + const country = addr.country || ''; 166 + 167 + locationResult = { 168 + displayName: data.display_name || q, 169 + location: { 170 + ...(street && { street }), 171 + ...(locality && { locality }), 172 + ...(region && { region }), 173 + ...(country && { country }) 174 + } 175 + }; 176 + } catch { 177 + locationError = "Couldn't find that location."; 178 + } finally { 179 + locationSearching = false; 180 + } 181 + } 182 + 183 + function confirmLocation() { 184 + if (locationResult) { 185 + location = locationResult.location; 186 + } 187 + showLocationModal = false; 188 + locationSearch = ''; 189 + locationResult = null; 190 + locationError = ''; 191 + } 192 + 193 + function removeLocation() { 194 + location = null; 195 + } 196 + 197 + function getLocationDisplayString(loc: EventLocation): string { 198 + return [loc.street, loc.locality, loc.region, loc.country].filter(Boolean).join(', '); 199 + } 200 + 118 201 function addLink() { 119 202 const uri = newLinkUri.trim(); 120 203 if (!uri) return; ··· 324 407 if (links.length > 0) { 325 408 record.uris = links; 326 409 } 410 + if (location) { 411 + record.locations = [ 412 + { 413 + $type: 'community.lexicon.location.address', 414 + ...location 415 + } 416 + ]; 417 + } 327 418 328 419 const response = await user.client.post('com.atproto.repo.createRecord', { 329 420 input: { ··· 359 450 <title>Create Event</title> 360 451 </svelte:head> 361 452 362 - <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12 sm:py-12"> 363 - <div class="mx-auto max-w-4xl"> 453 + <div class="min-h-screen px-6 py-12 sm:py-12"> 454 + <div class="mx-auto max-w-2xl"> 364 455 {#if user.isInitializing} 365 456 <div class="flex items-center gap-3"> 366 457 <div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div> ··· 565 656 </div> 566 657 </div> 567 658 659 + <!-- Location row --> 660 + {#if location} 661 + <div class="mb-6 flex items-center gap-4"> 662 + <div 663 + class="border-base-200 dark:border-base-700 flex size-12 shrink-0 items-center justify-center rounded-xl border" 664 + > 665 + <svg 666 + xmlns="http://www.w3.org/2000/svg" 667 + fill="none" 668 + viewBox="0 0 24 24" 669 + stroke-width="1.5" 670 + stroke="currentColor" 671 + class="text-base-900 dark:text-base-200 size-5" 672 + > 673 + <path 674 + stroke-linecap="round" 675 + stroke-linejoin="round" 676 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 677 + /> 678 + <path 679 + stroke-linecap="round" 680 + stroke-linejoin="round" 681 + 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" 682 + /> 683 + </svg> 684 + </div> 685 + <p class="text-base-900 dark:text-base-50 flex-1 font-semibold"> 686 + {getLocationDisplayString(location)} 687 + </p> 688 + <button 689 + type="button" 690 + onclick={removeLocation} 691 + class="text-base-400 shrink-0 hover:text-red-500" 692 + aria-label="Remove location" 693 + > 694 + <svg 695 + xmlns="http://www.w3.org/2000/svg" 696 + viewBox="0 0 20 20" 697 + fill="currentColor" 698 + class="size-4" 699 + > 700 + <path 701 + 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" 702 + /> 703 + </svg> 704 + </button> 705 + </div> 706 + {:else} 707 + <button 708 + type="button" 709 + onclick={() => (showLocationModal = true)} 710 + class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 mb-6 flex items-center gap-4 transition-colors" 711 + > 712 + <div 713 + class="border-base-200 dark:border-base-700 flex size-12 shrink-0 items-center justify-center rounded-xl border" 714 + > 715 + <svg 716 + xmlns="http://www.w3.org/2000/svg" 717 + fill="none" 718 + viewBox="0 0 24 24" 719 + stroke-width="1.5" 720 + stroke="currentColor" 721 + class="size-5" 722 + > 723 + <path 724 + stroke-linecap="round" 725 + stroke-linejoin="round" 726 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 727 + /> 728 + <path 729 + stroke-linecap="round" 730 + stroke-linejoin="round" 731 + 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" 732 + /> 733 + </svg> 734 + </div> 735 + <span class="text-sm">Add location</span> 736 + </button> 737 + {/if} 738 + 568 739 <!-- About Event --> 569 740 <div class="mt-8 mb-8"> 570 741 <p ··· 713 884 {/if} 714 885 </div> 715 886 </div> 887 + 888 + <!-- Location modal --> 889 + <Modal bind:open={showLocationModal}> 890 + <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Add location</p> 891 + <form 892 + onsubmit={(e) => { 893 + e.preventDefault(); 894 + searchLocation(); 895 + }} 896 + class="mt-2" 897 + > 898 + <div class="flex gap-2"> 899 + <input 900 + type="text" 901 + bind:value={locationSearch} 902 + placeholder="Search for a city or address..." 903 + class="border-base-300 dark:border-base-700 bg-base-100 dark:bg-base-800 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 flex-1 rounded-lg border px-3 py-2 text-sm focus:outline-none" 904 + /> 905 + <Button type="submit" disabled={locationSearching || !locationSearch.trim()}> 906 + {locationSearching ? 'Searching...' : 'Search'} 907 + </Button> 908 + </div> 909 + </form> 910 + 911 + {#if locationError} 912 + <p class="mt-3 text-sm text-red-600 dark:text-red-400">{locationError}</p> 913 + {/if} 914 + 915 + {#if locationResult} 916 + <div 917 + class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 mt-4 overflow-hidden rounded-xl border p-4" 918 + > 919 + <div class="flex items-start gap-3"> 920 + <svg 921 + xmlns="http://www.w3.org/2000/svg" 922 + fill="none" 923 + viewBox="0 0 24 24" 924 + stroke-width="1.5" 925 + stroke="currentColor" 926 + class="text-base-500 mt-0.5 size-5 shrink-0" 927 + > 928 + <path 929 + stroke-linecap="round" 930 + stroke-linejoin="round" 931 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 932 + /> 933 + <path 934 + stroke-linecap="round" 935 + stroke-linejoin="round" 936 + 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" 937 + /> 938 + </svg> 939 + <div class="min-w-0 flex-1"> 940 + <p class="text-base-900 dark:text-base-50 font-medium"> 941 + {getLocationDisplayString(locationResult.location)} 942 + </p> 943 + <p class="text-base-500 dark:text-base-400 mt-0.5 truncate text-xs"> 944 + {locationResult.displayName} 945 + </p> 946 + </div> 947 + </div> 948 + <div class="mt-4 flex justify-end"> 949 + <Button onclick={confirmLocation}>Use this location</Button> 950 + </div> 951 + </div> 952 + {/if} 953 + 954 + <p class="text-base-400 dark:text-base-500 mt-4 text-xs"> 955 + Geocoding by <a 956 + href="https://nominatim.openstreetmap.org/" 957 + class="hover:text-base-600 dark:hover:text-base-400 underline" 958 + target="_blank">Nominatim</a 959 + > 960 + / &copy; 961 + <a 962 + href="https://www.openstreetmap.org/copyright" 963 + class="hover:text-base-600 dark:hover:text-base-400 underline" 964 + target="_blank">OpenStreetMap contributors</a 965 + > 966 + </p> 967 + </Modal>
+2 -1
src/routes/api/geocoding/+server.ts
··· 8 8 } 9 9 10 10 const nomUrl = 11 - 'https://nominatim.openstreetmap.org/search?format=json&q=' + encodeURIComponent(q); 11 + 'https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&q=' + 12 + encodeURIComponent(q); 12 13 13 14 try { 14 15 const data = await fetch(nomUrl, {