your personal website on atproto - mirror blento.app

refactor

Florian df7c39b1 7ecddd19

+146 -1050
+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 + import * as TID from '@atcute/tid'; 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 - <Button href="./events/new" variant="primary">New event</Button> 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 + > 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 - <Button href="./edit" size="sm" class="shrink-0">Edit</Button> 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 + }).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 - throw error(404, 'Event not found'); 43 + return { 44 + eventData: null, 45 + did, 46 + rkey, 47 + hostProfile: hostProfile ?? null, 48 + eventCid: null 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 + import Avatar from 'svelte-boring-avatars'; 15 16 16 17 let { data } = $props(); 17 18 18 19 let rkey: string = $derived(data.rkey); 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 - function populateFromEventData() { 86 + function populateLocationFromEventData() { 85 87 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 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 + } 112 108 113 - // Load existing thumbnail from CDN 109 + function populateThumbnailFromEventData() { 110 + const eventData = data.eventData; 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 + 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 + 126 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 + 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 + } else if (!isNew) { 161 + // For edits without location changes, load from event data 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 - } else if (!thumbnailChanged) { 174 + } else if (!thumbnailChanged && !isNew) { 152 175 // 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 - } 176 + populateThumbnailFromEventData(); 166 177 } 167 178 168 179 hasDraft = true; 169 180 } catch { 170 181 localStorage.removeItem(DRAFT_KEY); 171 - populateFromEventData(); 182 + if (!isNew) populateFromEventData(); 172 183 } 173 - } else { 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 - populateFromEventData(); 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 + } 222 246 hasDraft = false; 223 247 } 224 248 ··· 447 471 try { 448 472 let media: Array<Record<string, unknown>> | undefined; 449 473 450 - if (thumbnailChanged) { 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 - // If thumbnailChanged but no thumbnailFile, media stays undefined (thumbnail removed) 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 - if (data.eventData.media && data.eventData.media.length > 0) { 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 - // Preserve original createdAt 476 - const originalCreatedAt = 477 - (data.eventData as Record<string, unknown>).createdAt || new Date().toISOString(); 499 + const createdAt = isNew 500 + ? new Date().toISOString() 501 + : ((data.eventData as Record<string, unknown>)?.createdAt as string) || 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 - createdAt: originalCreatedAt 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 - if (locationChanged) { 531 + if (isNew || locationChanged) { 507 532 if (location) { 508 533 record.locations = [ 509 534 { ··· 512 537 } 513 538 ]; 514 539 } 515 - // If locationChanged but no location, locations stays undefined (removed) 516 - } else if (data.eventData.locations && data.eventData.locations.length > 0) { 540 + // If changed/new but no location, locations stays undefined (removed/absent) 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 - error = 'Failed to save event. Please try again.'; 560 + error = `Failed to ${isNew ? 'create' : 'save'} event. Please try again.`; 536 561 } 537 562 } catch (e) { 538 - console.error('Failed to save event:', e); 539 - error = 'Failed to save event. Please try again.'; 563 + console.error(`Failed to ${isNew ? 'create' : 'save'} event:`, e); 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 - <title>Edit Event</title> 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 - <p class="text-base-600 dark:text-base-400 mb-4">Log in to edit this event.</p> 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> 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 - <Badge size="sm">Local edit</Badge> 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 - Discard changes 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 - {#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" 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 610 647 /> 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} 648 + </div> 649 + {/if} 650 + <!-- Upload overlay on hover --> 631 651 <button 632 652 type="button" 633 653 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' 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' 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 - class="mb-1 size-6" 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 - <span class="text-sm">Add image</span> 672 + <span class="text-sm font-medium">Upload thumbnail</span> 653 673 </button> 654 - {/if} 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> 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 - 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 + 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 - 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 + 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 - {submitting ? 'Saving...' : 'Save Changes'} 895 + {submitting 896 + ? isNew 897 + ? 'Creating...' 898 + : 'Saving...' 899 + : isNew 900 + ? 'Create Event' 901 + : 'Save Changes'} 857 902 </Button> 858 903 </div> 859 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 - / &copy; 961 - <a 962 - href="https://www.openstreetmap.org/copyright" 963 - class="hover:text-base-600 dark:hover:text-base-400 underline" 964 - target="_blank">OpenStreetMap contributors</a 965 - > 966 - </p> 967 - </Modal>