your personal website on atproto - mirror blento.app

add datetimepicker, lots of other small improvements

+787 -206
+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>
+322 -165
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'; ··· 13 23 import { putImage, getImage, deleteImage } from '$lib/components/image-store'; 14 24 import Modal from '$lib/components/modal/Modal.svelte'; 15 25 import Avatar from 'svelte-boring-avatars'; 26 + import DateTimePicker from '$lib/components/DateTimePicker.svelte'; 16 27 17 28 let { data } = $props(); 18 29 ··· 54 65 let thumbnailPreview: string | null = $state(null); 55 66 let submitting = $state(false); 56 67 let error: string | null = $state(null); 68 + let titleEl: HTMLTextAreaElement | undefined = $state(undefined); 57 69 58 70 let location: EventLocation | null = $state(null); 59 71 let locationChanged = $state(false); ··· 64 76 let locationResult: { displayName: string; location: EventLocation } | null = $state(null); 65 77 66 78 let links: Array<{ uri: string; name: string }> = $state([]); 79 + let editingDates = $state(false); 67 80 let showLinkPopup = $state(false); 68 81 let newLinkUri = $state(''); 69 82 let newLinkName = $state(''); 83 + let linkError = $state(''); 70 84 71 - let hasDraft = $state(false); 72 85 let draftLoaded = $state(false); 73 86 74 87 function isoToDatetimeLocal(iso: string): string { ··· 175 188 // No new thumbnail in draft, show existing one from event data 176 189 populateThumbnailFromEventData(); 177 190 } 178 - 179 - hasDraft = true; 180 191 } catch { 181 192 localStorage.removeItem(DRAFT_KEY); 182 193 if (!isNew) populateFromEventData(); ··· 185 196 populateFromEventData(); 186 197 } 187 198 draftLoaded = true; 199 + if (!startsAt) editingDates = true; 200 + titleEl?.focus(); 188 201 }); 189 202 190 203 let saveDraftTimeout: ReturnType<typeof setTimeout> | undefined; ··· 206 219 if (locationChanged) draft.location = location; 207 220 if (thumbnailKey) draft.thumbnailKey = thumbnailKey; 208 221 localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); 209 - hasDraft = true; 210 222 }, 500); 211 223 } 212 224 ··· 224 236 saveDraft(); 225 237 }); 226 238 227 - function deleteDraft() { 228 - localStorage.removeItem(DRAFT_KEY); 229 - if (thumbnailKey) deleteImage(thumbnailKey); 230 - thumbnailKey = null; 231 - thumbnailChanged = false; 232 - if (isNew) { 233 - name = ''; 234 - description = ''; 235 - startsAt = ''; 236 - endsAt = ''; 237 - links = []; 238 - mode = 'inperson'; 239 - location = null; 240 - thumbnailFile = null; 241 - if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 242 - thumbnailPreview = null; 243 - } else { 244 - populateFromEventData(); 245 - } 246 - hasDraft = false; 247 - } 248 - 249 239 async function searchLocation() { 250 240 const q = locationSearch.trim(); 251 241 if (!q) return; ··· 305 295 } 306 296 307 297 function addLink() { 308 - const uri = newLinkUri.trim(); 309 - 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 + } 310 305 links.push({ uri, name: newLinkName.trim() }); 311 306 newLinkUri = ''; 312 307 newLinkName = ''; 308 + linkError = ''; 313 309 showLinkPopup = false; 314 310 } 315 311 ··· 410 406 startDate.getDate() === endDate.getDate() 411 407 ); 412 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 + 413 422 async function tokensToFacets(tokens: Token[]): Promise<Record<string, unknown>[]> { 414 423 const encoder = new TextEncoder(); 415 424 const facets: Record<string, unknown>[] = []; ··· 566 575 submitting = false; 567 576 } 568 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 + } 569 604 </script> 570 605 571 606 <svelte:head> ··· 589 624 <Button onclick={() => loginModalState.show()}>Log in</Button> 590 625 </div> 591 626 {:else} 592 - <div class="mb-6 flex items-center gap-3"> 593 - <Badge size="sm">{isNew ? 'Local draft' : 'Local edit'}</Badge> 594 - {#if hasDraft} 595 - <button 596 - type="button" 597 - onclick={deleteDraft} 598 - class="text-base-500 dark:text-base-400 cursor-pointer text-xs hover:text-red-500 hover:underline" 599 - > 600 - {isNew ? 'Delete draft' : 'Discard changes'} 601 - </button> 602 - {/if} 603 - </div> 604 - 605 627 <form 606 628 onsubmit={(e) => { 607 629 e.preventDefault(); ··· 672 694 <span class="text-sm font-medium">Upload thumbnail</span> 673 695 </button> 674 696 {#if thumbnailPreview} 675 - <button 676 - type="button" 697 + <Button 698 + variant="ghost" 699 + size="iconSm" 677 700 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" 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" 680 702 > 681 703 <svg 682 704 xmlns="http://www.w3.org/2000/svg" 683 705 viewBox="0 0 20 20" 684 706 fill="currentColor" 685 - class="size-4" 707 + class="size-3.5" 686 708 > 687 709 <path 688 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" 689 711 /> 690 712 </svg> 691 - </button> 713 + </Button> 692 714 {/if} 693 715 </div> 694 716 </div> 695 717 696 718 <!-- Right column: event details --> 697 719 <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> 698 - <!-- Name --> 699 - <input 700 - type="text" 701 - bind:value={name} 702 - required 703 - placeholder="Event name" 704 - class="text-base-900 dark:text-base-50 placeholder:text-base-500 dark:placeholder:text-base-500 mb-2 w-full border-0 bg-transparent text-4xl leading-tight font-bold focus:border-0 focus:ring-0 focus:outline-none sm:text-5xl" 705 - /> 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> 706 740 707 741 <!-- Mode toggle --> 708 742 <div class="mb-8"> ··· 725 759 </div> 726 760 727 761 <!-- Date row --> 728 - <div class="mb-4 flex items-center gap-4"> 762 + <div class="mb-4 flex items-start gap-4"> 729 763 <div 730 - 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" 731 765 > 732 766 {#if startDate} 733 767 <span ··· 745 779 viewBox="0 0 24 24" 746 780 stroke-width="1.5" 747 781 stroke="currentColor" 748 - class="text-base-400 dark:text-base-500 size-5" 782 + class="text-base-900 dark:text-base-200 size-5" 749 783 > 750 784 <path 751 785 stroke-linecap="round" ··· 756 790 {/if} 757 791 </div> 758 792 <div class="flex-1"> 759 - {#if startDate} 760 - <p class="text-base-900 dark:text-base-50 font-semibold"> 761 - {formatWeekday(startDate)}, {formatFullDate(startDate)} 762 - {#if endDate && !isSameDay} 763 - - {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> 764 892 {/if} 765 - </p> 766 - <p class="text-base-500 dark:text-base-400 text-sm"> 767 - {formatTime(startDate)} 768 - {#if endDate && isSameDay} 769 - - {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> 770 911 {/if} 771 - </p> 912 + </div> 772 913 {/if} 773 - <div class="mt-1 flex flex-wrap gap-3"> 774 - <label class="flex items-center gap-1.5"> 775 - <span class="text-base-500 dark:text-base-400 text-xs">Start</span> 776 - <input 777 - type="datetime-local" 778 - bind:value={startsAt} 779 - required 780 - class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none" 781 - /> 782 - </label> 783 - <label class="flex items-center gap-1.5"> 784 - <span class="text-base-500 dark:text-base-400 text-xs">End</span> 785 - <input 786 - type="datetime-local" 787 - bind:value={endsAt} 788 - class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none" 789 - /> 790 - </label> 791 - </div> 792 914 </div> 793 915 </div> 794 916 ··· 796 918 {#if location} 797 919 <div class="mb-6 flex items-center gap-4"> 798 920 <div 799 - 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" 800 922 > 801 923 <svg 802 924 xmlns="http://www.w3.org/2000/svg" ··· 821 943 <p class="text-base-900 dark:text-base-50 flex-1 font-semibold"> 822 944 {getLocationDisplayString(location)} 823 945 </p> 824 - <button 825 - type="button" 826 - onclick={removeLocation} 827 - class="text-base-400 shrink-0 hover:text-red-500" 828 - aria-label="Remove location" 829 - > 946 + <Button variant="ghost" size="iconSm" onclick={removeLocation} class="shrink-0"> 830 947 <svg 831 948 xmlns="http://www.w3.org/2000/svg" 832 949 viewBox="0 0 20 20" 833 950 fill="currentColor" 834 - class="size-4" 951 + class="size-3.5" 835 952 > 836 953 <path 837 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" 838 955 /> 839 956 </svg> 840 - </button> 957 + </Button> 841 958 </div> 842 959 {:else} 843 - <button 844 - type="button" 845 - onclick={() => (showLocationModal = true)} 846 - 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" 847 - > 848 - <div 849 - class="border-base-200 dark:border-base-700 flex size-12 shrink-0 items-center justify-center rounded-xl border" 850 - > 960 + <div class="mb-6"> 961 + <Button variant="ghost" onclick={() => (showLocationModal = true)}> 851 962 <svg 852 963 xmlns="http://www.w3.org/2000/svg" 853 964 fill="none" 854 965 viewBox="0 0 24 24" 855 966 stroke-width="1.5" 856 967 stroke="currentColor" 857 - class="size-5" 968 + class="size-4" 858 969 > 859 970 <path 860 971 stroke-linecap="round" ··· 867 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" 868 979 /> 869 980 </svg> 870 - </div> 871 - <span class="text-sm">Add location</span> 872 - </button> 981 + Add location 982 + </Button> 983 + </div> 873 984 {/if} 874 985 875 986 <!-- About Event --> ··· 883 994 bind:value={description} 884 995 rows={4} 885 996 placeholder="What's this event about? @mentions, #hashtags and links will be detected automatically." 886 - class="text-base-700 dark:text-base-300 placeholder:text-base-500 dark:placeholder:text-base-500 w-full resize-none border-0 bg-transparent leading-relaxed focus:border-0 focus:ring-0 focus:outline-none" 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;" 887 999 ></textarea> 888 1000 </div> 889 1001 ··· 891 1003 <p class="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p> 892 1004 {/if} 893 1005 894 - <Button type="submit" disabled={submitting}> 1006 + <Button type="submit" disabled={submitting || !name.trim() || !startsAt}> 895 1007 {submitting 896 1008 ? isNew 897 1009 ? 'Creating...' ··· 944 1056 <span class="text-base-700 dark:text-base-300 truncate text-sm"> 945 1057 {link.name || link.uri.replace(/^https?:\/\//, '')} 946 1058 </span> 947 - <button 948 - type="button" 1059 + <Button 1060 + variant="ghost" 1061 + size="iconSm" 949 1062 onclick={() => removeLink(i)} 950 - class="text-base-400 ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-500" 951 - aria-label="Remove link" 1063 + class="ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100" 952 1064 > 953 1065 <svg 954 1066 xmlns="http://www.w3.org/2000/svg" ··· 960 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" 961 1073 /> 962 1074 </svg> 963 - </button> 1075 + </Button> 964 1076 </div> 965 1077 {/each} 966 1078 </div> 967 1079 968 - <div class="relative mt-3"> 969 - <button 970 - type="button" 971 - onclick={() => (showLinkPopup = !showLinkPopup)} 972 - 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" 973 - > 974 - <svg 975 - xmlns="http://www.w3.org/2000/svg" 976 - fill="none" 977 - viewBox="0 0 24 24" 978 - stroke-width="1.5" 979 - stroke="currentColor" 980 - class="size-4" 981 - > 982 - <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 983 - </svg> 984 - Add link 985 - </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> 986 1098 987 - {#if showLinkPopup} 988 - <div 989 - 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" 990 - > 991 - <input 1099 + Add link 1100 + </Button> 1101 + </PopoverTrigger> 1102 + <PopoverContent side="bottom" sideOffset={8} class="w-64 p-3"> 1103 + <Input 992 1104 type="url" 993 1105 bind:value={newLinkUri} 994 1106 placeholder="https://..." 995 - 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 + }} 996 1115 /> 997 - <input 1116 + <Input 998 1117 type="text" 999 1118 bind:value={newLinkName} 1000 1119 placeholder="Label (optional)" 1001 - 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 + }} 1002 1128 /> 1129 + {#if linkError} 1130 + <p class="mb-2 text-xs text-red-500">{linkError}</p> 1131 + {/if} 1003 1132 <div class="flex justify-end gap-2"> 1004 - <button 1005 - type="button" 1006 - onclick={() => (showLinkPopup = false)} 1007 - 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 + }} 1008 1142 > 1009 1143 Cancel 1010 - </button> 1011 - <button 1012 - type="button" 1013 - onclick={addLink} 1014 - disabled={!newLinkUri.trim()} 1015 - 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" 1016 - > 1017 - Add 1018 - </button> 1144 + </Button> 1145 + <Button onclick={addLink} size="sm" disabled={!newLinkUri.trim()}>Add</Button> 1019 1146 </div> 1020 - </div> 1021 - {/if} 1147 + </PopoverContent> 1148 + </PopoverRoot> 1022 1149 </div> 1023 1150 </div> 1024 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} 1025 1187 </form> 1026 1188 {/if} 1027 1189 </div> ··· 1038 1200 class="mt-2" 1039 1201 > 1040 1202 <div class="flex gap-2"> 1041 - <input 1042 - type="text" 1043 - bind:value={locationSearch} 1044 - placeholder="Search for a city or address..." 1045 - 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" 1046 - /> 1203 + <Input type="text" class="flex-1" bind:value={locationSearch} /> 1047 1204 <Button type="submit" disabled={locationSearching || !locationSearch.trim()}> 1048 1205 {locationSearching ? 'Searching...' : 'Search'} 1049 1206 </Button>