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