tangled
alpha
login
or
join now
flo-bit.dev
/
blento
20
fork
atom
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
add datetimepicker, lots of other small improvements
Florian
1 week ago
6bf9d2b4
df7c39b1
+787
-206
7 changed files
expand all
collapse all
unified
split
.claude
settings.local.json
package.json
pnpm-lock.yaml
src
lib
components
DatePicker.svelte
DateTimePicker.svelte
TimePicker.svelte
routes
[[actor=actor]]
events
[rkey]
edit
+page.svelte
+34
-32
.claude/settings.local.json
···
1
1
{
2
2
-
"permissions": {
3
3
-
"allow": [
4
4
-
"Bash(pnpm check:*)",
5
5
-
"mcp__ide__getDiagnostics",
6
6
-
"mcp__plugin_svelte_svelte__svelte-autofixer",
7
7
-
"mcp__plugin_svelte_svelte__list-sections",
8
8
-
"Bash(pkill:*)",
9
9
-
"Bash(timeout 8 pnpm dev:*)",
10
10
-
"Bash(git checkout:*)",
11
11
-
"Bash(npx svelte-kit:*)",
12
12
-
"Bash(ls:*)",
13
13
-
"Bash(pnpm format:*)",
14
14
-
"Bash(pnpm add:*)",
15
15
-
"WebSearch",
16
16
-
"WebFetch(domain:github.com)",
17
17
-
"WebFetch(domain:flipclockjs.com)",
18
18
-
"WebFetch(domain:codepen.io)",
19
19
-
"WebFetch(domain:flo-bit.dev)",
20
20
-
"Bash(pnpm install)",
21
21
-
"Bash(pnpm install:*)",
22
22
-
"Bash(pnpm config:*)",
23
23
-
"Bash(lsof:*)",
24
24
-
"Bash(pnpm dev)",
25
25
-
"Bash(pnpm exec svelte-kit:*)",
26
26
-
"Bash(pnpm build:*)",
27
27
-
"Bash(pnpm remove:*)",
28
28
-
"Bash(grep:*)",
29
29
-
"Bash(find:*)",
30
30
-
"Bash(npx prettier:*)",
31
31
-
"Bash(node -e:*)"
32
32
-
]
33
33
-
}
2
2
+
"permissions": {
3
3
+
"allow": [
4
4
+
"Bash(pnpm check:*)",
5
5
+
"mcp__ide__getDiagnostics",
6
6
+
"mcp__plugin_svelte_svelte__svelte-autofixer",
7
7
+
"mcp__plugin_svelte_svelte__list-sections",
8
8
+
"Bash(pkill:*)",
9
9
+
"Bash(timeout 8 pnpm dev:*)",
10
10
+
"Bash(git checkout:*)",
11
11
+
"Bash(npx svelte-kit:*)",
12
12
+
"Bash(ls:*)",
13
13
+
"Bash(pnpm format:*)",
14
14
+
"Bash(pnpm add:*)",
15
15
+
"WebSearch",
16
16
+
"WebFetch(domain:github.com)",
17
17
+
"WebFetch(domain:flipclockjs.com)",
18
18
+
"WebFetch(domain:codepen.io)",
19
19
+
"WebFetch(domain:flo-bit.dev)",
20
20
+
"Bash(pnpm install)",
21
21
+
"Bash(pnpm install:*)",
22
22
+
"Bash(pnpm config:*)",
23
23
+
"Bash(lsof:*)",
24
24
+
"Bash(pnpm dev)",
25
25
+
"Bash(pnpm exec svelte-kit:*)",
26
26
+
"Bash(pnpm build:*)",
27
27
+
"Bash(pnpm remove:*)",
28
28
+
"Bash(grep:*)",
29
29
+
"Bash(find:*)",
30
30
+
"Bash(npx prettier:*)",
31
31
+
"Bash(node -e:*)",
32
32
+
"mcp__plugin_svelte_svelte__get-documentation",
33
33
+
"WebFetch(domain:bits-ui.com)"
34
34
+
]
35
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
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
65
+
'@internationalized/date':
66
66
+
specifier: ^3.11.0
67
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
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
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
967
-
'@internationalized/date@3.10.1':
968
968
-
resolution: {integrity: sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==}
970
970
+
'@internationalized/date@3.11.0':
971
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
1236
-
resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
1239
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
3046
-
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
3049
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
3814
-
'@internationalized/date@3.10.1':
3817
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
4590
-
'@internationalized/date': 3.10.1
4593
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
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
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
4602
-
'@internationalized/date': 3.10.1
4605
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
1
+
<script lang="ts">
2
2
+
// @ts-nocheck
3
3
+
import { DatePicker } from 'bits-ui';
4
4
+
import { CalendarDate, type DateValue } from '@internationalized/date';
5
5
+
import { untrack } from 'svelte';
6
6
+
7
7
+
let {
8
8
+
value = $bindable(''),
9
9
+
required = false,
10
10
+
minValue = '',
11
11
+
locale = 'en',
12
12
+
onSelect
13
13
+
}: {
14
14
+
value: string;
15
15
+
required?: boolean;
16
16
+
minValue?: string;
17
17
+
locale?: string;
18
18
+
onSelect?: () => void;
19
19
+
} = $props();
20
20
+
21
21
+
let isOpen = $state(false);
22
22
+
23
23
+
const currentYear = new Date().getFullYear();
24
24
+
const yearRange = Array.from({ length: 7 }, (_, i) => currentYear - 1 + i);
25
25
+
const today = new Date();
26
26
+
const todayDay = today.getDate();
27
27
+
const todayMonth = today.getMonth() + 1;
28
28
+
const todayYear = today.getFullYear();
29
29
+
30
30
+
let internalValue: CalendarDate | undefined = $state(undefined);
31
31
+
32
32
+
function parseDateStr(str: string): CalendarDate | undefined {
33
33
+
if (!str) return undefined;
34
34
+
const [yearStr, monthStr, dayStr] = str.split('-');
35
35
+
const year = parseInt(yearStr, 10);
36
36
+
const month = parseInt(monthStr, 10);
37
37
+
const day = parseInt(dayStr, 10);
38
38
+
if (isNaN(year) || isNaN(month) || isNaN(day)) return undefined;
39
39
+
return new CalendarDate(year, month, day);
40
40
+
}
41
41
+
42
42
+
function formatDateStr(dt: CalendarDate): string {
43
43
+
const y = String(dt.year).padStart(4, '0');
44
44
+
const m = String(dt.month).padStart(2, '0');
45
45
+
const d = String(dt.day).padStart(2, '0');
46
46
+
return `${y}-${m}-${d}`;
47
47
+
}
48
48
+
49
49
+
let internalMinValue: CalendarDate | undefined = $derived.by(() => {
50
50
+
return parseDateStr(minValue);
51
51
+
});
52
52
+
53
53
+
$effect(() => {
54
54
+
const parsed = parseDateStr(value);
55
55
+
untrack(() => {
56
56
+
if (parsed) {
57
57
+
if (
58
58
+
!internalValue ||
59
59
+
parsed.year !== internalValue.year ||
60
60
+
parsed.month !== internalValue.month ||
61
61
+
parsed.day !== internalValue.day
62
62
+
) {
63
63
+
internalValue = parsed;
64
64
+
}
65
65
+
} else {
66
66
+
internalValue = undefined;
67
67
+
}
68
68
+
});
69
69
+
});
70
70
+
71
71
+
function handleValueChange(newVal: DateValue | undefined) {
72
72
+
if (newVal && newVal instanceof CalendarDate) {
73
73
+
internalValue = newVal;
74
74
+
value = formatDateStr(newVal);
75
75
+
}
76
76
+
}
77
77
+
78
78
+
function handleOpenChange(open: boolean) {
79
79
+
isOpen = open;
80
80
+
}
81
81
+
82
82
+
function handleOpenChangeComplete(open: boolean) {
83
83
+
if (!open && internalValue) {
84
84
+
onSelect?.();
85
85
+
}
86
86
+
}
87
87
+
</script>
88
88
+
89
89
+
<DatePicker.Root
90
90
+
bind:value={internalValue}
91
91
+
onValueChange={handleValueChange}
92
92
+
onOpenChange={handleOpenChange}
93
93
+
onOpenChangeComplete={handleOpenChangeComplete}
94
94
+
minValue={internalMinValue}
95
95
+
granularity="day"
96
96
+
fixedWeeks={true}
97
97
+
weekdayFormat="short"
98
98
+
{locale}
99
99
+
{required}
100
100
+
>
101
101
+
<div
102
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
103
+
>
104
104
+
<DatePicker.Input>
105
105
+
{#snippet children({ segments })}
106
106
+
{#each segments as segment, i (segment.part + i)}
107
107
+
{#if segment.part === 'literal'}
108
108
+
<span class="text-base-400 dark:text-base-500">{segment.value}</span>
109
109
+
{:else}
110
110
+
<DatePicker.Segment
111
111
+
part={segment.part}
112
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
113
+
>
114
114
+
{segment.value}
115
115
+
</DatePicker.Segment>
116
116
+
{/if}
117
117
+
{/each}
118
118
+
{/snippet}
119
119
+
</DatePicker.Input>
120
120
+
121
121
+
<DatePicker.Trigger
122
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
123
+
>
124
124
+
<svg
125
125
+
xmlns="http://www.w3.org/2000/svg"
126
126
+
fill="none"
127
127
+
viewBox="0 0 24 24"
128
128
+
stroke-width="1.5"
129
129
+
stroke="currentColor"
130
130
+
class="size-4"
131
131
+
>
132
132
+
<path
133
133
+
stroke-linecap="round"
134
134
+
stroke-linejoin="round"
135
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
136
+
/>
137
137
+
</svg>
138
138
+
</DatePicker.Trigger>
139
139
+
</div>
140
140
+
141
141
+
<DatePicker.Content
142
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
143
+
>
144
144
+
<DatePicker.Calendar>
145
145
+
{#snippet children({ months, weekdays })}
146
146
+
<DatePicker.Header class="flex items-center justify-between">
147
147
+
<DatePicker.PrevButton
148
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
149
+
>
150
150
+
<svg
151
151
+
xmlns="http://www.w3.org/2000/svg"
152
152
+
viewBox="0 0 20 20"
153
153
+
fill="currentColor"
154
154
+
class="size-5"
155
155
+
>
156
156
+
<path
157
157
+
fill-rule="evenodd"
158
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
159
+
clip-rule="evenodd"
160
160
+
/>
161
161
+
</svg>
162
162
+
</DatePicker.PrevButton>
163
163
+
164
164
+
<div class="flex items-center gap-1.5">
165
165
+
<DatePicker.MonthSelect
166
166
+
monthFormat="long"
167
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
168
+
/>
169
169
+
<DatePicker.YearSelect
170
170
+
years={yearRange}
171
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
172
+
/>
173
173
+
</div>
174
174
+
175
175
+
<DatePicker.NextButton
176
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
177
+
>
178
178
+
<svg
179
179
+
xmlns="http://www.w3.org/2000/svg"
180
180
+
viewBox="0 0 20 20"
181
181
+
fill="currentColor"
182
182
+
class="size-5"
183
183
+
>
184
184
+
<path
185
185
+
fill-rule="evenodd"
186
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
187
+
clip-rule="evenodd"
188
188
+
/>
189
189
+
</svg>
190
190
+
</DatePicker.NextButton>
191
191
+
</DatePicker.Header>
192
192
+
193
193
+
{#each months as month (month.value.month)}
194
194
+
<DatePicker.Grid class="mt-3 w-full">
195
195
+
<DatePicker.GridHead>
196
196
+
<DatePicker.GridRow class="flex w-full">
197
197
+
{#each weekdays as weekday, i (i)}
198
198
+
<DatePicker.HeadCell
199
199
+
class="text-base-400 dark:text-base-500 flex-1 text-center text-xs font-medium"
200
200
+
>
201
201
+
{weekday}
202
202
+
</DatePicker.HeadCell>
203
203
+
{/each}
204
204
+
</DatePicker.GridRow>
205
205
+
</DatePicker.GridHead>
206
206
+
207
207
+
<DatePicker.GridBody>
208
208
+
{#each month.weeks as week, weekIndex (weekIndex)}
209
209
+
<DatePicker.GridRow class="flex w-full">
210
210
+
{#each week as day (day.toString())}
211
211
+
<DatePicker.Cell date={day} month={month.value} class="flex-1 p-0.5">
212
212
+
<DatePicker.Day>
213
213
+
{#snippet children({ selected, disabled, day: dayText })}
214
214
+
<div
215
215
+
class="relative flex size-9 items-center justify-center rounded-lg text-sm
216
216
+
{selected
217
217
+
? 'bg-accent-500 font-medium text-white'
218
218
+
: disabled
219
219
+
? 'text-base-300 dark:text-base-600 pointer-events-none'
220
220
+
: day.month !== month.value.month
221
221
+
? 'text-base-300 dark:text-base-600'
222
222
+
: 'text-base-700 hover:bg-base-200 dark:text-base-300 dark:hover:bg-base-700'}"
223
223
+
>
224
224
+
{dayText}
225
225
+
{#if day.day === todayDay && day.month === todayMonth && day.year === todayYear}
226
226
+
<span
227
227
+
class="bg-accent-500 absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full"
228
228
+
class:bg-white={selected}
229
229
+
></span>
230
230
+
{/if}
231
231
+
</div>
232
232
+
{/snippet}
233
233
+
</DatePicker.Day>
234
234
+
</DatePicker.Cell>
235
235
+
{/each}
236
236
+
</DatePicker.GridRow>
237
237
+
{/each}
238
238
+
</DatePicker.GridBody>
239
239
+
</DatePicker.Grid>
240
240
+
{/each}
241
241
+
{/snippet}
242
242
+
</DatePicker.Calendar>
243
243
+
</DatePicker.Content>
244
244
+
</DatePicker.Root>
+73
src/lib/components/DateTimePicker.svelte
···
1
1
+
<script lang="ts">
2
2
+
// @ts-nocheck
3
3
+
import DatePickerField from './DatePicker.svelte';
4
4
+
import TimePicker from './TimePicker.svelte';
5
5
+
import { untrack } from 'svelte';
6
6
+
import { browser } from '$app/environment';
7
7
+
8
8
+
let {
9
9
+
value = $bindable(''),
10
10
+
required = false,
11
11
+
minValue = ''
12
12
+
}: {
13
13
+
value: string;
14
14
+
required?: boolean;
15
15
+
minValue?: string;
16
16
+
} = $props();
17
17
+
18
18
+
let datePart = $state('');
19
19
+
let timePart = $state('00:00');
20
20
+
let timeEl: HTMLDivElement | undefined = $state(undefined);
21
21
+
22
22
+
const locale = browser ? navigator.language || 'en' : 'en';
23
23
+
let minDatePart = $derived(minValue ? minValue.split('T')[0] || '' : '');
24
24
+
25
25
+
// Sync external value -> date/time parts
26
26
+
$effect(() => {
27
27
+
const v = value;
28
28
+
untrack(() => {
29
29
+
if (v) {
30
30
+
const [d, t] = v.split('T');
31
31
+
if (d && d !== datePart) datePart = d;
32
32
+
if (t && t !== timePart) timePart = t;
33
33
+
}
34
34
+
});
35
35
+
});
36
36
+
37
37
+
// Sync date/time parts -> external value
38
38
+
$effect(() => {
39
39
+
const d = datePart;
40
40
+
const t = timePart;
41
41
+
untrack(() => {
42
42
+
if (d) {
43
43
+
const newVal = `${d}T${t || '00:00'}`;
44
44
+
if (newVal !== value) value = newVal;
45
45
+
}
46
46
+
});
47
47
+
});
48
48
+
49
49
+
function focusTime() {
50
50
+
// Small delay to let the popover finish closing
51
51
+
setTimeout(() => {
52
52
+
if (timeEl) {
53
53
+
const segment = timeEl.querySelector('[data-segment]');
54
54
+
if (segment instanceof HTMLElement) {
55
55
+
segment.focus();
56
56
+
}
57
57
+
}
58
58
+
}, 50);
59
59
+
}
60
60
+
</script>
61
61
+
62
62
+
<div class="flex items-center gap-1.5">
63
63
+
<DatePickerField
64
64
+
bind:value={datePart}
65
65
+
{required}
66
66
+
minValue={minDatePart}
67
67
+
{locale}
68
68
+
onSelect={focusTime}
69
69
+
/>
70
70
+
<div bind:this={timeEl}>
71
71
+
<TimePicker bind:value={timePart} {locale} />
72
72
+
</div>
73
73
+
</div>
+101
src/lib/components/TimePicker.svelte
···
1
1
+
<script lang="ts">
2
2
+
// @ts-nocheck
3
3
+
import { TimeField } from 'bits-ui';
4
4
+
import { Time } from '@internationalized/date';
5
5
+
import { untrack } from 'svelte';
6
6
+
7
7
+
let {
8
8
+
value = $bindable(''),
9
9
+
required = false,
10
10
+
locale = 'en'
11
11
+
}: {
12
12
+
value: string;
13
13
+
required?: boolean;
14
14
+
locale?: string;
15
15
+
} = $props();
16
16
+
17
17
+
let internalValue: Time | undefined = $state(undefined);
18
18
+
19
19
+
function parseTimeStr(str: string): Time | undefined {
20
20
+
if (!str) return undefined;
21
21
+
const [hourStr, minuteStr] = str.split(':');
22
22
+
const hour = parseInt(hourStr, 10);
23
23
+
const minute = parseInt(minuteStr, 10);
24
24
+
if (isNaN(hour) || isNaN(minute)) return undefined;
25
25
+
return new Time(hour, minute);
26
26
+
}
27
27
+
28
28
+
function formatTimeStr(t: Time): string {
29
29
+
const h = String(t.hour).padStart(2, '0');
30
30
+
const m = String(t.minute).padStart(2, '0');
31
31
+
return `${h}:${m}`;
32
32
+
}
33
33
+
34
34
+
$effect(() => {
35
35
+
const parsed = parseTimeStr(value);
36
36
+
untrack(() => {
37
37
+
if (parsed) {
38
38
+
if (
39
39
+
!internalValue ||
40
40
+
parsed.hour !== internalValue.hour ||
41
41
+
parsed.minute !== internalValue.minute
42
42
+
) {
43
43
+
internalValue = parsed;
44
44
+
}
45
45
+
} else {
46
46
+
internalValue = undefined;
47
47
+
}
48
48
+
});
49
49
+
});
50
50
+
51
51
+
function handleValueChange(newVal: Time | undefined) {
52
52
+
if (newVal && newVal instanceof Time) {
53
53
+
internalValue = newVal;
54
54
+
value = formatTimeStr(newVal);
55
55
+
}
56
56
+
}
57
57
+
</script>
58
58
+
59
59
+
<TimeField.Root
60
60
+
bind:value={internalValue}
61
61
+
onValueChange={handleValueChange}
62
62
+
granularity="minute"
63
63
+
{locale}
64
64
+
{required}
65
65
+
>
66
66
+
<div
67
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
68
+
>
69
69
+
<TimeField.Input>
70
70
+
{#snippet children({ segments })}
71
71
+
{#each segments as segment, i (segment.part + i)}
72
72
+
{#if segment.part === 'literal'}
73
73
+
<span class="text-base-400 dark:text-base-500">{segment.value}</span>
74
74
+
{:else}
75
75
+
<TimeField.Segment
76
76
+
part={segment.part}
77
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
78
+
>
79
79
+
{segment.value}
80
80
+
</TimeField.Segment>
81
81
+
{/if}
82
82
+
{/each}
83
83
+
{/snippet}
84
84
+
</TimeField.Input>
85
85
+
86
86
+
<svg
87
87
+
xmlns="http://www.w3.org/2000/svg"
88
88
+
fill="none"
89
89
+
viewBox="0 0 24 24"
90
90
+
stroke-width="1.5"
91
91
+
stroke="currentColor"
92
92
+
class="text-base-400 dark:text-base-500 ml-auto size-4 pl-0.5"
93
93
+
>
94
94
+
<path
95
95
+
stroke-linecap="round"
96
96
+
stroke-linejoin="round"
97
97
+
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
98
98
+
/>
99
99
+
</svg>
100
100
+
</div>
101
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
4
-
import { uploadBlob, putRecord, resolveHandle } from '$lib/atproto/methods';
4
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
7
-
import { Avatar as FoxAvatar, Badge, Button, ToggleGroup, ToggleGroupItem } from '@foxui/core';
7
7
+
import { validateLink } from '$lib/helper';
8
8
+
import {
9
9
+
Avatar as FoxAvatar,
10
10
+
Button,
11
11
+
PopoverRoot,
12
12
+
PopoverTrigger,
13
13
+
PopoverContent,
14
14
+
ToggleGroup,
15
15
+
ToggleGroupItem,
16
16
+
Input
17
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
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
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
79
+
let editingDates = $state(false);
67
80
let showLinkPopup = $state(false);
68
81
let newLinkUri = $state('');
69
82
let newLinkName = $state('');
83
83
+
let linkError = $state('');
70
84
71
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
178
-
179
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
199
+
if (!startsAt) editingDates = true;
200
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
209
-
hasDraft = true;
210
222
}, 500);
211
223
}
212
224
···
224
236
saveDraft();
225
237
});
226
238
227
227
-
function deleteDraft() {
228
228
-
localStorage.removeItem(DRAFT_KEY);
229
229
-
if (thumbnailKey) deleteImage(thumbnailKey);
230
230
-
thumbnailKey = null;
231
231
-
thumbnailChanged = false;
232
232
-
if (isNew) {
233
233
-
name = '';
234
234
-
description = '';
235
235
-
startsAt = '';
236
236
-
endsAt = '';
237
237
-
links = [];
238
238
-
mode = 'inperson';
239
239
-
location = null;
240
240
-
thumbnailFile = null;
241
241
-
if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview);
242
242
-
thumbnailPreview = null;
243
243
-
} else {
244
244
-
populateFromEventData();
245
245
-
}
246
246
-
hasDraft = false;
247
247
-
}
248
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
308
-
const uri = newLinkUri.trim();
309
309
-
if (!uri) return;
298
298
+
const raw = newLinkUri.trim();
299
299
+
if (!raw) return;
300
300
+
const uri = validateLink(raw);
301
301
+
if (!uri) {
302
302
+
linkError = 'Please enter a valid URL';
303
303
+
return;
304
304
+
}
310
305
links.push({ uri, name: newLinkName.trim() });
311
306
newLinkUri = '';
312
307
newLinkName = '';
308
308
+
linkError = '';
313
309
showLinkPopup = false;
314
310
}
315
311
···
410
406
startDate.getDate() === endDate.getDate()
411
407
);
412
408
409
409
+
// Auto-adjust end date if start moves past it
410
410
+
$effect(() => {
411
411
+
if (startsAt && endsAt) {
412
412
+
const s = new Date(startsAt);
413
413
+
const e = new Date(endsAt);
414
414
+
if (s >= e) {
415
415
+
const adjusted = new Date(s);
416
416
+
adjusted.setHours(adjusted.getHours() + 1);
417
417
+
endsAt = isoToDatetimeLocal(adjusted.toISOString());
418
418
+
}
419
419
+
}
420
420
+
});
421
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
578
+
579
579
+
let showDeleteConfirm = $state(false);
580
580
+
let deleting = $state(false);
581
581
+
582
582
+
async function handleDelete() {
583
583
+
deleting = true;
584
584
+
try {
585
585
+
await deleteRecord({
586
586
+
collection: 'community.lexicon.calendar.event',
587
587
+
rkey
588
588
+
});
589
589
+
localStorage.removeItem(DRAFT_KEY);
590
590
+
if (thumbnailKey) deleteImage(thumbnailKey);
591
591
+
const handle =
592
592
+
user.profile?.handle && user.profile.handle !== 'handle.invalid'
593
593
+
? user.profile.handle
594
594
+
: user.did;
595
595
+
goto(`/${handle}/events`);
596
596
+
} catch (e) {
597
597
+
console.error('Failed to delete event:', e);
598
598
+
error = 'Failed to delete event. Please try again.';
599
599
+
} finally {
600
600
+
deleting = false;
601
601
+
showDeleteConfirm = false;
602
602
+
}
603
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
592
-
<div class="mb-6 flex items-center gap-3">
593
593
-
<Badge size="sm">{isNew ? 'Local draft' : 'Local edit'}</Badge>
594
594
-
{#if hasDraft}
595
595
-
<button
596
596
-
type="button"
597
597
-
onclick={deleteDraft}
598
598
-
class="text-base-500 dark:text-base-400 cursor-pointer text-xs hover:text-red-500 hover:underline"
599
599
-
>
600
600
-
{isNew ? 'Delete draft' : 'Discard changes'}
601
601
-
</button>
602
602
-
{/if}
603
603
-
</div>
604
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
675
-
<button
676
676
-
type="button"
697
697
+
<Button
698
698
+
variant="ghost"
699
699
+
size="iconSm"
677
700
onclick={removeThumbnail}
678
678
-
aria-label="Remove thumbnail"
679
679
-
class="bg-base-900/70 absolute top-2 right-2 flex size-7 items-center justify-center rounded-full text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600"
701
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
685
-
class="size-4"
707
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
691
-
</button>
713
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
698
-
<!-- Name -->
699
699
-
<input
700
700
-
type="text"
701
701
-
bind:value={name}
702
702
-
required
703
703
-
placeholder="Event name"
704
704
-
class="text-base-900 dark:text-base-50 placeholder:text-base-500 dark:placeholder:text-base-500 mb-2 w-full border-0 bg-transparent text-4xl leading-tight font-bold focus:border-0 focus:ring-0 focus:outline-none sm:text-5xl"
705
705
-
/>
720
720
+
<!-- Name + Save button -->
721
721
+
<div class="mb-2 flex items-start justify-between gap-4">
722
722
+
<textarea
723
723
+
bind:this={titleEl}
724
724
+
bind:value={name}
725
725
+
required
726
726
+
placeholder="Event name"
727
727
+
rows={1}
728
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
729
+
style="field-sizing: content;"
730
730
+
></textarea>
731
731
+
<Button
732
732
+
type="submit"
733
733
+
size="sm"
734
734
+
class="shrink-0"
735
735
+
disabled={submitting || !name.trim() || !startsAt}
736
736
+
>
737
737
+
{submitting ? (isNew ? 'Creating...' : 'Saving...') : isNew ? 'Create' : 'Save'}
738
738
+
</Button>
739
739
+
</div>
706
740
707
741
<!-- Mode toggle -->
708
742
<div class="mb-8">
···
725
759
</div>
726
760
727
761
<!-- Date row -->
728
728
-
<div class="mb-4 flex items-center gap-4">
762
762
+
<div class="mb-4 flex items-start gap-4">
729
763
<div
730
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
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
748
-
class="text-base-400 dark:text-base-500 size-5"
782
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
759
-
{#if startDate}
760
760
-
<p class="text-base-900 dark:text-base-50 font-semibold">
761
761
-
{formatWeekday(startDate)}, {formatFullDate(startDate)}
762
762
-
{#if endDate && !isSameDay}
763
763
-
- {formatWeekday(endDate)}, {formatFullDate(endDate)}
793
793
+
{#if startDate && !editingDates}
794
794
+
<!-- Display mode: show formatted date, click to edit -->
795
795
+
<div class="flex items-start gap-2">
796
796
+
<button
797
797
+
type="button"
798
798
+
onclick={() => (editingDates = true)}
799
799
+
class="cursor-pointer text-left"
800
800
+
>
801
801
+
<p class="text-base-900 dark:text-base-50 font-semibold">
802
802
+
{formatWeekday(startDate)}, {formatFullDate(startDate)}
803
803
+
{#if endDate && !isSameDay}
804
804
+
- {formatWeekday(endDate)}, {formatFullDate(endDate)}
805
805
+
{/if}
806
806
+
</p>
807
807
+
<p class="text-base-500 dark:text-base-400 text-sm">
808
808
+
{formatTime(startDate)}
809
809
+
{#if endDate && isSameDay}
810
810
+
- {formatTime(endDate)}
811
811
+
{/if}
812
812
+
</p>
813
813
+
</button>
814
814
+
<Button variant="ghost" size="iconSm" onclick={() => (editingDates = true)}>
815
815
+
<svg
816
816
+
xmlns="http://www.w3.org/2000/svg"
817
817
+
fill="none"
818
818
+
viewBox="0 0 24 24"
819
819
+
stroke-width="1.5"
820
820
+
stroke="currentColor"
821
821
+
class="size-3.5"
822
822
+
>
823
823
+
<path
824
824
+
stroke-linecap="round"
825
825
+
stroke-linejoin="round"
826
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
827
+
/>
828
828
+
</svg>
829
829
+
</Button>
830
830
+
</div>
831
831
+
{:else}
832
832
+
<!-- Edit mode: show pickers -->
833
833
+
<div class="flex flex-col gap-2">
834
834
+
<div class="flex items-center gap-2">
835
835
+
{#if endsAt}
836
836
+
<span class="text-base-500 dark:text-base-400 w-9 text-xs">Start</span>
837
837
+
{/if}
838
838
+
<DateTimePicker bind:value={startsAt} required />
839
839
+
</div>
840
840
+
{#if endsAt}
841
841
+
<div class="flex items-center gap-2">
842
842
+
<span class="text-base-500 dark:text-base-400 w-9 text-xs">End</span>
843
843
+
<DateTimePicker bind:value={endsAt} minValue={startsAt} />
844
844
+
<Button variant="ghost" size="iconSm" onclick={() => (endsAt = '')}>
845
845
+
<svg
846
846
+
xmlns="http://www.w3.org/2000/svg"
847
847
+
fill="none"
848
848
+
viewBox="0 0 24 24"
849
849
+
stroke-width="1.5"
850
850
+
stroke="currentColor"
851
851
+
class="size-3.5"
852
852
+
>
853
853
+
<path
854
854
+
stroke-linecap="round"
855
855
+
stroke-linejoin="round"
856
856
+
d="M6 18 18 6M6 6l12 12"
857
857
+
/>
858
858
+
</svg>
859
859
+
</Button>
860
860
+
</div>
861
861
+
{:else}
862
862
+
<Button
863
863
+
variant="ghost"
864
864
+
size="sm"
865
865
+
class="w-fit"
866
866
+
onclick={() => {
867
867
+
if (startsAt) {
868
868
+
const d = new Date(startsAt);
869
869
+
d.setHours(d.getHours() + 1);
870
870
+
endsAt = isoToDatetimeLocal(d.toISOString());
871
871
+
} else {
872
872
+
endsAt = '';
873
873
+
}
874
874
+
}}
875
875
+
>
876
876
+
<svg
877
877
+
xmlns="http://www.w3.org/2000/svg"
878
878
+
fill="none"
879
879
+
viewBox="0 0 24 24"
880
880
+
stroke-width="1.5"
881
881
+
stroke="currentColor"
882
882
+
class="size-3.5"
883
883
+
>
884
884
+
<path
885
885
+
stroke-linecap="round"
886
886
+
stroke-linejoin="round"
887
887
+
d="M12 4.5v15m7.5-7.5h-15"
888
888
+
/>
889
889
+
</svg>
890
890
+
Add end date
891
891
+
</Button>
764
892
{/if}
765
765
-
</p>
766
766
-
<p class="text-base-500 dark:text-base-400 text-sm">
767
767
-
{formatTime(startDate)}
768
768
-
{#if endDate && isSameDay}
769
769
-
- {formatTime(endDate)}
893
893
+
{#if startDate}
894
894
+
<Button size="sm" onclick={() => (editingDates = false)} class="mt-1 w-fit">
895
895
+
<svg
896
896
+
xmlns="http://www.w3.org/2000/svg"
897
897
+
fill="none"
898
898
+
viewBox="0 0 24 24"
899
899
+
stroke-width="2"
900
900
+
stroke="currentColor"
901
901
+
class="size-3.5"
902
902
+
>
903
903
+
<path
904
904
+
stroke-linecap="round"
905
905
+
stroke-linejoin="round"
906
906
+
d="m4.5 12.75 6 6 9-13.5"
907
907
+
/>
908
908
+
</svg>
909
909
+
Done
910
910
+
</Button>
770
911
{/if}
771
771
-
</p>
912
912
+
</div>
772
913
{/if}
773
773
-
<div class="mt-1 flex flex-wrap gap-3">
774
774
-
<label class="flex items-center gap-1.5">
775
775
-
<span class="text-base-500 dark:text-base-400 text-xs">Start</span>
776
776
-
<input
777
777
-
type="datetime-local"
778
778
-
bind:value={startsAt}
779
779
-
required
780
780
-
class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none"
781
781
-
/>
782
782
-
</label>
783
783
-
<label class="flex items-center gap-1.5">
784
784
-
<span class="text-base-500 dark:text-base-400 text-xs">End</span>
785
785
-
<input
786
786
-
type="datetime-local"
787
787
-
bind:value={endsAt}
788
788
-
class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none"
789
789
-
/>
790
790
-
</label>
791
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
799
-
class="border-base-200 dark:border-base-700 flex size-12 shrink-0 items-center justify-center rounded-xl border"
921
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
824
-
<button
825
825
-
type="button"
826
826
-
onclick={removeLocation}
827
827
-
class="text-base-400 shrink-0 hover:text-red-500"
828
828
-
aria-label="Remove location"
829
829
-
>
946
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
834
-
class="size-4"
951
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
840
-
</button>
957
957
+
</Button>
841
958
</div>
842
959
{:else}
843
843
-
<button
844
844
-
type="button"
845
845
-
onclick={() => (showLocationModal = true)}
846
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
847
-
>
848
848
-
<div
849
849
-
class="border-base-200 dark:border-base-700 flex size-12 shrink-0 items-center justify-center rounded-xl border"
850
850
-
>
960
960
+
<div class="mb-6">
961
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
857
-
class="size-5"
968
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
870
-
</div>
871
871
-
<span class="text-sm">Add location</span>
872
872
-
</button>
981
981
+
Add location
982
982
+
</Button>
983
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
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
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
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
894
-
<Button type="submit" disabled={submitting}>
1006
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
947
-
<button
948
948
-
type="button"
1059
1059
+
<Button
1060
1060
+
variant="ghost"
1061
1061
+
size="iconSm"
949
1062
onclick={() => removeLink(i)}
950
950
-
class="text-base-400 ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-500"
951
951
-
aria-label="Remove link"
1063
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
963
-
</button>
1075
1075
+
</Button>
964
1076
</div>
965
1077
{/each}
966
1078
</div>
967
1079
968
968
-
<div class="relative mt-3">
969
969
-
<button
970
970
-
type="button"
971
971
-
onclick={() => (showLinkPopup = !showLinkPopup)}
972
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
973
-
>
974
974
-
<svg
975
975
-
xmlns="http://www.w3.org/2000/svg"
976
976
-
fill="none"
977
977
-
viewBox="0 0 24 24"
978
978
-
stroke-width="1.5"
979
979
-
stroke="currentColor"
980
980
-
class="size-4"
981
981
-
>
982
982
-
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
983
983
-
</svg>
984
984
-
Add link
985
985
-
</button>
1080
1080
+
<div class="mt-3">
1081
1081
+
<PopoverRoot bind:open={showLinkPopup}>
1082
1082
+
<PopoverTrigger>
1083
1083
+
<Button size="sm">
1084
1084
+
<svg
1085
1085
+
xmlns="http://www.w3.org/2000/svg"
1086
1086
+
fill="none"
1087
1087
+
viewBox="0 0 24 24"
1088
1088
+
stroke-width="1.5"
1089
1089
+
stroke="currentColor"
1090
1090
+
class="size-4"
1091
1091
+
>
1092
1092
+
<path
1093
1093
+
stroke-linecap="round"
1094
1094
+
stroke-linejoin="round"
1095
1095
+
d="M12 4.5v15m7.5-7.5h-15"
1096
1096
+
/>
1097
1097
+
</svg>
986
1098
987
987
-
{#if showLinkPopup}
988
988
-
<div
989
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
990
-
>
991
991
-
<input
1099
1099
+
Add link
1100
1100
+
</Button>
1101
1101
+
</PopoverTrigger>
1102
1102
+
<PopoverContent side="bottom" sideOffset={8} class="w-64 p-3">
1103
1103
+
<Input
992
1104
type="url"
993
1105
bind:value={newLinkUri}
994
1106
placeholder="https://..."
995
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
1107
+
variant="secondary"
1108
1108
+
class="mb-2"
1109
1109
+
onkeydown={(e) => {
1110
1110
+
if (e.key === 'Enter') {
1111
1111
+
e.preventDefault();
1112
1112
+
addLink();
1113
1113
+
}
1114
1114
+
}}
996
1115
/>
997
997
-
<input
1116
1116
+
<Input
998
1117
type="text"
999
1118
bind:value={newLinkName}
1000
1119
placeholder="Label (optional)"
1001
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
1120
+
variant="secondary"
1121
1121
+
class="mb-2"
1122
1122
+
onkeydown={(e) => {
1123
1123
+
if (e.key === 'Enter') {
1124
1124
+
e.preventDefault();
1125
1125
+
addLink();
1126
1126
+
}
1127
1127
+
}}
1002
1128
/>
1129
1129
+
{#if linkError}
1130
1130
+
<p class="mb-2 text-xs text-red-500">{linkError}</p>
1131
1131
+
{/if}
1003
1132
<div class="flex justify-end gap-2">
1004
1004
-
<button
1005
1005
-
type="button"
1006
1006
-
onclick={() => (showLinkPopup = false)}
1007
1007
-
class="text-base-500 dark:text-base-400 text-xs hover:underline"
1133
1133
+
<Button
1134
1134
+
variant="ghost"
1135
1135
+
size="sm"
1136
1136
+
onclick={() => {
1137
1137
+
showLinkPopup = false;
1138
1138
+
linkError = '';
1139
1139
+
newLinkUri = '';
1140
1140
+
newLinkName = '';
1141
1141
+
}}
1008
1142
>
1009
1143
Cancel
1010
1010
-
</button>
1011
1011
-
<button
1012
1012
-
type="button"
1013
1013
-
onclick={addLink}
1014
1014
-
disabled={!newLinkUri.trim()}
1015
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
1016
-
>
1017
1017
-
Add
1018
1018
-
</button>
1144
1144
+
</Button>
1145
1145
+
<Button onclick={addLink} size="sm" disabled={!newLinkUri.trim()}>Add</Button>
1019
1146
</div>
1020
1020
-
</div>
1021
1021
-
{/if}
1147
1147
+
</PopoverContent>
1148
1148
+
</PopoverRoot>
1022
1149
</div>
1023
1150
</div>
1024
1151
</div>
1152
1152
+
1153
1153
+
{#if !isNew}
1154
1154
+
<div class="border-base-200 dark:border-base-800 mt-12 border-t pt-8">
1155
1155
+
{#if showDeleteConfirm}
1156
1156
+
<div class="flex items-center gap-3">
1157
1157
+
<p class="text-sm text-red-600 dark:text-red-400">
1158
1158
+
Are you sure? This cannot be undone.
1159
1159
+
</p>
1160
1160
+
<Button
1161
1161
+
variant="secondary"
1162
1162
+
size="sm"
1163
1163
+
onclick={() => (showDeleteConfirm = false)}
1164
1164
+
disabled={deleting}
1165
1165
+
>
1166
1166
+
Cancel
1167
1167
+
</Button>
1168
1168
+
<Button
1169
1169
+
size="sm"
1170
1170
+
onclick={handleDelete}
1171
1171
+
disabled={deleting}
1172
1172
+
variant="red"
1173
1173
+
>
1174
1174
+
{deleting ? 'Deleting...' : 'Delete'}
1175
1175
+
</Button>
1176
1176
+
</div>
1177
1177
+
{:else}
1178
1178
+
<Button
1179
1179
+
variant="red"
1180
1180
+
onclick={() => (showDeleteConfirm = true)}
1181
1181
+
>
1182
1182
+
Delete event
1183
1183
+
</Button>
1184
1184
+
{/if}
1185
1185
+
</div>
1186
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
1041
-
<input
1042
1042
-
type="text"
1043
1043
-
bind:value={locationSearch}
1044
1044
-
placeholder="Search for a city or address..."
1045
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
1046
-
/>
1203
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>