your personal website on atproto - mirror blento.app

Merge pull request #226 from flo-bit/events-set-date

Events set date

authored by

Florian and committed by
GitHub
51cc3f92 7ecddd19

+909 -1232
+34 -32
.claude/settings.local.json
··· 1 1 { 2 - "permissions": { 3 - "allow": [ 4 - "Bash(pnpm check:*)", 5 - "mcp__ide__getDiagnostics", 6 - "mcp__plugin_svelte_svelte__svelte-autofixer", 7 - "mcp__plugin_svelte_svelte__list-sections", 8 - "Bash(pkill:*)", 9 - "Bash(timeout 8 pnpm dev:*)", 10 - "Bash(git checkout:*)", 11 - "Bash(npx svelte-kit:*)", 12 - "Bash(ls:*)", 13 - "Bash(pnpm format:*)", 14 - "Bash(pnpm add:*)", 15 - "WebSearch", 16 - "WebFetch(domain:github.com)", 17 - "WebFetch(domain:flipclockjs.com)", 18 - "WebFetch(domain:codepen.io)", 19 - "WebFetch(domain:flo-bit.dev)", 20 - "Bash(pnpm install)", 21 - "Bash(pnpm install:*)", 22 - "Bash(pnpm config:*)", 23 - "Bash(lsof:*)", 24 - "Bash(pnpm dev)", 25 - "Bash(pnpm exec svelte-kit:*)", 26 - "Bash(pnpm build:*)", 27 - "Bash(pnpm remove:*)", 28 - "Bash(grep:*)", 29 - "Bash(find:*)", 30 - "Bash(npx prettier:*)", 31 - "Bash(node -e:*)" 32 - ] 33 - } 2 + "permissions": { 3 + "allow": [ 4 + "Bash(pnpm check:*)", 5 + "mcp__ide__getDiagnostics", 6 + "mcp__plugin_svelte_svelte__svelte-autofixer", 7 + "mcp__plugin_svelte_svelte__list-sections", 8 + "Bash(pkill:*)", 9 + "Bash(timeout 8 pnpm dev:*)", 10 + "Bash(git checkout:*)", 11 + "Bash(npx svelte-kit:*)", 12 + "Bash(ls:*)", 13 + "Bash(pnpm format:*)", 14 + "Bash(pnpm add:*)", 15 + "WebSearch", 16 + "WebFetch(domain:github.com)", 17 + "WebFetch(domain:flipclockjs.com)", 18 + "WebFetch(domain:codepen.io)", 19 + "WebFetch(domain:flo-bit.dev)", 20 + "Bash(pnpm install)", 21 + "Bash(pnpm install:*)", 22 + "Bash(pnpm config:*)", 23 + "Bash(lsof:*)", 24 + "Bash(pnpm dev)", 25 + "Bash(pnpm exec svelte-kit:*)", 26 + "Bash(pnpm build:*)", 27 + "Bash(pnpm remove:*)", 28 + "Bash(grep:*)", 29 + "Bash(find:*)", 30 + "Bash(npx prettier:*)", 31 + "Bash(node -e:*)", 32 + "mcp__plugin_svelte_svelte__get-documentation", 33 + "WebFetch(domain:bits-ui.com)" 34 + ] 35 + } 34 36 }
+1
package.json
··· 58 58 "@foxui/social": "^0.4.7", 59 59 "@foxui/time": "^0.4.7", 60 60 "@foxui/visual": "^0.4.7", 61 + "@internationalized/date": "^3.11.0", 61 62 "@number-flow/svelte": "^0.3.10", 62 63 "@tailwindcss/typography": "^0.5.19", 63 64 "@threlte/core": "^8.3.1",
+12 -9
pnpm-lock.yaml
··· 62 62 '@foxui/visual': 63 63 specifier: ^0.4.7 64 64 version: 0.4.7(svelte@5.48.0)(tailwindcss@4.1.18) 65 + '@internationalized/date': 66 + specifier: ^3.11.0 67 + version: 3.11.0 65 68 '@number-flow/svelte': 66 69 specifier: ^0.3.10 67 70 version: 0.3.10(svelte@5.48.0) ··· 124 127 version: 0.176.0 125 128 bits-ui: 126 129 specifier: ^2.15.4 127 - version: 2.15.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0) 130 + version: 2.15.4(@internationalized/date@3.11.0)(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0) 128 131 clsx: 129 132 specifier: ^2.1.1 130 133 version: 2.1.1 ··· 964 967 cpu: [x64] 965 968 os: [win32] 966 969 967 - '@internationalized/date@3.10.1': 968 - resolution: {integrity: sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==} 970 + '@internationalized/date@3.11.0': 971 + resolution: {integrity: sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==, tarball: https://registry.npmjs.org/@internationalized/date/-/date-3.11.0.tgz} 969 972 970 973 '@jridgewell/gen-mapping@0.3.13': 971 974 resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} ··· 1233 1236 vite: ^6.3.0 || ^7.0.0 1234 1237 1235 1238 '@swc/helpers@0.5.18': 1236 - resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} 1239 + resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==, tarball: https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz} 1237 1240 1238 1241 '@tailwindcss/forms@0.5.11': 1239 1242 resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==} ··· 3043 3046 typescript: '>=4.8.4' 3044 3047 3045 3048 tslib@2.8.1: 3046 - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 3049 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz} 3047 3050 3048 3051 turndown@7.2.2: 3049 3052 resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==} ··· 3811 3814 '@img/sharp-win32-x64@0.34.5': 3812 3815 optional: true 3813 3816 3814 - '@internationalized/date@3.10.1': 3817 + '@internationalized/date@3.11.0': 3815 3818 dependencies: 3816 3819 '@swc/helpers': 0.5.18 3817 3820 ··· 4587 4590 dependencies: 4588 4591 '@floating-ui/core': 1.7.3 4589 4592 '@floating-ui/dom': 1.7.5 4590 - '@internationalized/date': 3.10.1 4593 + '@internationalized/date': 3.11.0 4591 4594 css.escape: 1.5.1 4592 4595 esm-env: 1.2.2 4593 4596 runed: 0.23.4(svelte@5.48.0) ··· 4595 4598 svelte-toolbelt: 0.7.1(svelte@5.48.0) 4596 4599 tabbable: 6.4.0 4597 4600 4598 - bits-ui@2.15.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0): 4601 + bits-ui@2.15.4(@internationalized/date@3.11.0)(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0): 4599 4602 dependencies: 4600 4603 '@floating-ui/core': 1.7.3 4601 4604 '@floating-ui/dom': 1.7.5 4602 - '@internationalized/date': 3.10.1 4605 + '@internationalized/date': 3.11.0 4603 4606 esm-env: 1.2.2 4604 4607 runed: 0.35.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0) 4605 4608 svelte: 5.48.0
+244
src/lib/components/DatePicker.svelte
··· 1 + <script lang="ts"> 2 + // @ts-nocheck 3 + import { DatePicker } from 'bits-ui'; 4 + import { CalendarDate, type DateValue } from '@internationalized/date'; 5 + import { untrack } from 'svelte'; 6 + 7 + let { 8 + value = $bindable(''), 9 + required = false, 10 + minValue = '', 11 + locale = 'en', 12 + onSelect 13 + }: { 14 + value: string; 15 + required?: boolean; 16 + minValue?: string; 17 + locale?: string; 18 + onSelect?: () => void; 19 + } = $props(); 20 + 21 + let isOpen = $state(false); 22 + 23 + const currentYear = new Date().getFullYear(); 24 + const yearRange = Array.from({ length: 7 }, (_, i) => currentYear - 1 + i); 25 + const today = new Date(); 26 + const todayDay = today.getDate(); 27 + const todayMonth = today.getMonth() + 1; 28 + const todayYear = today.getFullYear(); 29 + 30 + let internalValue: CalendarDate | undefined = $state(undefined); 31 + 32 + function parseDateStr(str: string): CalendarDate | undefined { 33 + if (!str) return undefined; 34 + const [yearStr, monthStr, dayStr] = str.split('-'); 35 + const year = parseInt(yearStr, 10); 36 + const month = parseInt(monthStr, 10); 37 + const day = parseInt(dayStr, 10); 38 + if (isNaN(year) || isNaN(month) || isNaN(day)) return undefined; 39 + return new CalendarDate(year, month, day); 40 + } 41 + 42 + function formatDateStr(dt: CalendarDate): string { 43 + const y = String(dt.year).padStart(4, '0'); 44 + const m = String(dt.month).padStart(2, '0'); 45 + const d = String(dt.day).padStart(2, '0'); 46 + return `${y}-${m}-${d}`; 47 + } 48 + 49 + let internalMinValue: CalendarDate | undefined = $derived.by(() => { 50 + return parseDateStr(minValue); 51 + }); 52 + 53 + $effect(() => { 54 + const parsed = parseDateStr(value); 55 + untrack(() => { 56 + if (parsed) { 57 + if ( 58 + !internalValue || 59 + parsed.year !== internalValue.year || 60 + parsed.month !== internalValue.month || 61 + parsed.day !== internalValue.day 62 + ) { 63 + internalValue = parsed; 64 + } 65 + } else { 66 + internalValue = undefined; 67 + } 68 + }); 69 + }); 70 + 71 + function handleValueChange(newVal: DateValue | undefined) { 72 + if (newVal && newVal instanceof CalendarDate) { 73 + internalValue = newVal; 74 + value = formatDateStr(newVal); 75 + } 76 + } 77 + 78 + function handleOpenChange(open: boolean) { 79 + isOpen = open; 80 + } 81 + 82 + function handleOpenChangeComplete(open: boolean) { 83 + if (!open && internalValue) { 84 + onSelect?.(); 85 + } 86 + } 87 + </script> 88 + 89 + <DatePicker.Root 90 + bind:value={internalValue} 91 + onValueChange={handleValueChange} 92 + onOpenChange={handleOpenChange} 93 + onOpenChangeComplete={handleOpenChangeComplete} 94 + minValue={internalMinValue} 95 + granularity="day" 96 + fixedWeeks={true} 97 + weekdayFormat="short" 98 + {locale} 99 + {required} 100 + > 101 + <div 102 + class="border-base-300 bg-base-100 text-base-900 focus-within:border-accent-500 dark:border-base-700 dark:bg-base-800 dark:text-base-100 dark:focus-within:border-accent-400 flex items-center rounded-xl border px-2.5 py-1.5 text-sm transition-colors" 103 + > 104 + <DatePicker.Input> 105 + {#snippet children({ segments })} 106 + {#each segments as segment, i (segment.part + i)} 107 + {#if segment.part === 'literal'} 108 + <span class="text-base-400 dark:text-base-500">{segment.value}</span> 109 + {:else} 110 + <DatePicker.Segment 111 + part={segment.part} 112 + class="hover:bg-base-200 focus:bg-base-200 dark:hover:bg-base-700 dark:focus:bg-base-700 rounded px-0.5 focus:outline-none" 113 + > 114 + {segment.value} 115 + </DatePicker.Segment> 116 + {/if} 117 + {/each} 118 + {/snippet} 119 + </DatePicker.Input> 120 + 121 + <DatePicker.Trigger 122 + class="text-base-400 hover:text-base-600 dark:text-base-500 dark:hover:text-base-300 ml-auto cursor-pointer pl-1.5" 123 + > 124 + <svg 125 + xmlns="http://www.w3.org/2000/svg" 126 + fill="none" 127 + viewBox="0 0 24 24" 128 + stroke-width="1.5" 129 + stroke="currentColor" 130 + class="size-4" 131 + > 132 + <path 133 + stroke-linecap="round" 134 + stroke-linejoin="round" 135 + 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" 136 + /> 137 + </svg> 138 + </DatePicker.Trigger> 139 + </div> 140 + 141 + <DatePicker.Content 142 + class="border-base-200 bg-base-50 dark:border-base-700 dark:bg-base-900 z-50 rounded-2xl border p-4 shadow-lg" 143 + > 144 + <DatePicker.Calendar> 145 + {#snippet children({ months, weekdays })} 146 + <DatePicker.Header class="flex items-center justify-between"> 147 + <DatePicker.PrevButton 148 + class="text-base-500 hover:bg-base-200 dark:text-base-400 dark:hover:bg-base-700 inline-flex size-8 items-center justify-center rounded-lg" 149 + > 150 + <svg 151 + xmlns="http://www.w3.org/2000/svg" 152 + viewBox="0 0 20 20" 153 + fill="currentColor" 154 + class="size-5" 155 + > 156 + <path 157 + fill-rule="evenodd" 158 + d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" 159 + clip-rule="evenodd" 160 + /> 161 + </svg> 162 + </DatePicker.PrevButton> 163 + 164 + <div class="flex items-center gap-1.5"> 165 + <DatePicker.MonthSelect 166 + monthFormat="long" 167 + class="text-base-900 dark:text-base-100 hover:text-accent-500 dark:hover:text-accent-400 cursor-pointer border-0 bg-transparent text-sm font-medium outline-none focus:ring-0 focus:outline-none" 168 + /> 169 + <DatePicker.YearSelect 170 + years={yearRange} 171 + class="text-base-900 dark:text-base-100 hover:text-accent-500 dark:hover:text-accent-400 cursor-pointer border-0 bg-transparent text-sm font-medium outline-none focus:ring-0 focus:outline-none" 172 + /> 173 + </div> 174 + 175 + <DatePicker.NextButton 176 + class="text-base-500 hover:bg-base-200 dark:text-base-400 dark:hover:bg-base-700 inline-flex size-8 items-center justify-center rounded-lg" 177 + > 178 + <svg 179 + xmlns="http://www.w3.org/2000/svg" 180 + viewBox="0 0 20 20" 181 + fill="currentColor" 182 + class="size-5" 183 + > 184 + <path 185 + fill-rule="evenodd" 186 + d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" 187 + clip-rule="evenodd" 188 + /> 189 + </svg> 190 + </DatePicker.NextButton> 191 + </DatePicker.Header> 192 + 193 + {#each months as month (month.value.month)} 194 + <DatePicker.Grid class="mt-3 w-full"> 195 + <DatePicker.GridHead> 196 + <DatePicker.GridRow class="flex w-full"> 197 + {#each weekdays as weekday, i (i)} 198 + <DatePicker.HeadCell 199 + class="text-base-400 dark:text-base-500 flex-1 text-center text-xs font-medium" 200 + > 201 + {weekday} 202 + </DatePicker.HeadCell> 203 + {/each} 204 + </DatePicker.GridRow> 205 + </DatePicker.GridHead> 206 + 207 + <DatePicker.GridBody> 208 + {#each month.weeks as week, weekIndex (weekIndex)} 209 + <DatePicker.GridRow class="flex w-full"> 210 + {#each week as day (day.toString())} 211 + <DatePicker.Cell date={day} month={month.value} class="flex-1 p-0.5"> 212 + <DatePicker.Day> 213 + {#snippet children({ selected, disabled, day: dayText })} 214 + <div 215 + class="relative flex size-9 items-center justify-center rounded-lg text-sm 216 + {selected 217 + ? 'bg-accent-500 font-medium text-white' 218 + : disabled 219 + ? 'text-base-300 dark:text-base-600 pointer-events-none' 220 + : day.month !== month.value.month 221 + ? 'text-base-300 dark:text-base-600' 222 + : 'text-base-700 hover:bg-base-200 dark:text-base-300 dark:hover:bg-base-700'}" 223 + > 224 + {dayText} 225 + {#if day.day === todayDay && day.month === todayMonth && day.year === todayYear} 226 + <span 227 + class="bg-accent-500 absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full" 228 + class:bg-white={selected} 229 + ></span> 230 + {/if} 231 + </div> 232 + {/snippet} 233 + </DatePicker.Day> 234 + </DatePicker.Cell> 235 + {/each} 236 + </DatePicker.GridRow> 237 + {/each} 238 + </DatePicker.GridBody> 239 + </DatePicker.Grid> 240 + {/each} 241 + {/snippet} 242 + </DatePicker.Calendar> 243 + </DatePicker.Content> 244 + </DatePicker.Root>
+73
src/lib/components/DateTimePicker.svelte
··· 1 + <script lang="ts"> 2 + // @ts-nocheck 3 + import DatePickerField from './DatePicker.svelte'; 4 + import TimePicker from './TimePicker.svelte'; 5 + import { untrack } from 'svelte'; 6 + import { browser } from '$app/environment'; 7 + 8 + let { 9 + value = $bindable(''), 10 + required = false, 11 + minValue = '' 12 + }: { 13 + value: string; 14 + required?: boolean; 15 + minValue?: string; 16 + } = $props(); 17 + 18 + let datePart = $state(''); 19 + let timePart = $state('00:00'); 20 + let timeEl: HTMLDivElement | undefined = $state(undefined); 21 + 22 + const locale = browser ? navigator.language || 'en' : 'en'; 23 + let minDatePart = $derived(minValue ? minValue.split('T')[0] || '' : ''); 24 + 25 + // Sync external value -> date/time parts 26 + $effect(() => { 27 + const v = value; 28 + untrack(() => { 29 + if (v) { 30 + const [d, t] = v.split('T'); 31 + if (d && d !== datePart) datePart = d; 32 + if (t && t !== timePart) timePart = t; 33 + } 34 + }); 35 + }); 36 + 37 + // Sync date/time parts -> external value 38 + $effect(() => { 39 + const d = datePart; 40 + const t = timePart; 41 + untrack(() => { 42 + if (d) { 43 + const newVal = `${d}T${t || '00:00'}`; 44 + if (newVal !== value) value = newVal; 45 + } 46 + }); 47 + }); 48 + 49 + function focusTime() { 50 + // Small delay to let the popover finish closing 51 + setTimeout(() => { 52 + if (timeEl) { 53 + const segment = timeEl.querySelector('[data-segment]'); 54 + if (segment instanceof HTMLElement) { 55 + segment.focus(); 56 + } 57 + } 58 + }, 50); 59 + } 60 + </script> 61 + 62 + <div class="flex items-center gap-1.5"> 63 + <DatePickerField 64 + bind:value={datePart} 65 + {required} 66 + minValue={minDatePart} 67 + {locale} 68 + onSelect={focusTime} 69 + /> 70 + <div bind:this={timeEl}> 71 + <TimePicker bind:value={timePart} {locale} /> 72 + </div> 73 + </div>
+101
src/lib/components/TimePicker.svelte
··· 1 + <script lang="ts"> 2 + // @ts-nocheck 3 + import { TimeField } from 'bits-ui'; 4 + import { Time } from '@internationalized/date'; 5 + import { untrack } from 'svelte'; 6 + 7 + let { 8 + value = $bindable(''), 9 + required = false, 10 + locale = 'en' 11 + }: { 12 + value: string; 13 + required?: boolean; 14 + locale?: string; 15 + } = $props(); 16 + 17 + let internalValue: Time | undefined = $state(undefined); 18 + 19 + function parseTimeStr(str: string): Time | undefined { 20 + if (!str) return undefined; 21 + const [hourStr, minuteStr] = str.split(':'); 22 + const hour = parseInt(hourStr, 10); 23 + const minute = parseInt(minuteStr, 10); 24 + if (isNaN(hour) || isNaN(minute)) return undefined; 25 + return new Time(hour, minute); 26 + } 27 + 28 + function formatTimeStr(t: Time): string { 29 + const h = String(t.hour).padStart(2, '0'); 30 + const m = String(t.minute).padStart(2, '0'); 31 + return `${h}:${m}`; 32 + } 33 + 34 + $effect(() => { 35 + const parsed = parseTimeStr(value); 36 + untrack(() => { 37 + if (parsed) { 38 + if ( 39 + !internalValue || 40 + parsed.hour !== internalValue.hour || 41 + parsed.minute !== internalValue.minute 42 + ) { 43 + internalValue = parsed; 44 + } 45 + } else { 46 + internalValue = undefined; 47 + } 48 + }); 49 + }); 50 + 51 + function handleValueChange(newVal: Time | undefined) { 52 + if (newVal && newVal instanceof Time) { 53 + internalValue = newVal; 54 + value = formatTimeStr(newVal); 55 + } 56 + } 57 + </script> 58 + 59 + <TimeField.Root 60 + bind:value={internalValue} 61 + onValueChange={handleValueChange} 62 + granularity="minute" 63 + {locale} 64 + {required} 65 + > 66 + <div 67 + class="border-base-300 bg-base-100 text-base-900 focus-within:border-accent-500 dark:border-base-700 dark:bg-base-800 dark:text-base-100 dark:focus-within:border-accent-400 flex items-center rounded-xl border px-2.5 py-1.5 text-sm transition-colors" 68 + > 69 + <TimeField.Input> 70 + {#snippet children({ segments })} 71 + {#each segments as segment, i (segment.part + i)} 72 + {#if segment.part === 'literal'} 73 + <span class="text-base-400 dark:text-base-500">{segment.value}</span> 74 + {:else} 75 + <TimeField.Segment 76 + part={segment.part} 77 + class="hover:bg-base-200 focus:bg-base-200 dark:hover:bg-base-700 dark:focus:bg-base-700 rounded px-0.5 focus:outline-none" 78 + > 79 + {segment.value} 80 + </TimeField.Segment> 81 + {/if} 82 + {/each} 83 + {/snippet} 84 + </TimeField.Input> 85 + 86 + <svg 87 + xmlns="http://www.w3.org/2000/svg" 88 + fill="none" 89 + viewBox="0 0 24 24" 90 + stroke-width="1.5" 91 + stroke="currentColor" 92 + class="text-base-400 dark:text-base-500 ml-auto size-4 pl-0.5" 93 + > 94 + <path 95 + stroke-linecap="round" 96 + stroke-linejoin="round" 97 + d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 98 + /> 99 + </svg> 100 + </div> 101 + </TimeField.Root>
+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;
+422 -220
src/routes/[[actor=actor]]/events/[rkey]/edit/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { user } from '$lib/atproto/auth.svelte'; 3 3 import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 4 - import { uploadBlob, putRecord, resolveHandle } from '$lib/atproto/methods'; 4 + import { uploadBlob, putRecord, deleteRecord, resolveHandle } from '$lib/atproto/methods'; 5 5 import { getCDNImageBlobUrl } from '$lib/atproto'; 6 6 import { compressImage } from '$lib/atproto/image-helper'; 7 - import { Avatar as FoxAvatar, Badge, Button, ToggleGroup, ToggleGroupItem } from '@foxui/core'; 7 + import { validateLink } from '$lib/helper'; 8 + import { 9 + Avatar as FoxAvatar, 10 + Button, 11 + PopoverRoot, 12 + PopoverTrigger, 13 + PopoverContent, 14 + ToggleGroup, 15 + ToggleGroupItem, 16 + Input 17 + } from '@foxui/core'; 8 18 import { goto } from '$app/navigation'; 9 19 import { tokenize, type Token } from '@atcute/bluesky-richtext-parser'; 10 20 import type { Handle } from '@atcute/lexicons'; ··· 12 22 import { browser } from '$app/environment'; 13 23 import { putImage, getImage, deleteImage } from '$lib/components/image-store'; 14 24 import Modal from '$lib/components/modal/Modal.svelte'; 25 + import Avatar from 'svelte-boring-avatars'; 26 + import DateTimePicker from '$lib/components/DateTimePicker.svelte'; 15 27 16 28 let { data } = $props(); 17 29 18 30 let rkey: string = $derived(data.rkey); 31 + let isNew = $derived(data.eventData === null); 19 32 let DRAFT_KEY = $derived(`blento-event-edit-${rkey}`); 20 33 21 34 type EventMode = 'inperson' | 'virtual' | 'hybrid'; ··· 52 65 let thumbnailPreview: string | null = $state(null); 53 66 let submitting = $state(false); 54 67 let error: string | null = $state(null); 68 + let titleEl: HTMLTextAreaElement | undefined = $state(undefined); 55 69 56 70 let location: EventLocation | null = $state(null); 57 71 let locationChanged = $state(false); ··· 62 76 let locationResult: { displayName: string; location: EventLocation } | null = $state(null); 63 77 64 78 let links: Array<{ uri: string; name: string }> = $state([]); 79 + let editingDates = $state(false); 65 80 let showLinkPopup = $state(false); 66 81 let newLinkUri = $state(''); 67 82 let newLinkName = $state(''); 83 + let linkError = $state(''); 68 84 69 - let hasDraft = $state(false); 70 85 let draftLoaded = $state(false); 71 86 72 87 function isoToDatetimeLocal(iso: string): string { ··· 81 96 return 'inperson'; 82 97 } 83 98 84 - function populateFromEventData() { 99 + function populateLocationFromEventData() { 85 100 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 101 + if (!eventData) return; 94 102 if (eventData.locations && eventData.locations.length > 0) { 95 103 const loc = eventData.locations.find((v) => v.$type === 'community.lexicon.location.address'); 96 104 if (loc) { ··· 109 117 } 110 118 } 111 119 locationChanged = false; 120 + } 112 121 113 - // Load existing thumbnail from CDN 122 + function populateThumbnailFromEventData() { 123 + const eventData = data.eventData; 124 + if (!eventData) return; 114 125 if (eventData.media && eventData.media.length > 0) { 115 126 const media = eventData.media.find((m) => m.role === 'thumbnail'); 116 127 if (media?.content) { ··· 123 134 } 124 135 } 125 136 137 + function populateFromEventData() { 138 + const eventData = data.eventData; 139 + if (!eventData) return; 140 + name = eventData.name || ''; 141 + description = eventData.description || ''; 142 + startsAt = eventData.startsAt ? isoToDatetimeLocal(eventData.startsAt) : ''; 143 + endsAt = eventData.endsAt ? isoToDatetimeLocal(eventData.endsAt) : ''; 144 + mode = eventData.mode ? stripModePrefix(eventData.mode) : 'inperson'; 145 + links = eventData.uris ? eventData.uris.map((l) => ({ uri: l.uri, name: l.name || '' })) : []; 146 + populateLocationFromEventData(); 147 + populateThumbnailFromEventData(); 148 + } 149 + 126 150 onMount(async () => { 151 + // Migrate old creation draft if this is a new event 152 + if (isNew) { 153 + const oldDraft = localStorage.getItem('blento-event-draft'); 154 + if (oldDraft && !localStorage.getItem(DRAFT_KEY)) { 155 + localStorage.setItem(DRAFT_KEY, oldDraft); 156 + localStorage.removeItem('blento-event-draft'); 157 + } 158 + } 159 + 127 160 const saved = localStorage.getItem(DRAFT_KEY); 128 161 if (saved) { 129 162 try { ··· 137 170 locationChanged = draft.locationChanged || false; 138 171 if (draft.locationChanged) { 139 172 location = draft.location || null; 173 + } else if (!isNew) { 174 + // For edits without location changes, load from event data 175 + populateLocationFromEventData(); 140 176 } 141 177 thumbnailChanged = draft.thumbnailChanged || false; 142 178 ··· 148 184 thumbnailPreview = URL.createObjectURL(img.blob); 149 185 thumbnailChanged = true; 150 186 } 151 - } else if (!thumbnailChanged) { 187 + } else if (!thumbnailChanged && !isNew) { 152 188 // 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 - } 189 + populateThumbnailFromEventData(); 166 190 } 167 - 168 - hasDraft = true; 169 191 } catch { 170 192 localStorage.removeItem(DRAFT_KEY); 171 - populateFromEventData(); 193 + if (!isNew) populateFromEventData(); 172 194 } 173 - } else { 195 + } else if (!isNew) { 174 196 populateFromEventData(); 175 197 } 176 198 draftLoaded = true; 199 + if (!startsAt) editingDates = true; 200 + titleEl?.focus(); 177 201 }); 178 202 179 203 let saveDraftTimeout: ReturnType<typeof setTimeout> | undefined; ··· 195 219 if (locationChanged) draft.location = location; 196 220 if (thumbnailKey) draft.thumbnailKey = thumbnailKey; 197 221 localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); 198 - hasDraft = true; 199 222 }, 500); 200 223 } 201 224 ··· 212 235 ]; 213 236 saveDraft(); 214 237 }); 215 - 216 - function deleteDraft() { 217 - localStorage.removeItem(DRAFT_KEY); 218 - if (thumbnailKey) deleteImage(thumbnailKey); 219 - thumbnailKey = null; 220 - thumbnailChanged = false; 221 - populateFromEventData(); 222 - hasDraft = false; 223 - } 224 238 225 239 async function searchLocation() { 226 240 const q = locationSearch.trim(); ··· 281 295 } 282 296 283 297 function addLink() { 284 - const uri = newLinkUri.trim(); 285 - if (!uri) return; 298 + const raw = newLinkUri.trim(); 299 + if (!raw) return; 300 + const uri = validateLink(raw); 301 + if (!uri) { 302 + linkError = 'Please enter a valid URL'; 303 + return; 304 + } 286 305 links.push({ uri, name: newLinkName.trim() }); 287 306 newLinkUri = ''; 288 307 newLinkName = ''; 308 + linkError = ''; 289 309 showLinkPopup = false; 290 310 } 291 311 ··· 386 406 startDate.getDate() === endDate.getDate() 387 407 ); 388 408 409 + // Auto-adjust end date if start moves past it 410 + $effect(() => { 411 + if (startsAt && endsAt) { 412 + const s = new Date(startsAt); 413 + const e = new Date(endsAt); 414 + if (s >= e) { 415 + const adjusted = new Date(s); 416 + adjusted.setHours(adjusted.getHours() + 1); 417 + endsAt = isoToDatetimeLocal(adjusted.toISOString()); 418 + } 419 + } 420 + }); 421 + 389 422 async function tokensToFacets(tokens: Token[]): Promise<Record<string, unknown>[]> { 390 423 const encoder = new TextEncoder(); 391 424 const facets: Record<string, unknown>[] = []; ··· 447 480 try { 448 481 let media: Array<Record<string, unknown>> | undefined; 449 482 450 - if (thumbnailChanged) { 483 + if (isNew || thumbnailChanged) { 451 484 if (thumbnailFile) { 452 485 const compressed = await compressImage(thumbnailFile); 453 486 const blobRef = await uploadBlob({ blob: compressed.blob }); ··· 464 497 ]; 465 498 } 466 499 } 467 - // If thumbnailChanged but no thumbnailFile, media stays undefined (thumbnail removed) 500 + // If changed/new but no thumbnailFile, media stays undefined (thumbnail removed/absent) 468 501 } else { 469 502 // Thumbnail not changed — reuse original media from eventData 470 - if (data.eventData.media && data.eventData.media.length > 0) { 503 + if (data.eventData?.media && data.eventData.media.length > 0) { 471 504 media = data.eventData.media as Array<Record<string, unknown>>; 472 505 } 473 506 } 474 507 475 - // Preserve original createdAt 476 - const originalCreatedAt = 477 - (data.eventData as Record<string, unknown>).createdAt || new Date().toISOString(); 508 + const createdAt = isNew 509 + ? new Date().toISOString() 510 + : ((data.eventData as Record<string, unknown>)?.createdAt as string) || 511 + new Date().toISOString(); 478 512 479 513 const record: Record<string, unknown> = { 480 514 $type: 'community.lexicon.calendar.event', ··· 482 516 mode: `community.lexicon.calendar.event#${mode}`, 483 517 status: 'community.lexicon.calendar.event#scheduled', 484 518 startsAt: new Date(startsAt).toISOString(), 485 - createdAt: originalCreatedAt 519 + createdAt 486 520 }; 487 521 488 522 const trimmedDescription = description.trim(); ··· 503 537 if (links.length > 0) { 504 538 record.uris = links; 505 539 } 506 - if (locationChanged) { 540 + if (isNew || locationChanged) { 507 541 if (location) { 508 542 record.locations = [ 509 543 { ··· 512 546 } 513 547 ]; 514 548 } 515 - // If locationChanged but no location, locations stays undefined (removed) 516 - } else if (data.eventData.locations && data.eventData.locations.length > 0) { 549 + // If changed/new but no location, locations stays undefined (removed/absent) 550 + } else if (data.eventData?.locations && data.eventData.locations.length > 0) { 517 551 record.locations = data.eventData.locations; 518 552 } 519 553 ··· 532 566 : user.did; 533 567 goto(`/${handle}/events/${rkey}`); 534 568 } else { 535 - error = 'Failed to save event. Please try again.'; 569 + error = `Failed to ${isNew ? 'create' : 'save'} event. Please try again.`; 536 570 } 537 571 } catch (e) { 538 - console.error('Failed to save event:', e); 539 - error = 'Failed to save event. Please try again.'; 572 + console.error(`Failed to ${isNew ? 'create' : 'save'} event:`, e); 573 + error = `Failed to ${isNew ? 'create' : 'save'} event. Please try again.`; 540 574 } finally { 541 575 submitting = false; 542 576 } 543 577 } 578 + 579 + let showDeleteConfirm = $state(false); 580 + let deleting = $state(false); 581 + 582 + async function handleDelete() { 583 + deleting = true; 584 + try { 585 + await deleteRecord({ 586 + collection: 'community.lexicon.calendar.event', 587 + rkey 588 + }); 589 + localStorage.removeItem(DRAFT_KEY); 590 + if (thumbnailKey) deleteImage(thumbnailKey); 591 + const handle = 592 + user.profile?.handle && user.profile.handle !== 'handle.invalid' 593 + ? user.profile.handle 594 + : user.did; 595 + goto(`/${handle}/events`); 596 + } catch (e) { 597 + console.error('Failed to delete event:', e); 598 + error = 'Failed to delete event. Please try again.'; 599 + } finally { 600 + deleting = false; 601 + showDeleteConfirm = false; 602 + } 603 + } 544 604 </script> 545 605 546 606 <svelte:head> 547 - <title>Edit Event</title> 607 + <title>{isNew ? 'Create Event' : 'Edit Event'}</title> 548 608 </svelte:head> 549 609 550 610 <div class="min-h-screen px-6 py-12 sm:py-12"> ··· 558 618 <div 559 619 class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 rounded-2xl border p-8 text-center" 560 620 > 561 - <p class="text-base-600 dark:text-base-400 mb-4">Log in to edit this event.</p> 621 + <p class="text-base-600 dark:text-base-400 mb-4"> 622 + Log in to {isNew ? 'create an event' : 'edit this event'}. 623 + </p> 562 624 <Button onclick={() => loginModalState.show()}>Log in</Button> 563 625 </div> 564 626 {:else} 565 - <div class="mb-6 flex items-center gap-3"> 566 - <Badge size="sm">Local edit</Badge> 567 - {#if hasDraft} 568 - <button 569 - type="button" 570 - onclick={deleteDraft} 571 - class="text-base-500 dark:text-base-400 cursor-pointer text-xs hover:text-red-500 hover:underline" 572 - > 573 - Discard changes 574 - </button> 575 - {/if} 576 - </div> 577 - 578 627 <form 579 628 onsubmit={(e) => { 580 629 e.preventDefault(); ··· 600 649 onchange={onFileChange} 601 650 class="hidden" 602 651 /> 603 - {#if thumbnailPreview} 604 - <div class="relative"> 605 - <button type="button" onclick={() => fileInput?.click()} class="w-full"> 606 - <img 607 - src={thumbnailPreview} 608 - alt="Thumbnail preview" 609 - class="border-base-200 dark:border-base-800 aspect-square w-full cursor-pointer rounded-2xl border object-cover" 610 - /> 611 - </button> 612 - <button 613 - type="button" 614 - onclick={removeThumbnail} 615 - aria-label="Remove thumbnail" 616 - class="bg-base-900/70 absolute top-2 right-2 flex size-7 items-center justify-center rounded-full text-white hover:bg-red-600" 652 + <div class="group relative"> 653 + {#if thumbnailPreview} 654 + <img 655 + src={thumbnailPreview} 656 + alt="Thumbnail preview" 657 + class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 658 + /> 659 + {:else} 660 + <div 661 + class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full" 617 662 > 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} 663 + <Avatar 664 + size={400} 665 + name={rkey} 666 + variant="marble" 667 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 668 + square 669 + /> 670 + </div> 671 + {/if} 672 + <!-- Upload overlay on hover --> 631 673 <button 632 674 type="button" 633 675 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' 676 + 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 677 + ? 'bg-black/40 text-white/90' 636 678 : ''}" 637 679 > 638 680 <svg ··· 641 683 viewBox="0 0 24 24" 642 684 stroke-width="1.5" 643 685 stroke="currentColor" 644 - class="mb-1 size-6" 686 + class="size-6" 645 687 > 646 688 <path 647 689 stroke-linecap="round" ··· 649 691 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 692 /> 651 693 </svg> 652 - <span class="text-sm">Add image</span> 694 + <span class="text-sm font-medium">Upload thumbnail</span> 653 695 </button> 654 - {/if} 696 + {#if thumbnailPreview} 697 + <Button 698 + variant="ghost" 699 + size="iconSm" 700 + onclick={removeThumbnail} 701 + class="bg-base-900/70 absolute top-2 right-2 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600" 702 + > 703 + <svg 704 + xmlns="http://www.w3.org/2000/svg" 705 + viewBox="0 0 20 20" 706 + fill="currentColor" 707 + class="size-3.5" 708 + > 709 + <path 710 + 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" 711 + /> 712 + </svg> 713 + </Button> 714 + {/if} 715 + </div> 655 716 </div> 656 717 657 718 <!-- Right column: event details --> 658 719 <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> 659 - <!-- Name --> 660 - <input 661 - type="text" 662 - bind:value={name} 663 - required 664 - placeholder="Event name" 665 - class="text-base-900 dark:text-base-50 placeholder:text-base-300 dark:placeholder:text-base-700 mb-2 w-full border-0 bg-transparent text-4xl leading-tight font-bold focus:border-0 focus:ring-0 focus:outline-none sm:text-5xl" 666 - /> 720 + <!-- Name + Save button --> 721 + <div class="mb-2 flex items-start justify-between gap-4"> 722 + <textarea 723 + bind:this={titleEl} 724 + bind:value={name} 725 + required 726 + placeholder="Event name" 727 + rows={1} 728 + class="text-base-900 dark:text-base-50 placeholder:text-base-500 dark:placeholder:text-base-500 w-full min-w-0 resize-none border-0 bg-transparent px-0 text-4xl leading-tight font-bold focus:border-0 focus:ring-0 focus:outline-none sm:text-5xl" 729 + style="field-sizing: content;" 730 + ></textarea> 731 + <Button 732 + type="submit" 733 + size="sm" 734 + class="shrink-0" 735 + disabled={submitting || !name.trim() || !startsAt} 736 + > 737 + {submitting ? (isNew ? 'Creating...' : 'Saving...') : isNew ? 'Create' : 'Save'} 738 + </Button> 739 + </div> 667 740 668 741 <!-- Mode toggle --> 669 742 <div class="mb-8"> ··· 686 759 </div> 687 760 688 761 <!-- Date row --> 689 - <div class="mb-4 flex items-center gap-4"> 762 + <div class="mb-4 flex items-start gap-4"> 690 763 <div 691 - class="border-base-200 dark:border-base-700 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 764 + class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 692 765 > 693 766 {#if startDate} 694 767 <span ··· 706 779 viewBox="0 0 24 24" 707 780 stroke-width="1.5" 708 781 stroke="currentColor" 709 - class="text-base-400 dark:text-base-500 size-5" 782 + class="text-base-900 dark:text-base-200 size-5" 710 783 > 711 784 <path 712 785 stroke-linecap="round" ··· 717 790 {/if} 718 791 </div> 719 792 <div class="flex-1"> 720 - {#if startDate} 721 - <p class="text-base-900 dark:text-base-50 font-semibold"> 722 - {formatWeekday(startDate)}, {formatFullDate(startDate)} 723 - {#if endDate && !isSameDay} 724 - - {formatWeekday(endDate)}, {formatFullDate(endDate)} 793 + {#if startDate && !editingDates} 794 + <!-- Display mode: show formatted date, click to edit --> 795 + <div class="flex items-start gap-2"> 796 + <button 797 + type="button" 798 + onclick={() => (editingDates = true)} 799 + class="cursor-pointer text-left" 800 + > 801 + <p class="text-base-900 dark:text-base-50 font-semibold"> 802 + {formatWeekday(startDate)}, {formatFullDate(startDate)} 803 + {#if endDate && !isSameDay} 804 + - {formatWeekday(endDate)}, {formatFullDate(endDate)} 805 + {/if} 806 + </p> 807 + <p class="text-base-500 dark:text-base-400 text-sm"> 808 + {formatTime(startDate)} 809 + {#if endDate && isSameDay} 810 + - {formatTime(endDate)} 811 + {/if} 812 + </p> 813 + </button> 814 + <Button variant="ghost" size="iconSm" onclick={() => (editingDates = true)}> 815 + <svg 816 + xmlns="http://www.w3.org/2000/svg" 817 + fill="none" 818 + viewBox="0 0 24 24" 819 + stroke-width="1.5" 820 + stroke="currentColor" 821 + class="size-3.5" 822 + > 823 + <path 824 + stroke-linecap="round" 825 + stroke-linejoin="round" 826 + d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" 827 + /> 828 + </svg> 829 + </Button> 830 + </div> 831 + {:else} 832 + <!-- Edit mode: show pickers --> 833 + <div class="flex flex-col gap-2"> 834 + <div class="flex items-center gap-2"> 835 + {#if endsAt} 836 + <span class="text-base-500 dark:text-base-400 w-9 text-xs">Start</span> 837 + {/if} 838 + <DateTimePicker bind:value={startsAt} required /> 839 + </div> 840 + {#if endsAt} 841 + <div class="flex items-center gap-2"> 842 + <span class="text-base-500 dark:text-base-400 w-9 text-xs">End</span> 843 + <DateTimePicker bind:value={endsAt} minValue={startsAt} /> 844 + <Button variant="ghost" size="iconSm" onclick={() => (endsAt = '')}> 845 + <svg 846 + xmlns="http://www.w3.org/2000/svg" 847 + fill="none" 848 + viewBox="0 0 24 24" 849 + stroke-width="1.5" 850 + stroke="currentColor" 851 + class="size-3.5" 852 + > 853 + <path 854 + stroke-linecap="round" 855 + stroke-linejoin="round" 856 + d="M6 18 18 6M6 6l12 12" 857 + /> 858 + </svg> 859 + </Button> 860 + </div> 861 + {:else} 862 + <Button 863 + variant="ghost" 864 + size="sm" 865 + class="w-fit" 866 + onclick={() => { 867 + if (startsAt) { 868 + const d = new Date(startsAt); 869 + d.setHours(d.getHours() + 1); 870 + endsAt = isoToDatetimeLocal(d.toISOString()); 871 + } else { 872 + endsAt = ''; 873 + } 874 + }} 875 + > 876 + <svg 877 + xmlns="http://www.w3.org/2000/svg" 878 + fill="none" 879 + viewBox="0 0 24 24" 880 + stroke-width="1.5" 881 + stroke="currentColor" 882 + class="size-3.5" 883 + > 884 + <path 885 + stroke-linecap="round" 886 + stroke-linejoin="round" 887 + d="M12 4.5v15m7.5-7.5h-15" 888 + /> 889 + </svg> 890 + Add end date 891 + </Button> 725 892 {/if} 726 - </p> 727 - <p class="text-base-500 dark:text-base-400 text-sm"> 728 - {formatTime(startDate)} 729 - {#if endDate && isSameDay} 730 - - {formatTime(endDate)} 893 + {#if startDate} 894 + <Button size="sm" onclick={() => (editingDates = false)} class="mt-1 w-fit"> 895 + <svg 896 + xmlns="http://www.w3.org/2000/svg" 897 + fill="none" 898 + viewBox="0 0 24 24" 899 + stroke-width="2" 900 + stroke="currentColor" 901 + class="size-3.5" 902 + > 903 + <path 904 + stroke-linecap="round" 905 + stroke-linejoin="round" 906 + d="m4.5 12.75 6 6 9-13.5" 907 + /> 908 + </svg> 909 + Done 910 + </Button> 731 911 {/if} 732 - </p> 912 + </div> 733 913 {/if} 734 - <div class="mt-1 flex flex-wrap gap-3"> 735 - <label class="flex items-center gap-1.5"> 736 - <span class="text-base-500 dark:text-base-400 text-xs">Start</span> 737 - <input 738 - type="datetime-local" 739 - bind:value={startsAt} 740 - required 741 - class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none" 742 - /> 743 - </label> 744 - <label class="flex items-center gap-1.5"> 745 - <span class="text-base-500 dark:text-base-400 text-xs">End</span> 746 - <input 747 - type="datetime-local" 748 - bind:value={endsAt} 749 - class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none" 750 - /> 751 - </label> 752 - </div> 753 914 </div> 754 915 </div> 755 916 ··· 757 918 {#if location} 758 919 <div class="mb-6 flex items-center gap-4"> 759 920 <div 760 - class="border-base-200 dark:border-base-700 flex size-12 shrink-0 items-center justify-center rounded-xl border" 921 + class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border" 761 922 > 762 923 <svg 763 924 xmlns="http://www.w3.org/2000/svg" ··· 782 943 <p class="text-base-900 dark:text-base-50 flex-1 font-semibold"> 783 944 {getLocationDisplayString(location)} 784 945 </p> 785 - <button 786 - type="button" 787 - onclick={removeLocation} 788 - class="text-base-400 shrink-0 hover:text-red-500" 789 - aria-label="Remove location" 790 - > 946 + <Button variant="ghost" size="iconSm" onclick={removeLocation} class="shrink-0"> 791 947 <svg 792 948 xmlns="http://www.w3.org/2000/svg" 793 949 viewBox="0 0 20 20" 794 950 fill="currentColor" 795 - class="size-4" 951 + class="size-3.5" 796 952 > 797 953 <path 798 954 d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 799 955 /> 800 956 </svg> 801 - </button> 957 + </Button> 802 958 </div> 803 959 {:else} 804 - <button 805 - type="button" 806 - onclick={() => (showLocationModal = true)} 807 - class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 mb-6 flex items-center gap-4 transition-colors" 808 - > 809 - <div 810 - class="border-base-200 dark:border-base-700 flex size-12 shrink-0 items-center justify-center rounded-xl border" 811 - > 960 + <div class="mb-6"> 961 + <Button variant="ghost" onclick={() => (showLocationModal = true)}> 812 962 <svg 813 963 xmlns="http://www.w3.org/2000/svg" 814 964 fill="none" 815 965 viewBox="0 0 24 24" 816 966 stroke-width="1.5" 817 967 stroke="currentColor" 818 - class="size-5" 968 + class="size-4" 819 969 > 820 970 <path 821 971 stroke-linecap="round" ··· 828 978 d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" 829 979 /> 830 980 </svg> 831 - </div> 832 - <span class="text-sm">Add location</span> 833 - </button> 981 + Add location 982 + </Button> 983 + </div> 834 984 {/if} 835 985 836 986 <!-- About Event --> ··· 844 994 bind:value={description} 845 995 rows={4} 846 996 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" 997 + 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 px-0 leading-relaxed focus:border-0 focus:ring-0 focus:outline-none" 998 + style="field-sizing: content;" 848 999 ></textarea> 849 1000 </div> 850 1001 ··· 852 1003 <p class="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p> 853 1004 {/if} 854 1005 855 - <Button type="submit" disabled={submitting}> 856 - {submitting ? 'Saving...' : 'Save Changes'} 1006 + <Button type="submit" disabled={submitting || !name.trim() || !startsAt}> 1007 + {submitting 1008 + ? isNew 1009 + ? 'Creating...' 1010 + : 'Saving...' 1011 + : isNew 1012 + ? 'Create Event' 1013 + : 'Save Changes'} 857 1014 </Button> 858 1015 </div> 859 1016 ··· 899 1056 <span class="text-base-700 dark:text-base-300 truncate text-sm"> 900 1057 {link.name || link.uri.replace(/^https?:\/\//, '')} 901 1058 </span> 902 - <button 903 - type="button" 1059 + <Button 1060 + variant="ghost" 1061 + size="iconSm" 904 1062 onclick={() => removeLink(i)} 905 - class="text-base-400 ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-500" 906 - aria-label="Remove link" 1063 + class="ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100" 907 1064 > 908 1065 <svg 909 1066 xmlns="http://www.w3.org/2000/svg" ··· 915 1072 d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 916 1073 /> 917 1074 </svg> 918 - </button> 1075 + </Button> 919 1076 </div> 920 1077 {/each} 921 1078 </div> 922 1079 923 - <div class="relative mt-3"> 924 - <button 925 - type="button" 926 - onclick={() => (showLinkPopup = !showLinkPopup)} 927 - class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 flex items-center gap-1.5 text-sm transition-colors" 928 - > 929 - <svg 930 - xmlns="http://www.w3.org/2000/svg" 931 - fill="none" 932 - viewBox="0 0 24 24" 933 - stroke-width="1.5" 934 - stroke="currentColor" 935 - class="size-4" 936 - > 937 - <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 938 - </svg> 939 - Add link 940 - </button> 1080 + <div class="mt-3"> 1081 + <PopoverRoot bind:open={showLinkPopup}> 1082 + <PopoverTrigger> 1083 + <Button size="sm"> 1084 + <svg 1085 + xmlns="http://www.w3.org/2000/svg" 1086 + fill="none" 1087 + viewBox="0 0 24 24" 1088 + stroke-width="1.5" 1089 + stroke="currentColor" 1090 + class="size-4" 1091 + > 1092 + <path 1093 + stroke-linecap="round" 1094 + stroke-linejoin="round" 1095 + d="M12 4.5v15m7.5-7.5h-15" 1096 + /> 1097 + </svg> 941 1098 942 - {#if showLinkPopup} 943 - <div 944 - class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 absolute top-full left-0 z-10 mt-2 w-64 rounded-xl border p-3 shadow-lg" 945 - > 946 - <input 1099 + Add link 1100 + </Button> 1101 + </PopoverTrigger> 1102 + <PopoverContent side="bottom" sideOffset={8} class="w-64 p-3"> 1103 + <Input 947 1104 type="url" 948 1105 bind:value={newLinkUri} 949 1106 placeholder="https://..." 950 - class="border-base-300 dark:border-base-700 bg-base-100 dark:bg-base-800 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 mb-2 w-full rounded-lg border px-2.5 py-1.5 text-sm focus:outline-none" 1107 + variant="secondary" 1108 + class="mb-2" 1109 + onkeydown={(e) => { 1110 + if (e.key === 'Enter') { 1111 + e.preventDefault(); 1112 + addLink(); 1113 + } 1114 + }} 951 1115 /> 952 - <input 1116 + <Input 953 1117 type="text" 954 1118 bind:value={newLinkName} 955 1119 placeholder="Label (optional)" 956 - class="border-base-300 dark:border-base-700 bg-base-100 dark:bg-base-800 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 mb-3 w-full rounded-lg border px-2.5 py-1.5 text-sm focus:outline-none" 1120 + variant="secondary" 1121 + class="mb-2" 1122 + onkeydown={(e) => { 1123 + if (e.key === 'Enter') { 1124 + e.preventDefault(); 1125 + addLink(); 1126 + } 1127 + }} 957 1128 /> 1129 + {#if linkError} 1130 + <p class="mb-2 text-xs text-red-500">{linkError}</p> 1131 + {/if} 958 1132 <div class="flex justify-end gap-2"> 959 - <button 960 - type="button" 961 - onclick={() => (showLinkPopup = false)} 962 - class="text-base-500 dark:text-base-400 text-xs hover:underline" 1133 + <Button 1134 + variant="ghost" 1135 + size="sm" 1136 + onclick={() => { 1137 + showLinkPopup = false; 1138 + linkError = ''; 1139 + newLinkUri = ''; 1140 + newLinkName = ''; 1141 + }} 963 1142 > 964 1143 Cancel 965 - </button> 966 - <button 967 - type="button" 968 - onclick={addLink} 969 - disabled={!newLinkUri.trim()} 970 - class="bg-base-900 dark:bg-base-100 text-base-50 dark:text-base-900 disabled:bg-base-300 dark:disabled:bg-base-700 rounded-lg px-3 py-1 text-xs font-medium disabled:cursor-not-allowed" 971 - > 972 - Add 973 - </button> 1144 + </Button> 1145 + <Button onclick={addLink} size="sm" disabled={!newLinkUri.trim()}>Add</Button> 974 1146 </div> 975 - </div> 976 - {/if} 1147 + </PopoverContent> 1148 + </PopoverRoot> 977 1149 </div> 978 1150 </div> 979 1151 </div> 1152 + 1153 + {#if !isNew} 1154 + <div class="border-base-200 dark:border-base-800 mt-12 border-t pt-8"> 1155 + {#if showDeleteConfirm} 1156 + <div class="flex items-center gap-3"> 1157 + <p class="text-sm text-red-600 dark:text-red-400"> 1158 + Are you sure? This cannot be undone. 1159 + </p> 1160 + <Button 1161 + variant="secondary" 1162 + size="sm" 1163 + onclick={() => (showDeleteConfirm = false)} 1164 + disabled={deleting} 1165 + > 1166 + Cancel 1167 + </Button> 1168 + <Button 1169 + size="sm" 1170 + onclick={handleDelete} 1171 + disabled={deleting} 1172 + variant="red" 1173 + > 1174 + {deleting ? 'Deleting...' : 'Delete'} 1175 + </Button> 1176 + </div> 1177 + {:else} 1178 + <Button 1179 + variant="red" 1180 + onclick={() => (showDeleteConfirm = true)} 1181 + > 1182 + Delete event 1183 + </Button> 1184 + {/if} 1185 + </div> 1186 + {/if} 980 1187 </form> 981 1188 {/if} 982 1189 </div> ··· 993 1200 class="mt-2" 994 1201 > 995 1202 <div class="flex gap-2"> 996 - <input 997 - type="text" 998 - bind:value={locationSearch} 999 - placeholder="Search for a city or address..." 1000 - class="border-base-300 dark:border-base-700 bg-base-100 dark:bg-base-800 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 flex-1 rounded-lg border px-3 py-2 text-sm focus:outline-none" 1001 - /> 1203 + <Input type="text" class="flex-1" bind:value={locationSearch} /> 1002 1204 <Button type="submit" disabled={locationSearching || !locationSearch.trim()}> 1003 1205 {locationSearching ? 'Searching...' : 'Search'} 1004 1206 </Button>
-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>