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
refactor
Florian
2 weeks ago
df7c39b1
7ecddd19
+146
-1050
5 changed files
expand all
collapse all
unified
split
src
routes
[[actor=actor]]
events
+page.svelte
[rkey]
+page.svelte
edit
+page.server.ts
+page.svelte
new
+page.svelte
+13
-1
src/routes/[[actor=actor]]/events/+page.svelte
···
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';
0
0
7
8
let { data } = $props();
9
···
110
</div>
111
</div>
112
{#if isOwner}
113
-
<Button href="./events/new" variant="primary">New event</Button>
0
0
0
0
0
0
0
0
0
0
114
{/if}
115
</div>
116
···
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 * as TID from '@atcute/tid';
8
+
import { goto } from '$app/navigation';
9
10
let { data } = $props();
11
···
112
</div>
113
</div>
114
{#if isOwner}
115
+
<Button
116
+
variant="primary"
117
+
onclick={() => {
118
+
const rkey = TID.now();
119
+
const handle =
120
+
user.profile?.handle && user.profile.handle !== 'handle.invalid'
121
+
? user.profile.handle
122
+
: user.did;
123
+
goto(`/${handle}/events/${rkey}/edit`);
124
+
}}>New event</Button
125
+
>
126
{/if}
127
</div>
128
+1
-1
src/routes/[[actor=actor]]/events/[rkey]/+page.svelte
···
251
{eventData.name}
252
</h1>
253
{#if isOwner}
254
-
<Button href="./edit" size="sm" class="shrink-0">Edit</Button>
255
{/if}
256
</div>
257
···
251
{eventData.name}
252
</h1>
253
{#if isOwner}
254
+
<Button href="./{rkey}/edit" size="sm" class="shrink-0">Edit</Button>
255
{/if}
256
</div>
257
+8
-2
src/routes/[[actor=actor]]/events/[rkey]/edit/+page.server.ts
···
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 })
···
40
]);
41
42
if (!eventRecord?.value) {
43
-
throw error(404, 'Event not found');
0
0
0
0
0
0
44
}
45
46
const eventData: EventData = eventRecord.value as EventData;
···
22
did: did as Did,
23
collection: 'community.lexicon.calendar.event',
24
rkey
25
+
}).catch(() => null),
26
cache
27
? cache.getProfile(did as Did).catch(() => null)
28
: getBlentoOrBskyProfile({ did: did as Did })
···
40
]);
41
42
if (!eventRecord?.value) {
43
+
return {
44
+
eventData: null,
45
+
did,
46
+
rkey,
47
+
hostProfile: hostProfile ?? null,
48
+
eventCid: null
49
+
};
50
}
51
52
const eventData: EventData = eventRecord.value as EventData;
+124
-79
src/routes/[[actor=actor]]/events/[rkey]/edit/+page.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';
0
15
16
let { data } = $props();
17
18
let rkey: string = $derived(data.rkey);
0
19
let DRAFT_KEY = $derived(`blento-event-edit-${rkey}`);
20
21
type EventMode = 'inperson' | 'virtual' | 'hybrid';
···
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) {
···
109
}
110
}
111
locationChanged = false;
0
112
113
-
// Load existing thumbnail from CDN
0
0
114
if (eventData.media && eventData.media.length > 0) {
115
const media = eventData.media.find((m) => m.role === 'thumbnail');
116
if (media?.content) {
···
123
}
124
}
125
0
0
0
0
0
0
0
0
0
0
0
0
0
126
onMount(async () => {
0
0
0
0
0
0
0
0
0
127
const saved = localStorage.getItem(DRAFT_KEY);
128
if (saved) {
129
try {
···
137
locationChanged = draft.locationChanged || false;
138
if (draft.locationChanged) {
139
location = draft.location || null;
0
0
0
140
}
141
thumbnailChanged = draft.thumbnailChanged || false;
142
···
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;
···
218
if (thumbnailKey) deleteImage(thumbnailKey);
219
thumbnailKey = null;
220
thumbnailChanged = false;
221
-
populateFromEventData();
0
0
0
0
0
0
0
0
0
0
0
0
0
222
hasDraft = false;
223
}
224
···
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 });
···
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();
0
478
479
const record: Record<string, unknown> = {
480
$type: 'community.lexicon.calendar.event',
···
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();
···
503
if (links.length > 0) {
504
record.uris = links;
505
}
506
-
if (locationChanged) {
507
if (location) {
508
record.locations = [
509
{
···
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
···
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
}
···
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">
···
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>
0
0
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>
···
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"
0
0
0
0
0
0
0
0
0
0
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
···
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"
···
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}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
655
</div>
656
657
<!-- Right column: event details -->
···
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 -->
···
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
···
853
{/if}
854
855
<Button type="submit" disabled={submitting}>
856
-
{submitting ? 'Saving...' : 'Save Changes'}
0
0
0
0
0
0
857
</Button>
858
</div>
859
···
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
+
import Avatar from 'svelte-boring-avatars';
16
17
let { data } = $props();
18
19
let rkey: string = $derived(data.rkey);
20
+
let isNew = $derived(data.eventData === null);
21
let DRAFT_KEY = $derived(`blento-event-edit-${rkey}`);
22
23
type EventMode = 'inperson' | 'virtual' | 'hybrid';
···
83
return 'inperson';
84
}
85
86
+
function populateLocationFromEventData() {
87
const eventData = data.eventData;
88
+
if (!eventData) return;
0
0
0
0
0
0
0
89
if (eventData.locations && eventData.locations.length > 0) {
90
const loc = eventData.locations.find((v) => v.$type === 'community.lexicon.location.address');
91
if (loc) {
···
104
}
105
}
106
locationChanged = false;
107
+
}
108
109
+
function populateThumbnailFromEventData() {
110
+
const eventData = data.eventData;
111
+
if (!eventData) return;
112
if (eventData.media && eventData.media.length > 0) {
113
const media = eventData.media.find((m) => m.role === 'thumbnail');
114
if (media?.content) {
···
121
}
122
}
123
124
+
function populateFromEventData() {
125
+
const eventData = data.eventData;
126
+
if (!eventData) return;
127
+
name = eventData.name || '';
128
+
description = eventData.description || '';
129
+
startsAt = eventData.startsAt ? isoToDatetimeLocal(eventData.startsAt) : '';
130
+
endsAt = eventData.endsAt ? isoToDatetimeLocal(eventData.endsAt) : '';
131
+
mode = eventData.mode ? stripModePrefix(eventData.mode) : 'inperson';
132
+
links = eventData.uris ? eventData.uris.map((l) => ({ uri: l.uri, name: l.name || '' })) : [];
133
+
populateLocationFromEventData();
134
+
populateThumbnailFromEventData();
135
+
}
136
+
137
onMount(async () => {
138
+
// Migrate old creation draft if this is a new event
139
+
if (isNew) {
140
+
const oldDraft = localStorage.getItem('blento-event-draft');
141
+
if (oldDraft && !localStorage.getItem(DRAFT_KEY)) {
142
+
localStorage.setItem(DRAFT_KEY, oldDraft);
143
+
localStorage.removeItem('blento-event-draft');
144
+
}
145
+
}
146
+
147
const saved = localStorage.getItem(DRAFT_KEY);
148
if (saved) {
149
try {
···
157
locationChanged = draft.locationChanged || false;
158
if (draft.locationChanged) {
159
location = draft.location || null;
160
+
} else if (!isNew) {
161
+
// For edits without location changes, load from event data
162
+
populateLocationFromEventData();
163
}
164
thumbnailChanged = draft.thumbnailChanged || false;
165
···
171
thumbnailPreview = URL.createObjectURL(img.blob);
172
thumbnailChanged = true;
173
}
174
+
} else if (!thumbnailChanged && !isNew) {
175
// No new thumbnail in draft, show existing one from event data
176
+
populateThumbnailFromEventData();
0
0
0
0
0
0
0
0
0
0
0
0
177
}
178
179
hasDraft = true;
180
} catch {
181
localStorage.removeItem(DRAFT_KEY);
182
+
if (!isNew) populateFromEventData();
183
}
184
+
} else if (!isNew) {
185
populateFromEventData();
186
}
187
draftLoaded = true;
···
229
if (thumbnailKey) deleteImage(thumbnailKey);
230
thumbnailKey = null;
231
thumbnailChanged = false;
232
+
if (isNew) {
233
+
name = '';
234
+
description = '';
235
+
startsAt = '';
236
+
endsAt = '';
237
+
links = [];
238
+
mode = 'inperson';
239
+
location = null;
240
+
thumbnailFile = null;
241
+
if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview);
242
+
thumbnailPreview = null;
243
+
} else {
244
+
populateFromEventData();
245
+
}
246
hasDraft = false;
247
}
248
···
471
try {
472
let media: Array<Record<string, unknown>> | undefined;
473
474
+
if (isNew || thumbnailChanged) {
475
if (thumbnailFile) {
476
const compressed = await compressImage(thumbnailFile);
477
const blobRef = await uploadBlob({ blob: compressed.blob });
···
488
];
489
}
490
}
491
+
// If changed/new but no thumbnailFile, media stays undefined (thumbnail removed/absent)
492
} else {
493
// Thumbnail not changed — reuse original media from eventData
494
+
if (data.eventData?.media && data.eventData.media.length > 0) {
495
media = data.eventData.media as Array<Record<string, unknown>>;
496
}
497
}
498
499
+
const createdAt = isNew
500
+
? new Date().toISOString()
501
+
: ((data.eventData as Record<string, unknown>)?.createdAt as string) ||
502
+
new Date().toISOString();
503
504
const record: Record<string, unknown> = {
505
$type: 'community.lexicon.calendar.event',
···
507
mode: `community.lexicon.calendar.event#${mode}`,
508
status: 'community.lexicon.calendar.event#scheduled',
509
startsAt: new Date(startsAt).toISOString(),
510
+
createdAt
511
};
512
513
const trimmedDescription = description.trim();
···
528
if (links.length > 0) {
529
record.uris = links;
530
}
531
+
if (isNew || locationChanged) {
532
if (location) {
533
record.locations = [
534
{
···
537
}
538
];
539
}
540
+
// If changed/new but no location, locations stays undefined (removed/absent)
541
+
} else if (data.eventData?.locations && data.eventData.locations.length > 0) {
542
record.locations = data.eventData.locations;
543
}
544
···
557
: user.did;
558
goto(`/${handle}/events/${rkey}`);
559
} else {
560
+
error = `Failed to ${isNew ? 'create' : 'save'} event. Please try again.`;
561
}
562
} catch (e) {
563
+
console.error(`Failed to ${isNew ? 'create' : 'save'} event:`, e);
564
+
error = `Failed to ${isNew ? 'create' : 'save'} event. Please try again.`;
565
} finally {
566
submitting = false;
567
}
···
569
</script>
570
571
<svelte:head>
572
+
<title>{isNew ? 'Create Event' : 'Edit Event'}</title>
573
</svelte:head>
574
575
<div class="min-h-screen px-6 py-12 sm:py-12">
···
583
<div
584
class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 rounded-2xl border p-8 text-center"
585
>
586
+
<p class="text-base-600 dark:text-base-400 mb-4">
587
+
Log in to {isNew ? 'create an event' : 'edit this event'}.
588
+
</p>
589
<Button onclick={() => loginModalState.show()}>Log in</Button>
590
</div>
591
{:else}
592
<div class="mb-6 flex items-center gap-3">
593
+
<Badge size="sm">{isNew ? 'Local draft' : 'Local edit'}</Badge>
594
{#if hasDraft}
595
<button
596
type="button"
597
onclick={deleteDraft}
598
class="text-base-500 dark:text-base-400 cursor-pointer text-xs hover:text-red-500 hover:underline"
599
>
600
+
{isNew ? 'Delete draft' : 'Discard changes'}
601
</button>
602
{/if}
603
</div>
···
627
onchange={onFileChange}
628
class="hidden"
629
/>
630
+
<div class="group relative">
631
+
{#if thumbnailPreview}
632
+
<img
633
+
src={thumbnailPreview}
634
+
alt="Thumbnail preview"
635
+
class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover"
636
+
/>
637
+
{:else}
638
+
<div
639
+
class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full"
640
+
>
641
+
<Avatar
642
+
size={400}
643
+
name={rkey}
644
+
variant="marble"
645
+
colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']}
646
+
square
647
/>
648
+
</div>
649
+
{/if}
650
+
<!-- Upload overlay on hover -->
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
651
<button
652
type="button"
653
onclick={() => fileInput?.click()}
654
+
class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-2xl bg-black/0 text-white/0 transition-colors group-hover:bg-black/40 group-hover:text-white/90 {isDragOver
655
+
? 'bg-black/40 text-white/90'
656
: ''}"
657
>
658
<svg
···
661
viewBox="0 0 24 24"
662
stroke-width="1.5"
663
stroke="currentColor"
664
+
class="size-6"
665
>
666
<path
667
stroke-linecap="round"
···
669
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"
670
/>
671
</svg>
672
+
<span class="text-sm font-medium">Upload thumbnail</span>
673
</button>
674
+
{#if thumbnailPreview}
675
+
<button
676
+
type="button"
677
+
onclick={removeThumbnail}
678
+
aria-label="Remove thumbnail"
679
+
class="bg-base-900/70 absolute top-2 right-2 flex size-7 items-center justify-center rounded-full text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600"
680
+
>
681
+
<svg
682
+
xmlns="http://www.w3.org/2000/svg"
683
+
viewBox="0 0 20 20"
684
+
fill="currentColor"
685
+
class="size-4"
686
+
>
687
+
<path
688
+
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"
689
+
/>
690
+
</svg>
691
+
</button>
692
+
{/if}
693
+
</div>
694
</div>
695
696
<!-- Right column: event details -->
···
701
bind:value={name}
702
required
703
placeholder="Event name"
704
+
class="text-base-900 dark:text-base-50 placeholder:text-base-500 dark:placeholder:text-base-500 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"
705
/>
706
707
<!-- Mode toggle -->
···
883
bind:value={description}
884
rows={4}
885
placeholder="What's this event about? @mentions, #hashtags and links will be detected automatically."
886
+
class="text-base-700 dark:text-base-300 placeholder:text-base-500 dark:placeholder:text-base-500 w-full resize-none border-0 bg-transparent leading-relaxed focus:border-0 focus:ring-0 focus:outline-none"
887
></textarea>
888
</div>
889
···
892
{/if}
893
894
<Button type="submit" disabled={submitting}>
895
+
{submitting
896
+
? isNew
897
+
? 'Creating...'
898
+
: 'Saving...'
899
+
: isNew
900
+
? 'Create Event'
901
+
: 'Save Changes'}
902
</Button>
903
</div>
904
-967
src/routes/[[actor=actor]]/events/new/+page.svelte
···
1
-
<script lang="ts">
2
-
import { user } from '$lib/atproto/auth.svelte';
3
-
import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte';
4
-
import { uploadBlob, resolveHandle } from '$lib/atproto/methods';
5
-
import { compressImage } from '$lib/atproto/image-helper';
6
-
import { Avatar as FoxAvatar, Badge, Button, ToggleGroup, ToggleGroupItem } from '@foxui/core';
7
-
import { goto } from '$app/navigation';
8
-
import { tokenize, type Token } from '@atcute/bluesky-richtext-parser';
9
-
import type { Handle } from '@atcute/lexicons';
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;
28
-
description: string;
29
-
startsAt: string;
30
-
endsAt: 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);
38
-
39
-
let name = $state('');
40
-
let description = $state('');
41
-
let startsAt = $state('');
42
-
let endsAt = $state('');
43
-
let mode: EventMode = $state('inperson');
44
-
let thumbnailFile: File | 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);
58
-
let newLinkUri = $state('');
59
-
let newLinkName = $state('');
60
-
61
-
let hasDraft = $state(false);
62
-
let draftLoaded = $state(false);
63
-
64
-
onMount(async () => {
65
-
const saved = localStorage.getItem(DRAFT_KEY);
66
-
if (saved) {
67
-
try {
68
-
const draft: EventDraft = JSON.parse(saved);
69
-
name = draft.name || '';
70
-
description = draft.description || '';
71
-
startsAt = draft.startsAt || '';
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);
79
-
if (img) {
80
-
thumbnailKey = draft.thumbnailKey;
81
-
thumbnailFile = new File([img.blob], img.name, { type: img.blob.type });
82
-
thumbnailPreview = URL.createObjectURL(img.blob);
83
-
}
84
-
}
85
-
86
-
hasDraft = true;
87
-
} catch {
88
-
localStorage.removeItem(DRAFT_KEY);
89
-
}
90
-
}
91
-
draftLoaded = true;
92
-
});
93
-
94
-
let saveDraftTimeout: ReturnType<typeof setTimeout> | undefined;
95
-
96
-
function saveDraft() {
97
-
if (!draftLoaded || !browser) return;
98
-
clearTimeout(saveDraftTimeout);
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;
107
-
} else {
108
-
localStorage.removeItem(DRAFT_KEY);
109
-
hasDraft = false;
110
-
}
111
-
}, 500);
112
-
}
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
-
128
-
function deleteDraft() {
129
-
localStorage.removeItem(DRAFT_KEY);
130
-
if (thumbnailKey) deleteImage(thumbnailKey);
131
-
name = '';
132
-
description = '';
133
-
startsAt = '';
134
-
endsAt = '';
135
-
links = [];
136
-
mode = 'inperson';
137
-
location = null;
138
-
thumbnailFile = null;
139
-
if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview);
140
-
thumbnailPreview = null;
141
-
thumbnailKey = 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;
204
-
links.push({ uri, name: newLinkName.trim() });
205
-
newLinkUri = '';
206
-
newLinkName = '';
207
-
showLinkPopup = false;
208
-
}
209
-
210
-
function removeLink(index: number) {
211
-
links.splice(index, 1);
212
-
}
213
-
214
-
let fileInput: HTMLInputElement | undefined = $state();
215
-
216
-
let hostName = $derived(user.profile?.displayName || user.profile?.handle || user.did || '');
217
-
218
-
async function setThumbnail(file: File) {
219
-
thumbnailFile = file;
220
-
if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview);
221
-
thumbnailPreview = URL.createObjectURL(file);
222
-
223
-
if (thumbnailKey) await deleteImage(thumbnailKey);
224
-
thumbnailKey = crypto.randomUUID();
225
-
await putImage(thumbnailKey, file, file.name);
226
-
saveDraft();
227
-
}
228
-
229
-
async function onFileChange(e: Event) {
230
-
const input = e.target as HTMLInputElement;
231
-
const file = input.files?.[0];
232
-
if (!file) return;
233
-
setThumbnail(file);
234
-
}
235
-
236
-
let isDragOver = $state(false);
237
-
238
-
function onDragOver(e: DragEvent) {
239
-
e.preventDefault();
240
-
isDragOver = true;
241
-
}
242
-
243
-
function onDragLeave(e: DragEvent) {
244
-
e.preventDefault();
245
-
isDragOver = false;
246
-
}
247
-
248
-
function onDrop(e: DragEvent) {
249
-
e.preventDefault();
250
-
isDragOver = false;
251
-
const file = e.dataTransfer?.files?.[0];
252
-
if (file?.type.startsWith('image/')) {
253
-
setThumbnail(file);
254
-
}
255
-
}
256
-
257
-
function removeThumbnail() {
258
-
thumbnailFile = null;
259
-
if (thumbnailPreview) {
260
-
URL.revokeObjectURL(thumbnailPreview);
261
-
thumbnailPreview = null;
262
-
}
263
-
if (thumbnailKey) {
264
-
deleteImage(thumbnailKey);
265
-
thumbnailKey = null;
266
-
}
267
-
if (fileInput) fileInput.value = '';
268
-
saveDraft();
269
-
}
270
-
271
-
function formatMonth(date: Date): string {
272
-
return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase();
273
-
}
274
-
275
-
function formatDay(date: Date): number {
276
-
return date.getDate();
277
-
}
278
-
279
-
function formatWeekday(date: Date): string {
280
-
return date.toLocaleDateString('en-US', { weekday: 'long' });
281
-
}
282
-
283
-
function formatFullDate(date: Date): string {
284
-
const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' };
285
-
if (date.getFullYear() !== new Date().getFullYear()) {
286
-
options.year = 'numeric';
287
-
}
288
-
return date.toLocaleDateString('en-US', options);
289
-
}
290
-
291
-
function formatTime(date: Date): string {
292
-
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
293
-
}
294
-
295
-
let startDate = $derived(startsAt ? new Date(startsAt) : null);
296
-
let endDate = $derived(endsAt ? new Date(endsAt) : null);
297
-
let isSameDay = $derived(
298
-
startDate &&
299
-
endDate &&
300
-
startDate.getFullYear() === endDate.getFullYear() &&
301
-
startDate.getMonth() === endDate.getMonth() &&
302
-
startDate.getDate() === endDate.getDate()
303
-
);
304
-
305
-
async function tokensToFacets(tokens: Token[]): Promise<Record<string, unknown>[]> {
306
-
const encoder = new TextEncoder();
307
-
const facets: Record<string, unknown>[] = [];
308
-
let byteOffset = 0;
309
-
310
-
for (const token of tokens) {
311
-
const tokenBytes = encoder.encode(token.raw);
312
-
const byteStart = byteOffset;
313
-
const byteEnd = byteOffset + tokenBytes.length;
314
-
315
-
if (token.type === 'mention') {
316
-
try {
317
-
const did = await resolveHandle({ handle: token.handle as Handle });
318
-
if (did) {
319
-
facets.push({
320
-
index: { byteStart, byteEnd },
321
-
features: [{ $type: 'app.bsky.richtext.facet#mention', did }]
322
-
});
323
-
}
324
-
} catch {
325
-
// skip unresolvable mentions
326
-
}
327
-
} else if (token.type === 'autolink') {
328
-
facets.push({
329
-
index: { byteStart, byteEnd },
330
-
features: [{ $type: 'app.bsky.richtext.facet#link', uri: token.url }]
331
-
});
332
-
} else if (token.type === 'topic') {
333
-
facets.push({
334
-
index: { byteStart, byteEnd },
335
-
features: [{ $type: 'app.bsky.richtext.facet#tag', tag: token.name }]
336
-
});
337
-
}
338
-
339
-
byteOffset = byteEnd;
340
-
}
341
-
342
-
return facets;
343
-
}
344
-
345
-
async function handleSubmit() {
346
-
error = null;
347
-
348
-
if (!name.trim()) {
349
-
error = 'Name is required.';
350
-
return;
351
-
}
352
-
if (!startsAt) {
353
-
error = 'Start date is required.';
354
-
return;
355
-
}
356
-
if (!user.client || !user.did) {
357
-
error = 'You must be logged in.';
358
-
return;
359
-
}
360
-
361
-
submitting = true;
362
-
363
-
try {
364
-
let media: Array<Record<string, unknown>> | undefined;
365
-
366
-
if (thumbnailFile) {
367
-
const compressed = await compressImage(thumbnailFile);
368
-
const blobRef = await uploadBlob({ blob: compressed.blob });
369
-
if (blobRef) {
370
-
media = [
371
-
{
372
-
role: 'thumbnail',
373
-
content: blobRef,
374
-
aspect_ratio: {
375
-
width: compressed.aspectRatio.width,
376
-
height: compressed.aspectRatio.height
377
-
}
378
-
}
379
-
];
380
-
}
381
-
}
382
-
383
-
const record: Record<string, unknown> = {
384
-
$type: 'community.lexicon.calendar.event',
385
-
name: name.trim(),
386
-
mode: `community.lexicon.calendar.event#${mode}`,
387
-
status: 'community.lexicon.calendar.event#scheduled',
388
-
startsAt: new Date(startsAt).toISOString(),
389
-
createdAt: new Date().toISOString()
390
-
};
391
-
392
-
const trimmedDescription = description.trim();
393
-
if (trimmedDescription) {
394
-
record.description = trimmedDescription;
395
-
const tokens = tokenize(trimmedDescription);
396
-
const facets = await tokensToFacets(tokens);
397
-
if (facets.length > 0) {
398
-
record.facets = facets;
399
-
}
400
-
}
401
-
if (endsAt) {
402
-
record.endsAt = new Date(endsAt).toISOString();
403
-
}
404
-
if (media) {
405
-
record.media = media;
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', {
420
-
input: {
421
-
collection: 'community.lexicon.calendar.event',
422
-
repo: user.did,
423
-
record
424
-
}
425
-
});
426
-
427
-
if (response.ok) {
428
-
localStorage.removeItem(DRAFT_KEY);
429
-
if (thumbnailKey) deleteImage(thumbnailKey);
430
-
const parts = response.data.uri.split('/');
431
-
const rkey = parts[parts.length - 1];
432
-
const handle =
433
-
user.profile?.handle && user.profile.handle !== 'handle.invalid'
434
-
? user.profile.handle
435
-
: user.did;
436
-
goto(`/${handle}/events/${rkey}`);
437
-
} else {
438
-
error = 'Failed to create event. Please try again.';
439
-
}
440
-
} catch (e) {
441
-
console.error('Failed to create event:', e);
442
-
error = 'Failed to create event. Please try again.';
443
-
} finally {
444
-
submitting = false;
445
-
}
446
-
}
447
-
</script>
448
-
449
-
<svelte:head>
450
-
<title>Create Event</title>
451
-
</svelte:head>
452
-
453
-
<div class="min-h-screen px-6 py-12 sm:py-12">
454
-
<div class="mx-auto max-w-3xl">
455
-
{#if user.isInitializing}
456
-
<div class="flex items-center gap-3">
457
-
<div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div>
458
-
<div class="bg-base-300 dark:bg-base-700 h-4 w-48 animate-pulse rounded"></div>
459
-
</div>
460
-
{:else if !user.isLoggedIn}
461
-
<div
462
-
class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-950/50 rounded-2xl border p-8 text-center"
463
-
>
464
-
<p class="text-base-600 dark:text-base-400 mb-4">Log in to create an event.</p>
465
-
<Button onclick={() => loginModalState.show()}>Log in</Button>
466
-
</div>
467
-
{:else}
468
-
<div class="mb-6 flex items-center gap-3">
469
-
<Badge size="sm">Local draft</Badge>
470
-
{#if hasDraft}
471
-
<button
472
-
type="button"
473
-
onclick={deleteDraft}
474
-
class="text-base-500 dark:text-base-400 cursor-pointer text-xs hover:text-red-500 hover:underline"
475
-
>
476
-
Delete draft
477
-
</button>
478
-
{/if}
479
-
</div>
480
-
481
-
<form
482
-
onsubmit={(e) => {
483
-
e.preventDefault();
484
-
handleSubmit();
485
-
}}
486
-
>
487
-
<!-- Two-column layout mirroring detail page -->
488
-
<div
489
-
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]"
490
-
>
491
-
<!-- Thumbnail (left column) -->
492
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
493
-
<div
494
-
class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none"
495
-
ondragover={onDragOver}
496
-
ondragleave={onDragLeave}
497
-
ondrop={onDrop}
498
-
>
499
-
<input
500
-
bind:this={fileInput}
501
-
type="file"
502
-
accept="image/*"
503
-
onchange={onFileChange}
504
-
class="hidden"
505
-
/>
506
-
{#if thumbnailPreview}
507
-
<div class="relative">
508
-
<button type="button" onclick={() => fileInput?.click()} class="w-full">
509
-
<img
510
-
src={thumbnailPreview}
511
-
alt="Thumbnail preview"
512
-
class="border-base-200 dark:border-base-800 aspect-square w-full cursor-pointer rounded-2xl border object-cover"
513
-
/>
514
-
</button>
515
-
<button
516
-
type="button"
517
-
onclick={removeThumbnail}
518
-
aria-label="Remove thumbnail"
519
-
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"
520
-
>
521
-
<svg
522
-
xmlns="http://www.w3.org/2000/svg"
523
-
viewBox="0 0 20 20"
524
-
fill="currentColor"
525
-
class="size-4"
526
-
>
527
-
<path
528
-
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"
529
-
/>
530
-
</svg>
531
-
</button>
532
-
</div>
533
-
{:else}
534
-
<button
535
-
type="button"
536
-
onclick={() => fileInput?.click()}
537
-
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
538
-
? 'border-accent-500 bg-accent-50 dark:bg-accent-950 text-accent-500'
539
-
: ''}"
540
-
>
541
-
<svg
542
-
xmlns="http://www.w3.org/2000/svg"
543
-
fill="none"
544
-
viewBox="0 0 24 24"
545
-
stroke-width="1.5"
546
-
stroke="currentColor"
547
-
class="mb-1 size-6"
548
-
>
549
-
<path
550
-
stroke-linecap="round"
551
-
stroke-linejoin="round"
552
-
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"
553
-
/>
554
-
</svg>
555
-
<span class="text-sm">Add image</span>
556
-
</button>
557
-
{/if}
558
-
</div>
559
-
560
-
<!-- Right column: event details -->
561
-
<div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1">
562
-
<!-- Name -->
563
-
<input
564
-
type="text"
565
-
bind:value={name}
566
-
required
567
-
placeholder="Event name"
568
-
class="text-base-900 dark:text-base-50 placeholder:text-base-500 dark:placeholder:text-base-500 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"
569
-
/>
570
-
571
-
<!-- Mode toggle -->
572
-
<div class="mb-8">
573
-
<ToggleGroup
574
-
type="single"
575
-
bind:value={
576
-
() => {
577
-
return mode;
578
-
},
579
-
(val) => {
580
-
if (val) mode = val;
581
-
}
582
-
}
583
-
class="w-fit"
584
-
>
585
-
<ToggleGroupItem size="sm" value="inperson">In Person</ToggleGroupItem>
586
-
<ToggleGroupItem size="sm" value="virtual">Virtual</ToggleGroupItem>
587
-
<ToggleGroupItem size="sm" value="hybrid">Hybrid</ToggleGroupItem>
588
-
</ToggleGroup>
589
-
</div>
590
-
591
-
<!-- Date row -->
592
-
<div class="mb-4 flex items-center gap-4">
593
-
<div
594
-
class="border-base-200 dark:border-base-700 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border"
595
-
>
596
-
{#if startDate}
597
-
<span
598
-
class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold"
599
-
>
600
-
{formatMonth(startDate)}
601
-
</span>
602
-
<span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold">
603
-
{formatDay(startDate)}
604
-
</span>
605
-
{:else}
606
-
<svg
607
-
xmlns="http://www.w3.org/2000/svg"
608
-
fill="none"
609
-
viewBox="0 0 24 24"
610
-
stroke-width="1.5"
611
-
stroke="currentColor"
612
-
class="text-base-400 dark:text-base-500 size-5"
613
-
>
614
-
<path
615
-
stroke-linecap="round"
616
-
stroke-linejoin="round"
617
-
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"
618
-
/>
619
-
</svg>
620
-
{/if}
621
-
</div>
622
-
<div class="flex-1">
623
-
{#if startDate}
624
-
<p class="text-base-900 dark:text-base-50 font-semibold">
625
-
{formatWeekday(startDate)}, {formatFullDate(startDate)}
626
-
{#if endDate && !isSameDay}
627
-
- {formatWeekday(endDate)}, {formatFullDate(endDate)}
628
-
{/if}
629
-
</p>
630
-
<p class="text-base-500 dark:text-base-400 text-sm">
631
-
{formatTime(startDate)}
632
-
{#if endDate && isSameDay}
633
-
- {formatTime(endDate)}
634
-
{/if}
635
-
</p>
636
-
{/if}
637
-
<div class="mt-1 flex flex-wrap gap-3">
638
-
<label class="flex items-center gap-1.5">
639
-
<span class="text-base-500 dark:text-base-400 text-xs">Start</span>
640
-
<input
641
-
type="datetime-local"
642
-
bind:value={startsAt}
643
-
required
644
-
class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none"
645
-
/>
646
-
</label>
647
-
<label class="flex items-center gap-1.5">
648
-
<span class="text-base-500 dark:text-base-400 text-xs">End</span>
649
-
<input
650
-
type="datetime-local"
651
-
bind:value={endsAt}
652
-
class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none"
653
-
/>
654
-
</label>
655
-
</div>
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
742
-
class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"
743
-
>
744
-
About
745
-
</p>
746
-
<textarea
747
-
bind:value={description}
748
-
rows={4}
749
-
placeholder="What's this event about? @mentions, #hashtags and links will be detected automatically."
750
-
class="text-base-700 dark:text-base-300 placeholder:text-base-500 dark:placeholder:text-base-500 w-full resize-none border-0 bg-transparent leading-relaxed focus:border-0 focus:ring-0 focus:outline-none"
751
-
></textarea>
752
-
</div>
753
-
754
-
{#if error}
755
-
<p class="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p>
756
-
{/if}
757
-
758
-
<Button type="submit" disabled={submitting}>
759
-
{submitting ? 'Creating...' : 'Create Event'}
760
-
</Button>
761
-
</div>
762
-
763
-
<!-- Hosted By -->
764
-
<div class="order-3 md:order-0 md:col-start-1">
765
-
<p
766
-
class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"
767
-
>
768
-
Hosted By
769
-
</p>
770
-
<div class="flex items-center gap-2.5">
771
-
<FoxAvatar src={user.profile?.avatar} alt={hostName} class="size-8 shrink-0" />
772
-
<span class="text-base-900 dark:text-base-100 truncate text-sm font-medium">
773
-
{hostName}
774
-
</span>
775
-
</div>
776
-
</div>
777
-
778
-
<!-- Links -->
779
-
<div class="order-4 md:order-0 md:col-start-1">
780
-
<p
781
-
class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase"
782
-
>
783
-
Links
784
-
</p>
785
-
<div class="space-y-3">
786
-
{#each links as link, i (i)}
787
-
<div class="group flex items-center gap-1.5">
788
-
<svg
789
-
xmlns="http://www.w3.org/2000/svg"
790
-
fill="none"
791
-
viewBox="0 0 24 24"
792
-
stroke-width="1.5"
793
-
stroke="currentColor"
794
-
class="text-base-700 dark:text-base-300 size-3.5 shrink-0"
795
-
>
796
-
<path
797
-
stroke-linecap="round"
798
-
stroke-linejoin="round"
799
-
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"
800
-
/>
801
-
</svg>
802
-
<span class="text-base-700 dark:text-base-300 truncate text-sm">
803
-
{link.name || link.uri.replace(/^https?:\/\//, '')}
804
-
</span>
805
-
<button
806
-
type="button"
807
-
onclick={() => removeLink(i)}
808
-
class="text-base-400 ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-500"
809
-
aria-label="Remove link"
810
-
>
811
-
<svg
812
-
xmlns="http://www.w3.org/2000/svg"
813
-
viewBox="0 0 20 20"
814
-
fill="currentColor"
815
-
class="size-3.5"
816
-
>
817
-
<path
818
-
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"
819
-
/>
820
-
</svg>
821
-
</button>
822
-
</div>
823
-
{/each}
824
-
</div>
825
-
826
-
<div class="relative mt-3">
827
-
<button
828
-
type="button"
829
-
onclick={() => (showLinkPopup = !showLinkPopup)}
830
-
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"
831
-
>
832
-
<svg
833
-
xmlns="http://www.w3.org/2000/svg"
834
-
fill="none"
835
-
viewBox="0 0 24 24"
836
-
stroke-width="1.5"
837
-
stroke="currentColor"
838
-
class="size-4"
839
-
>
840
-
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
841
-
</svg>
842
-
Add link
843
-
</button>
844
-
845
-
{#if showLinkPopup}
846
-
<div
847
-
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"
848
-
>
849
-
<input
850
-
type="url"
851
-
bind:value={newLinkUri}
852
-
placeholder="https://..."
853
-
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"
854
-
/>
855
-
<input
856
-
type="text"
857
-
bind:value={newLinkName}
858
-
placeholder="Label (optional)"
859
-
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"
860
-
/>
861
-
<div class="flex justify-end gap-2">
862
-
<button
863
-
type="button"
864
-
onclick={() => (showLinkPopup = false)}
865
-
class="text-base-500 dark:text-base-400 text-xs hover:underline"
866
-
>
867
-
Cancel
868
-
</button>
869
-
<button
870
-
type="button"
871
-
onclick={addLink}
872
-
disabled={!newLinkUri.trim()}
873
-
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"
874
-
>
875
-
Add
876
-
</button>
877
-
</div>
878
-
</div>
879
-
{/if}
880
-
</div>
881
-
</div>
882
-
</div>
883
-
</form>
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>
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0