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