tangled
alpha
login
or
join now
flo-bit.dev
/
blento
21
fork
atom
your personal website on atproto - mirror
blento.app
21
fork
atom
overview
issues
1
pulls
pipelines
fixes, design, start attendees
Florian
3 weeks ago
428ce852
14ebad75
+1588
-12
8 changed files
expand all
collapse all
unified
split
src
routes
[[actor=actor]]
events
+page.svelte
[rkey]
+page.svelte
EventAttendees.svelte
api.remote.ts
edit
+page.server.ts
+page.svelte
new
+page.svelte
api
geocoding
+server.ts
+2
-3
src/routes/[[actor=actor]]/events/+page.svelte
···
75
return { url, alt: media.alt || event.name };
76
}
77
78
-
let actorPrefix = $derived(data.hostProfile?.handle ? `/${data.hostProfile.handle}` : `/${did}`);
79
let isOwner = $derived(user.isLoggedIn && user.did === did);
80
</script>
81
···
111
</div>
112
</div>
113
{#if isOwner}
114
-
<Button href="{actorPrefix}/events/new" variant="primary">New event</Button>
115
{/if}
116
</div>
117
···
124
{@const location = getLocationString(event.locations)}
125
{@const rkey = event.rkey}
126
<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-950 block overflow-hidden rounded-2xl border transition-colors"
129
>
130
<!-- Thumbnail -->
···
75
return { url, alt: media.alt || event.name };
76
}
77
0
78
let isOwner = $derived(user.isLoggedIn && user.did === did);
79
</script>
80
···
110
</div>
111
</div>
112
{#if isOwner}
113
+
<Button href="./events/new" variant="primary">New event</Button>
114
{/if}
115
</div>
116
···
123
{@const location = getLocationString(event.locations)}
124
{@const rkey = event.rkey}
125
<a
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"
128
>
129
<!-- Thumbnail -->
+26
-6
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 { Avatar as FoxAvatar, Badge } from '@foxui/core';
0
5
import Avatar from 'svelte-boring-avatars';
6
import EventRsvp from './EventRsvp.svelte';
0
7
import { page } from '$app/state';
8
import { segmentize, type Facet } from '@atcute/bluesky-richtext-segmenter';
9
import { sanitize } from '$lib/sanitize';
···
157
let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`);
158
159
let ogImageUrl = $derived(`${page.url.origin}${page.url.pathname}/og.png`);
0
0
160
</script>
161
162
<svelte:head>
···
213
214
<!-- Right column: event details -->
215
<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>
0
0
0
0
0
221
222
<!-- Mode badge -->
223
{#if eventData.mode}
···
288
289
<EventRsvp {eventUri} eventCid={data.eventCid} />
290
0
0
0
0
0
0
291
<!-- About Event -->
292
{#if descriptionHtml}
293
<div class="mt-8 mb-8">
···
363
</div>
364
</div>
365
{/if}
0
0
0
0
0
366
367
<!-- View on Smoke Signal link -->
368
<a
···
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 { segmentize, type Facet } from '@atcute/bluesky-richtext-segmenter';
11
import { sanitize } from '$lib/sanitize';
···
159
let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`);
160
161
let ogImageUrl = $derived(`${page.url.origin}${page.url.pathname}/og.png`);
162
+
163
+
let isOwner = $derived(user.isLoggedIn && user.did === did);
164
</script>
165
166
<svelte:head>
···
217
218
<!-- Right column: event details -->
219
<div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1">
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>
230
231
<!-- Mode badge -->
232
{#if eventData.mode}
···
297
298
<EventRsvp {eventUri} eventCid={data.eventCid} />
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
+
306
<!-- About Event -->
307
{#if descriptionHtml}
308
<div class="mt-8 mb-8">
···
378
</div>
379
</div>
380
{/if}
381
+
382
+
<!-- Attendees -->
383
+
<!-- <div class="order-5 md:order-0 md:col-start-1">
384
+
<EventAttendees {eventUri} {did} />
385
+
</div> -->
386
387
<!-- View on Smoke Signal link -->
388
<a
+134
src/routes/[[actor=actor]]/events/[rkey]/EventAttendees.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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}
+47
src/routes/[[actor=actor]]/events/[rkey]/api.remote.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
+
/ ©
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>
+254
-2
src/routes/[[actor=actor]]/events/new/+page.svelte
···
10
import { onMount } from 'svelte';
11
import { browser } from '$app/environment';
12
import { putImage, getImage, deleteImage } from '$lib/components/image-store';
0
13
14
const DRAFT_KEY = 'blento-event-draft';
15
16
type EventMode = 'inperson' | 'virtual' | 'hybrid';
0
0
0
0
0
0
0
17
18
interface EventDraft {
19
name: string;
···
23
links: Array<{ uri: string; name: string }>;
24
mode?: EventMode;
25
thumbnailKey?: string;
0
26
}
27
28
let thumbnailKey: string | null = $state(null);
···
36
let thumbnailPreview: string | null = $state(null);
37
let submitting = $state(false);
38
let error: string | null = $state(null);
0
0
0
0
0
0
0
39
40
let links: Array<{ uri: string; name: string }> = $state([]);
41
let showLinkPopup = $state(false);
···
56
endsAt = draft.endsAt || '';
57
links = draft.links || [];
58
mode = draft.mode || 'inperson';
0
59
60
if (draft.thumbnailKey) {
61
const img = await getImage(draft.thumbnailKey);
···
82
saveDraftTimeout = setTimeout(() => {
83
const draft: EventDraft = { name, description, startsAt, endsAt, links, mode };
84
if (thumbnailKey) draft.thumbnailKey = thumbnailKey;
85
-
const hasContent = name || description || startsAt || endsAt || links.length > 0;
0
86
if (hasContent) {
87
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
88
hasDraft = true;
···
95
96
$effect(() => {
97
// track all draft fields by reading them
98
-
void [name, description, startsAt, endsAt, mode, JSON.stringify(links)];
0
0
0
0
0
0
0
0
99
saveDraft();
100
});
101
···
108
endsAt = '';
109
links = [];
110
mode = 'inperson';
0
111
thumbnailFile = null;
112
if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview);
113
thumbnailPreview = null;
···
115
hasDraft = false;
116
}
117
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
118
function addLink() {
119
const uri = newLinkUri.trim();
120
if (!uri) return;
···
323
}
324
if (links.length > 0) {
325
record.uris = links;
0
0
0
0
0
0
0
0
326
}
327
328
const response = await user.client.post('com.atproto.repo.createRecord', {
···
565
</div>
566
</div>
567
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
568
<!-- About Event -->
569
<div class="mt-8 mb-8">
570
<p
···
713
{/if}
714
</div>
715
</div>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
10
import { onMount } from 'svelte';
11
import { browser } from '$app/environment';
12
import { putImage, getImage, deleteImage } from '$lib/components/image-store';
13
+
import Modal from '$lib/components/modal/Modal.svelte';
14
15
const DRAFT_KEY = 'blento-event-draft';
16
17
type EventMode = 'inperson' | 'virtual' | 'hybrid';
18
+
19
+
interface EventLocation {
20
+
street?: string;
21
+
locality?: string;
22
+
region?: string;
23
+
country?: string;
24
+
}
25
26
interface EventDraft {
27
name: string;
···
31
links: Array<{ uri: string; name: string }>;
32
mode?: EventMode;
33
thumbnailKey?: string;
34
+
location?: EventLocation;
35
}
36
37
let thumbnailKey: string | null = $state(null);
···
45
let thumbnailPreview: string | null = $state(null);
46
let submitting = $state(false);
47
let error: string | null = $state(null);
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
56
let links: Array<{ uri: string; name: string }> = $state([]);
57
let showLinkPopup = $state(false);
···
72
endsAt = draft.endsAt || '';
73
links = draft.links || [];
74
mode = draft.mode || 'inperson';
75
+
location = draft.location || null;
76
77
if (draft.thumbnailKey) {
78
const img = await getImage(draft.thumbnailKey);
···
99
saveDraftTimeout = setTimeout(() => {
100
const draft: EventDraft = { name, description, startsAt, endsAt, links, mode };
101
if (thumbnailKey) draft.thumbnailKey = thumbnailKey;
102
+
if (location) draft.location = location;
103
+
const hasContent = name || description || startsAt || endsAt || links.length > 0 || location;
104
if (hasContent) {
105
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
106
hasDraft = true;
···
113
114
$effect(() => {
115
// track all draft fields by reading them
116
+
void [
117
+
name,
118
+
description,
119
+
startsAt,
120
+
endsAt,
121
+
mode,
122
+
JSON.stringify(links),
123
+
JSON.stringify(location)
124
+
];
125
saveDraft();
126
});
127
···
134
endsAt = '';
135
links = [];
136
mode = 'inperson';
137
+
location = null;
138
thumbnailFile = null;
139
if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview);
140
thumbnailPreview = null;
···
142
hasDraft = false;
143
}
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
+
201
function addLink() {
202
const uri = newLinkUri.trim();
203
if (!uri) return;
···
406
}
407
if (links.length > 0) {
408
record.uris = links;
409
+
}
410
+
if (location) {
411
+
record.locations = [
412
+
{
413
+
$type: 'community.lexicon.location.address',
414
+
...location
415
+
}
416
+
];
417
}
418
419
const response = await user.client.post('com.atproto.repo.createRecord', {
···
656
</div>
657
</div>
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
+
739
<!-- About Event -->
740
<div class="mt-8 mb-8">
741
<p
···
884
{/if}
885
</div>
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
+
/ ©
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
}
9
10
const nomUrl =
11
-
'https://nominatim.openstreetmap.org/search?format=json&q=' + encodeURIComponent(q);
0
12
13
try {
14
const data = await fetch(nomUrl, {
···
8
}
9
10
const nomUrl =
11
+
'https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&q=' +
12
+
encodeURIComponent(q);
13
14
try {
15
const data = await fetch(nomUrl, {