@axium/calendar 0.4.8 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db.json +10 -0
- package/dist/common.d.ts +16 -1
- package/dist/common.js +12 -1
- package/dist/server/index.js +41 -9
- package/locales/en.json +28 -2
- package/package.json +1 -1
- package/routes/calendar/+page.svelte +43 -41
- package/routes/calendar/RecurrenceDialog.svelte +201 -0
- package/routes/calendar/RecurrenceSelect.svelte +58 -0
- package/routes/calendar/cal.css +13 -4
package/db.json
CHANGED
|
@@ -107,6 +107,16 @@
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"delta": true,
|
|
113
|
+
"alter_tables": {
|
|
114
|
+
"calendars": {
|
|
115
|
+
"add_columns": {
|
|
116
|
+
"isDefault": { "type": "boolean", "required": true, "default": false }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
110
120
|
}
|
|
111
121
|
],
|
|
112
122
|
"wipe": ["calendars", "events", "acl.calendars"],
|
package/dist/common.d.ts
CHANGED
|
@@ -118,12 +118,14 @@ export declare function formatEventTimes(event: Event): string;
|
|
|
118
118
|
export declare const CalendarInit: z.ZodObject<{
|
|
119
119
|
name: z.ZodString;
|
|
120
120
|
color: any;
|
|
121
|
+
isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
|
121
122
|
}, z.core.$strip>;
|
|
122
123
|
export interface CalendarInit extends z.infer<typeof CalendarInit> {
|
|
123
124
|
}
|
|
124
125
|
export declare const Calendar: z.ZodObject<{
|
|
125
126
|
name: z.ZodString;
|
|
126
127
|
color: any;
|
|
128
|
+
isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
|
127
129
|
id: z.ZodUUID;
|
|
128
130
|
userId: z.ZodUUID;
|
|
129
131
|
created: z.ZodCoercedDate<unknown>;
|
|
@@ -162,9 +164,11 @@ declare const CalendarAPI: {
|
|
|
162
164
|
readonly PUT: readonly [z.ZodObject<{
|
|
163
165
|
name: z.ZodString;
|
|
164
166
|
color: any;
|
|
167
|
+
isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
|
165
168
|
}, z.core.$strip>, z.ZodObject<{
|
|
166
169
|
name: z.ZodString;
|
|
167
170
|
color: any;
|
|
171
|
+
isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
|
168
172
|
id: z.ZodUUID;
|
|
169
173
|
userId: z.ZodUUID;
|
|
170
174
|
created: z.ZodCoercedDate<unknown>;
|
|
@@ -193,6 +197,7 @@ declare const CalendarAPI: {
|
|
|
193
197
|
readonly GET: z.ZodArray<z.ZodObject<{
|
|
194
198
|
name: z.ZodString;
|
|
195
199
|
color: any;
|
|
200
|
+
isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
|
196
201
|
id: z.ZodUUID;
|
|
197
202
|
userId: z.ZodUUID;
|
|
198
203
|
created: z.ZodCoercedDate<unknown>;
|
|
@@ -223,6 +228,7 @@ declare const CalendarAPI: {
|
|
|
223
228
|
readonly GET: z.ZodObject<{
|
|
224
229
|
name: z.ZodString;
|
|
225
230
|
color: any;
|
|
231
|
+
isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
|
226
232
|
id: z.ZodUUID;
|
|
227
233
|
userId: z.ZodUUID;
|
|
228
234
|
created: z.ZodCoercedDate<unknown>;
|
|
@@ -251,9 +257,11 @@ declare const CalendarAPI: {
|
|
|
251
257
|
readonly PATCH: readonly [z.ZodObject<{
|
|
252
258
|
name: z.ZodString;
|
|
253
259
|
color: any;
|
|
260
|
+
isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
|
254
261
|
}, z.core.$strip>, z.ZodObject<{
|
|
255
262
|
name: z.ZodString;
|
|
256
263
|
color: any;
|
|
264
|
+
isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
|
257
265
|
id: z.ZodUUID;
|
|
258
266
|
userId: z.ZodUUID;
|
|
259
267
|
created: z.ZodCoercedDate<unknown>;
|
|
@@ -282,6 +290,7 @@ declare const CalendarAPI: {
|
|
|
282
290
|
readonly DELETE: z.ZodObject<{
|
|
283
291
|
name: z.ZodString;
|
|
284
292
|
color: any;
|
|
293
|
+
isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
|
285
294
|
id: z.ZodUUID;
|
|
286
295
|
userId: z.ZodUUID;
|
|
287
296
|
created: z.ZodCoercedDate<unknown>;
|
|
@@ -478,8 +487,14 @@ declare module '@axium/core/api' {
|
|
|
478
487
|
* Convert a `Date` to an iCalendar datetime
|
|
479
488
|
*/
|
|
480
489
|
export declare function toDateTime(date: Date): string;
|
|
490
|
+
export type ByDay = 'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA';
|
|
481
491
|
/** e.g. `FR`, `SA` */
|
|
482
|
-
export declare function toByDay(date: Date):
|
|
492
|
+
export declare function toByDay(date: Date): ByDay;
|
|
493
|
+
export declare const weekdayInfo: {
|
|
494
|
+
narrow: string;
|
|
495
|
+
short: string;
|
|
496
|
+
rrule: ByDay;
|
|
497
|
+
}[];
|
|
483
498
|
export declare function eventToICS(event: Event): string;
|
|
484
499
|
export declare function eventFromICS(ics: string): Event;
|
|
485
500
|
export {};
|
package/dist/common.js
CHANGED
|
@@ -106,7 +106,6 @@ export const EventData = z.object({
|
|
|
106
106
|
isAllDay: z.coerce.boolean(),
|
|
107
107
|
description: z.string().max(2000).nullish(),
|
|
108
108
|
color: Color.nullish(),
|
|
109
|
-
// note: recurrences are not support yet
|
|
110
109
|
recurrence: z.string().max(1000).nullish(),
|
|
111
110
|
recurrenceExcludes: z.string().max(100).array().max(100).nullish(),
|
|
112
111
|
recurrenceId: z.uuid().nullish(),
|
|
@@ -132,6 +131,7 @@ export function formatEventTimes(event) {
|
|
|
132
131
|
export const CalendarInit = z.object({
|
|
133
132
|
name: z.string(),
|
|
134
133
|
color: Color.nullish(),
|
|
134
|
+
isDefault: z.coerce.boolean().optional(),
|
|
135
135
|
});
|
|
136
136
|
export const Calendar = CalendarInit.extend({
|
|
137
137
|
id: z.uuid(),
|
|
@@ -191,6 +191,17 @@ export function toDateTime(date) {
|
|
|
191
191
|
export function toByDay(date) {
|
|
192
192
|
return date.toLocaleString('en', { weekday: 'short' }).slice(0, 2).toUpperCase();
|
|
193
193
|
}
|
|
194
|
+
// Generate weekday labels/names via Intl — Sunday-anchored (getDay() === 0)
|
|
195
|
+
const sunday = new Date(2000, 0, 2); // a known Sunday
|
|
196
|
+
export const weekdayInfo = Array.from({ length: 7 }, (_, i) => {
|
|
197
|
+
const d = new Date(sunday);
|
|
198
|
+
d.setDate(sunday.getDate() + i);
|
|
199
|
+
return {
|
|
200
|
+
narrow: d.toLocaleString('default', { weekday: 'narrow' }),
|
|
201
|
+
short: d.toLocaleString('default', { weekday: 'short' }),
|
|
202
|
+
rrule: toByDay(d),
|
|
203
|
+
};
|
|
204
|
+
});
|
|
194
205
|
export function eventToICS(event) {
|
|
195
206
|
const lines = [
|
|
196
207
|
'BEGIN:VCALENDAR',
|
package/dist/server/index.js
CHANGED
|
@@ -40,9 +40,17 @@ addRoute({
|
|
|
40
40
|
async PUT(request, { id: userId }) {
|
|
41
41
|
const init = await parseBody(request, CalendarInit);
|
|
42
42
|
await checkAuthForUser(request, userId);
|
|
43
|
+
// A user's first calendar becomes their default so a default always exists.
|
|
44
|
+
const existing = await database
|
|
45
|
+
.selectFrom('calendars')
|
|
46
|
+
.select('id')
|
|
47
|
+
.where('userId', '=', userId)
|
|
48
|
+
.limit(1)
|
|
49
|
+
.executeTakeFirst()
|
|
50
|
+
.catch(withError('Could not create calendar'));
|
|
43
51
|
return withEncoded(await database
|
|
44
52
|
.insertInto('calendars')
|
|
45
|
-
.values({ ...withDecoded(init), userId })
|
|
53
|
+
.values({ ...withDecoded(init), userId, isDefault: !existing })
|
|
46
54
|
.returningAll()
|
|
47
55
|
.executeTakeFirstOrThrow()
|
|
48
56
|
.catch(withError('Could not create calendar')));
|
|
@@ -57,14 +65,38 @@ addRoute({
|
|
|
57
65
|
},
|
|
58
66
|
async PATCH(request, { id }) {
|
|
59
67
|
const body = await parseBody(request, CalendarInit);
|
|
60
|
-
await authRequestForItem(request, 'calendars', id, { edit: true });
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
const { item } = await authRequestForItem(request, 'calendars', id, { edit: true });
|
|
69
|
+
if (!body.isDefault) {
|
|
70
|
+
return withEncoded(await database
|
|
71
|
+
.updateTable('calendars')
|
|
72
|
+
.set(withDecoded(body))
|
|
73
|
+
.where('id', '=', id)
|
|
74
|
+
.returningAll()
|
|
75
|
+
.executeTakeFirstOrThrow()
|
|
76
|
+
.catch(withError('Could not update calendar')));
|
|
77
|
+
}
|
|
78
|
+
const tx = await database.startTransaction().execute();
|
|
79
|
+
try {
|
|
80
|
+
// A calendar being made default must be the only default for its owner, so clear the others first.
|
|
81
|
+
await tx
|
|
82
|
+
.updateTable('calendars')
|
|
83
|
+
.set({ isDefault: false })
|
|
84
|
+
.where('userId', '=', item.userId)
|
|
85
|
+
.where('isDefault', '=', true)
|
|
86
|
+
.execute();
|
|
87
|
+
const calendar = await tx
|
|
88
|
+
.updateTable('calendars')
|
|
89
|
+
.set(withDecoded(body))
|
|
90
|
+
.where('id', '=', id)
|
|
91
|
+
.returningAll()
|
|
92
|
+
.executeTakeFirstOrThrow();
|
|
93
|
+
await tx.commit().execute();
|
|
94
|
+
return withEncoded(calendar);
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
await tx.rollback().execute();
|
|
98
|
+
throw withError('Could not update calendar')(e);
|
|
99
|
+
}
|
|
68
100
|
},
|
|
69
101
|
async DELETE(request, { id }) {
|
|
70
102
|
await authRequestForItem(request, 'calendars', id, { manage: true });
|
package/locales/en.json
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
"list_owned": "My Calendars",
|
|
14
14
|
"list_shared": "Shared Calendars",
|
|
15
15
|
"delete_confirm": "Are you sure you want to delete the calendar \"{name}\"?",
|
|
16
|
-
"edit": "Edit"
|
|
16
|
+
"edit": "Edit",
|
|
17
|
+
"make_default": "Make default",
|
|
18
|
+
"default": "(default)"
|
|
17
19
|
},
|
|
18
20
|
"calendar_init": {
|
|
19
21
|
"name": "Name",
|
|
@@ -23,6 +25,29 @@
|
|
|
23
25
|
"event_delete": {
|
|
24
26
|
"confirm": "Are you sure you want to delete this event?"
|
|
25
27
|
},
|
|
28
|
+
"RecurrenceDialog": {
|
|
29
|
+
"title": "Custom recurrence",
|
|
30
|
+
"confirm": "Done",
|
|
31
|
+
"repeat_every": "Repeat every",
|
|
32
|
+
"on": "On",
|
|
33
|
+
"freq": {
|
|
34
|
+
"day": "day",
|
|
35
|
+
"week": "week",
|
|
36
|
+
"month": "month",
|
|
37
|
+
"year": "year"
|
|
38
|
+
},
|
|
39
|
+
"monthly": {
|
|
40
|
+
"on_day": "On the {day}",
|
|
41
|
+
"on_weekday": "On the {day}"
|
|
42
|
+
},
|
|
43
|
+
"end": {
|
|
44
|
+
"label": "End",
|
|
45
|
+
"never": "Never",
|
|
46
|
+
"on": "On",
|
|
47
|
+
"after": "After",
|
|
48
|
+
"occurrences": "occurrences"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
26
51
|
"event_init": {
|
|
27
52
|
"all_day": "All day",
|
|
28
53
|
"description_placeholder": "Add description",
|
|
@@ -32,7 +57,8 @@
|
|
|
32
57
|
"monthly_on": "Every month on the {day}",
|
|
33
58
|
"none": "Does not repeat",
|
|
34
59
|
"weekly": "Every week on {day}",
|
|
35
|
-
"yearly": "Every year on {date}"
|
|
60
|
+
"yearly": "Every year on {date}",
|
|
61
|
+
"custom": "Custom..."
|
|
36
62
|
},
|
|
37
63
|
"submit_edit": "Update",
|
|
38
64
|
"title_placeholder": "Add title",
|
package/package.json
CHANGED
|
@@ -1,30 +1,20 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { getEvents, type EventInitFormData, type EventInitProp } from '@axium/calendar/client';
|
|
3
|
-
import type { Event } from '@axium/calendar/common';
|
|
4
|
-
import {
|
|
5
|
-
CalendarInit,
|
|
6
|
-
dateToInputValue,
|
|
7
|
-
fromRRuleDate,
|
|
8
|
-
getCalPermissionsInfo,
|
|
9
|
-
longWeekDay,
|
|
10
|
-
toByDay,
|
|
11
|
-
toRRuleDate,
|
|
12
|
-
weekDayOfMonth,
|
|
13
|
-
weekDaysFor,
|
|
14
|
-
withOrdinalSuffix,
|
|
15
|
-
} from '@axium/calendar/common';
|
|
3
|
+
import type { Calendar, Event } from '@axium/calendar/common';
|
|
4
|
+
import { CalendarInit, dateToInputValue, fromRRuleDate, getCalPermissionsInfo, toRRuleDate, weekDaysFor } from '@axium/calendar/common';
|
|
16
5
|
import * as Cal from '@axium/calendar/components';
|
|
17
6
|
import { fetchAPI, text } from '@axium/client';
|
|
18
7
|
import { contextMenu, dynamicRows } from '@axium/client/attachments';
|
|
19
8
|
import { AccessControlDialog, ColorPicker, Discovery, discovery, FormDialog, Icon, Popover } from '@axium/client/components';
|
|
20
9
|
import { toast } from '@axium/client/toast';
|
|
21
|
-
import { colorHashHex, encodeColor } from '@axium/core/color';
|
|
10
|
+
import { colorHashHex, decodeColor, encodeColor } from '@axium/core/color';
|
|
22
11
|
import { rrulestr } from 'rrule';
|
|
23
12
|
import { useSwipe } from 'svelte-gestures';
|
|
24
|
-
import { SvelteDate } from 'svelte/reactivity';
|
|
13
|
+
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
|
25
14
|
import { _throw } from 'utilium';
|
|
26
15
|
import * as z from 'zod';
|
|
27
16
|
import './cal.css';
|
|
17
|
+
import RecurrenceSelect from './RecurrenceSelect.svelte';
|
|
28
18
|
|
|
29
19
|
const { data } = $props();
|
|
30
20
|
|
|
@@ -55,13 +45,16 @@
|
|
|
55
45
|
const spanDays = $derived(span == 'week' ? 7 : _throw('Invalid span value'));
|
|
56
46
|
const weekDays = $derived(weekDaysFor(start));
|
|
57
47
|
|
|
58
|
-
let dialogs = $state<Record<string, HTMLDialogElement>>({})
|
|
48
|
+
let dialogs = $state<Record<string, HTMLDialogElement>>({}),
|
|
49
|
+
hiddenCalIds = new SvelteSet<string>();
|
|
50
|
+
|
|
51
|
+
const defaultCalendar = $derived(calendars.find(cal => cal.userId == user.id && cal.isDefault) ?? calendars[0]);
|
|
59
52
|
|
|
60
53
|
const defaultEventInit = $derived<any>({
|
|
61
54
|
attendees: [],
|
|
62
55
|
recurrenceExcludes: [],
|
|
63
56
|
recurrenceId: null,
|
|
64
|
-
calId:
|
|
57
|
+
calId: defaultCalendar?.id,
|
|
65
58
|
start: new Date(defaultStart),
|
|
66
59
|
end: new Date(defaultStart.getTime() + 3600_000),
|
|
67
60
|
});
|
|
@@ -81,7 +74,7 @@
|
|
|
81
74
|
|
|
82
75
|
const recurringEvents = $derived(
|
|
83
76
|
events
|
|
84
|
-
.filter(ev => ev.recurrence)
|
|
77
|
+
.filter(ev => ev.recurrence && !hiddenCalIds.has(ev.calId))
|
|
85
78
|
.map(ev => {
|
|
86
79
|
const rule = rrulestr('RRULE:' + ev.recurrence, { dtstart: toRRuleDate(ev.start) });
|
|
87
80
|
const recurrences = rule
|
|
@@ -92,7 +85,13 @@
|
|
|
92
85
|
);
|
|
93
86
|
|
|
94
87
|
const defaultCalColor = encodeColor(colorHashHex(user.name));
|
|
95
|
-
const defaultEventColor = $derived((eventInit.calendar ||
|
|
88
|
+
const defaultEventColor = $derived((eventInit.calendar || defaultCalendar)?.color || defaultCalColor);
|
|
89
|
+
|
|
90
|
+
async function makeDefault(cal: Calendar) {
|
|
91
|
+
const result = await fetchAPI('PATCH', 'calendars/:id', { ...cal, isDefault: true }, cal.id);
|
|
92
|
+
for (const other of calendars) other.isDefault = other.userId == user.id && other.id == cal.id;
|
|
93
|
+
Object.assign(cal, result);
|
|
94
|
+
}
|
|
96
95
|
|
|
97
96
|
let calSidebar = $state<HTMLDivElement>();
|
|
98
97
|
</script>
|
|
@@ -156,11 +155,27 @@
|
|
|
156
155
|
class="cal-sidebar-item"
|
|
157
156
|
{@attach contextMenu(
|
|
158
157
|
{ i: 'pencil', text: text('calendar.edit'), action: edit },
|
|
158
|
+
...(cal.isDefault ? [] : [{ i: 'star', text: text('calendar.make_default'), action: () => makeDefault(cal) }]),
|
|
159
159
|
{ i: 'user-group', text: text('generic.share'), action: () => dialogs['share:' + cal.id].showModal() },
|
|
160
160
|
{ i: 'trash', text: text('generic.delete'), action: () => dialogs['delete:' + cal.id].showModal() }
|
|
161
161
|
)}
|
|
162
162
|
>
|
|
163
|
-
<span
|
|
163
|
+
<span
|
|
164
|
+
class="icon-text"
|
|
165
|
+
role="checkbox"
|
|
166
|
+
aria-checked={!hiddenCalIds.has(cal.id)}
|
|
167
|
+
tabindex="0"
|
|
168
|
+
onclick={() => {
|
|
169
|
+
if (hiddenCalIds.has(cal.id)) hiddenCalIds.delete(cal.id);
|
|
170
|
+
else hiddenCalIds.add(cal.id);
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
<label class="checkbox" style:--cal-color={decodeColor(cal.color || defaultCalColor)}>
|
|
174
|
+
{#if !hiddenCalIds.has(cal.id)}<Icon i="check" --size="1.3em" />{/if}
|
|
175
|
+
</label>
|
|
176
|
+
<span>{cal.name}</span>
|
|
177
|
+
{#if cal.isDefault}<span class="subtle">{text('calendar.default')}</span>{/if}
|
|
178
|
+
</span>
|
|
164
179
|
<Popover showToggle="hover">
|
|
165
180
|
<button
|
|
166
181
|
class="reset menu-item"
|
|
@@ -174,6 +189,12 @@
|
|
|
174
189
|
<Icon i="pencil" />
|
|
175
190
|
<span>{text('calendar.edit')}</span>
|
|
176
191
|
</button>
|
|
192
|
+
{#if !cal.isDefault}
|
|
193
|
+
<div class="menu-item" onclick={() => makeDefault(cal)}>
|
|
194
|
+
<Icon i="star" />
|
|
195
|
+
<span>{text('calendar.make_default')}</span>
|
|
196
|
+
</div>
|
|
197
|
+
{/if}
|
|
177
198
|
<div class="menu-item" onclick={() => dialogs['share:' + cal.id].showModal()}>
|
|
178
199
|
<Icon i="user-group" />
|
|
179
200
|
<span>{text('generic.share')}</span>
|
|
@@ -245,6 +266,7 @@
|
|
|
245
266
|
{@const eventsForWeekDays = Object.groupBy(
|
|
246
267
|
events.filter(
|
|
247
268
|
e =>
|
|
269
|
+
!hiddenCalIds.has(e.calId) &&
|
|
248
270
|
e.start < new Date(weekDays[6].getFullYear(), weekDays[6].getMonth(), weekDays[6].getDate() + 1) &&
|
|
249
271
|
e.end > weekDays[0]
|
|
250
272
|
),
|
|
@@ -314,7 +336,6 @@
|
|
|
314
336
|
<div class="event-times">
|
|
315
337
|
<input
|
|
316
338
|
type="datetime-local"
|
|
317
|
-
name="start"
|
|
318
339
|
id="eventInit.start"
|
|
319
340
|
bind:value={eventInitStart}
|
|
320
341
|
onchange={e => (eventInit.start = new Date(e.currentTarget.value))}
|
|
@@ -322,7 +343,6 @@
|
|
|
322
343
|
/>
|
|
323
344
|
<input
|
|
324
345
|
type="datetime-local"
|
|
325
|
-
name="end"
|
|
326
346
|
id="eventInit.end"
|
|
327
347
|
bind:value={eventInitEnd}
|
|
328
348
|
onchange={e => (eventInit.end = new Date(e.currentTarget.value))}
|
|
@@ -335,25 +355,7 @@
|
|
|
335
355
|
</label>
|
|
336
356
|
<label for="eventInit.isAllDay:checkbox">{text('event_init.all_day')}</label>
|
|
337
357
|
<div class="spacing"></div>
|
|
338
|
-
<
|
|
339
|
-
<option value="">{text('event_init.recurrence.none')}</option>
|
|
340
|
-
<option value="FREQ=DAILY">{text('event_init.recurrence.daily')}</option>
|
|
341
|
-
<option value="FREQ=WEEKLY;BYDAY={toByDay(eventInit.start)}">
|
|
342
|
-
{text('event_init.recurrence.weekly', { day: longWeekDay(eventInit.start) })}
|
|
343
|
-
</option>
|
|
344
|
-
<option value="FREQ=MONTHLY;BYDAY={Math.ceil(eventInit.start.getDate() / 7) + toByDay(eventInit.start)}"
|
|
345
|
-
>{text('event_init.recurrence.monthly_on', { day: weekDayOfMonth(eventInit.start) })}
|
|
346
|
-
</option>
|
|
347
|
-
<option value="FREQ=MONTHLY;BYMONTHDAY={eventInit.start.getDate()}">
|
|
348
|
-
{text('event_init.recurrence.monthly_on', { day: withOrdinalSuffix(eventInit.start.getDate()) })}
|
|
349
|
-
</option>
|
|
350
|
-
<option value="FREQ=YEARLY;BYMONTH={eventInit.start.getMonth()};BYMONTHDAY={eventInit.start.getDate()}">
|
|
351
|
-
{text('event_init.recurrence.yearly', {
|
|
352
|
-
date: eventInit.start.toLocaleDateString('default', { month: 'long', day: 'numeric' }),
|
|
353
|
-
})}
|
|
354
|
-
</option>
|
|
355
|
-
<!-- @todo <option value="">Custom</option> -->
|
|
356
|
-
</select>
|
|
358
|
+
<RecurrenceSelect bind:eventInit />
|
|
357
359
|
</div>
|
|
358
360
|
</div>
|
|
359
361
|
</div>
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { withOrdinalSuffix, toByDay, weekDayOfMonth, weekdayInfo } from '@axium/calendar/common';
|
|
3
|
+
import type { EventInitProp } from '@axium/calendar/client';
|
|
4
|
+
import { text } from '@axium/client';
|
|
5
|
+
import { FormDialog } from '@axium/client/components';
|
|
6
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
dialog = $bindable(),
|
|
10
|
+
eventInit = $bindable(),
|
|
11
|
+
}: {
|
|
12
|
+
dialog?: HTMLDialogElement;
|
|
13
|
+
eventInit: EventInitProp;
|
|
14
|
+
} = $props();
|
|
15
|
+
|
|
16
|
+
let interval = $state(1),
|
|
17
|
+
freq = $state<'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'>('WEEKLY');
|
|
18
|
+
|
|
19
|
+
let byWeekday = $state<SvelteSet<number>>(new SvelteSet());
|
|
20
|
+
$effect(() => {
|
|
21
|
+
byWeekday = new SvelteSet([eventInit.start.getDay()]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
let monthlyMode = $state<'day' | 'weekday'>('day');
|
|
25
|
+
|
|
26
|
+
let endType = $state<'never' | 'on' | 'after'>('never'),
|
|
27
|
+
endDate = $state(''),
|
|
28
|
+
endCount = $state(1);
|
|
29
|
+
|
|
30
|
+
function buildRRule(): string {
|
|
31
|
+
const parts: string[] = [`FREQ=${freq}`, `INTERVAL=${interval}`];
|
|
32
|
+
|
|
33
|
+
if (freq === 'WEEKLY') {
|
|
34
|
+
parts.push(
|
|
35
|
+
`BYDAY=${[...byWeekday]
|
|
36
|
+
.sort()
|
|
37
|
+
.map(i => weekdayInfo[i].rrule)
|
|
38
|
+
.join(',')}`
|
|
39
|
+
);
|
|
40
|
+
} else if (freq === 'MONTHLY') {
|
|
41
|
+
if (monthlyMode === 'weekday') {
|
|
42
|
+
const nth = Math.ceil(eventInit.start.getDate() / 7);
|
|
43
|
+
parts.push(`BYDAY=${nth}${toByDay(eventInit.start)}`);
|
|
44
|
+
} else {
|
|
45
|
+
parts.push(`BYMONTHDAY=${eventInit.start.getDate()}`);
|
|
46
|
+
}
|
|
47
|
+
} else if (freq === 'YEARLY') {
|
|
48
|
+
parts.push(`BYMONTH=${eventInit.start.getMonth() + 1};BYMONTHDAY=${eventInit.start.getDate()}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (endType === 'on' && endDate) {
|
|
52
|
+
const d = new Date(endDate + 'T00:00:00');
|
|
53
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
54
|
+
parts.push(`UNTIL=${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T000000Z`);
|
|
55
|
+
} else if (endType === 'after') {
|
|
56
|
+
parts.push(`COUNT=${endCount}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return parts.join(';');
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<FormDialog
|
|
64
|
+
bind:dialog
|
|
65
|
+
id="recurrence-custom"
|
|
66
|
+
submitText={text('RecurrenceDialog.confirm')}
|
|
67
|
+
submit={async () => (eventInit.recurrence = buildRRule())}
|
|
68
|
+
>
|
|
69
|
+
{#snippet header()}
|
|
70
|
+
<h3>{text('RecurrenceDialog.title')}</h3>
|
|
71
|
+
{/snippet}
|
|
72
|
+
|
|
73
|
+
<div class="row">
|
|
74
|
+
<span>{text('RecurrenceDialog.repeat_every')}</span>
|
|
75
|
+
<input type="number" min="1" max="999" bind:value={interval} />
|
|
76
|
+
<select bind:value={freq}>
|
|
77
|
+
<option value="DAILY">{text('RecurrenceDialog.freq.day', { n: interval })}</option>
|
|
78
|
+
<option value="WEEKLY">{text('RecurrenceDialog.freq.week', { n: interval })}</option>
|
|
79
|
+
<option value="MONTHLY">{text('RecurrenceDialog.freq.month', { n: interval })}</option>
|
|
80
|
+
<option value="YEARLY">{text('RecurrenceDialog.freq.year', { n: interval })}</option>
|
|
81
|
+
</select>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{#if freq === 'WEEKLY'}
|
|
85
|
+
<div class="on-section">
|
|
86
|
+
<span class="subtle">{text('RecurrenceDialog.on')}</span>
|
|
87
|
+
<div class="weekday-toggles">
|
|
88
|
+
{#each weekdayInfo as { narrow, short }, i}
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
class={['weekday', byWeekday.has(i) && 'active']}
|
|
92
|
+
onclick={() => {
|
|
93
|
+
if (!byWeekday.has(i)) byWeekday.add(i);
|
|
94
|
+
else if (byWeekday.size && i !== eventInit.start.getDay()) byWeekday.delete(i);
|
|
95
|
+
}}
|
|
96
|
+
aria-label={short}
|
|
97
|
+
aria-pressed={byWeekday.has(i)}>{narrow}</button
|
|
98
|
+
>
|
|
99
|
+
{/each}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
{:else if freq === 'MONTHLY'}
|
|
103
|
+
<div class="on-section">
|
|
104
|
+
<span class="subtle">{text('RecurrenceDialog.on')}</span>
|
|
105
|
+
<div class="monthly-options">
|
|
106
|
+
<label>
|
|
107
|
+
<input type="radio" bind:group={monthlyMode} value="day" />
|
|
108
|
+
{text('RecurrenceDialog.monthly.on_day', { day: withOrdinalSuffix(eventInit.start.getDate()) })}
|
|
109
|
+
</label>
|
|
110
|
+
<label>
|
|
111
|
+
<input type="radio" bind:group={monthlyMode} value="weekday" />
|
|
112
|
+
{text('RecurrenceDialog.monthly.on_weekday', { day: weekDayOfMonth(eventInit.start) })}
|
|
113
|
+
</label>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
{/if}
|
|
117
|
+
|
|
118
|
+
<div class="end-section">
|
|
119
|
+
<span class="subtle">{text('RecurrenceDialog.end.label')}</span>
|
|
120
|
+
<label>
|
|
121
|
+
<input type="radio" bind:group={endType} value="never" />
|
|
122
|
+
{text('RecurrenceDialog.end.never')}
|
|
123
|
+
</label>
|
|
124
|
+
<label class="end-row">
|
|
125
|
+
<input type="radio" bind:group={endType} value="on" />
|
|
126
|
+
{text('RecurrenceDialog.end.on')}
|
|
127
|
+
<input type="date" bind:value={endDate} disabled={endType !== 'on'} onclick={() => (endType = 'on')} />
|
|
128
|
+
</label>
|
|
129
|
+
<label class="end-row">
|
|
130
|
+
<input type="radio" bind:group={endType} value="after" />
|
|
131
|
+
{text('RecurrenceDialog.end.after')}
|
|
132
|
+
<input
|
|
133
|
+
type="number"
|
|
134
|
+
min="1"
|
|
135
|
+
max="999"
|
|
136
|
+
bind:value={endCount}
|
|
137
|
+
disabled={endType !== 'after'}
|
|
138
|
+
onclick={() => (endType = 'after')}
|
|
139
|
+
/>
|
|
140
|
+
{text('RecurrenceDialog.end.occurrences')}
|
|
141
|
+
</label>
|
|
142
|
+
</div>
|
|
143
|
+
</FormDialog>
|
|
144
|
+
|
|
145
|
+
<style>
|
|
146
|
+
h3 {
|
|
147
|
+
margin: 0 0 0.25em;
|
|
148
|
+
padding: 0.75em 1em 0;
|
|
149
|
+
font-size: 1.1em;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.row {
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
gap: 0.5em;
|
|
156
|
+
|
|
157
|
+
input[type='number'] {
|
|
158
|
+
width: 4em;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.on-section,
|
|
163
|
+
.end-section {
|
|
164
|
+
display: flex;
|
|
165
|
+
flex-direction: column;
|
|
166
|
+
gap: 0.4em;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.weekday-toggles {
|
|
170
|
+
display: flex;
|
|
171
|
+
gap: 0.3em;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.weekday {
|
|
175
|
+
width: 2.25em;
|
|
176
|
+
height: 2.25em;
|
|
177
|
+
border-radius: 50%;
|
|
178
|
+
padding: 0;
|
|
179
|
+
|
|
180
|
+
&.active {
|
|
181
|
+
background-color: var(--bg-strong);
|
|
182
|
+
border-color: var(--border-strong);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.monthly-options {
|
|
187
|
+
display: flex;
|
|
188
|
+
flex-direction: column;
|
|
189
|
+
gap: 0.4em;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.end-row {
|
|
193
|
+
display: flex;
|
|
194
|
+
align-items: center;
|
|
195
|
+
gap: 0.4em;
|
|
196
|
+
|
|
197
|
+
input[type='number'] {
|
|
198
|
+
width: 4em;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
</style>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { EventInitProp } from '@axium/calendar/client';
|
|
3
|
+
import { longWeekDay, toByDay, weekDayOfMonth, withOrdinalSuffix } from '@axium/calendar/common';
|
|
4
|
+
import { text } from '@axium/client';
|
|
5
|
+
import type { Entries } from 'utilium';
|
|
6
|
+
import RecurrenceDialog from './RecurrenceDialog.svelte';
|
|
7
|
+
|
|
8
|
+
let { eventInit = $bindable() }: { eventInit: EventInitProp } = $props();
|
|
9
|
+
|
|
10
|
+
const recurrences = $derived({
|
|
11
|
+
none: '',
|
|
12
|
+
daily: 'FREQ=DAILY',
|
|
13
|
+
weekly: `FREQ=WEEKLY;BYDAY=${toByDay(eventInit.start)}`,
|
|
14
|
+
monthly_by_day: `FREQ=MONTHLY;BYDAY=${Math.ceil(eventInit.start.getDate() / 7) + toByDay(eventInit.start)}`,
|
|
15
|
+
monthly_by_weekday: `FREQ=MONTHLY;BYMONTHDAY=${eventInit.start.getDate()}`,
|
|
16
|
+
yearly: `FREQ=YEARLY;BYMONTH=${eventInit.start.getMonth()};BYMONTHDAY=${eventInit.start.getDate()}`,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const recurrenceKind = $derived(
|
|
20
|
+
!eventInit.recurrence
|
|
21
|
+
? 'none'
|
|
22
|
+
: (Object.entries(recurrences) as Entries<typeof recurrences>).find(([_, v]) => v === eventInit.recurrence)?.[0] || 'custom'
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
let dialog = $state<HTMLDialogElement>();
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<select
|
|
29
|
+
value={recurrenceKind}
|
|
30
|
+
onchange={e => {
|
|
31
|
+
const { value } = e.currentTarget;
|
|
32
|
+
if (value === 'custom')
|
|
33
|
+
e.currentTarget.value = recurrenceKind; // to prevent "Custom..." being shown when dialog cancelled
|
|
34
|
+
else if (value in recurrences) eventInit.recurrence = recurrences[value as keyof typeof recurrences];
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
<option value="none">{text('event_init.recurrence.none')}</option>
|
|
38
|
+
<option value="daily">{text('event_init.recurrence.daily')}</option>
|
|
39
|
+
<option value="weekly">
|
|
40
|
+
{text('event_init.recurrence.weekly', { day: longWeekDay(eventInit.start) })}
|
|
41
|
+
</option>
|
|
42
|
+
<option value="monthly_by_weekday">
|
|
43
|
+
{text('event_init.recurrence.monthly_on', { day: weekDayOfMonth(eventInit.start) })}
|
|
44
|
+
</option>
|
|
45
|
+
<option value="monthly_by_day">
|
|
46
|
+
{text('event_init.recurrence.monthly_on', { day: withOrdinalSuffix(eventInit.start.getDate()) })}
|
|
47
|
+
</option>
|
|
48
|
+
<option value="yearly">
|
|
49
|
+
{text('event_init.recurrence.yearly', {
|
|
50
|
+
date: eventInit.start.toLocaleDateString('default', { month: 'long', day: 'numeric' }),
|
|
51
|
+
})}
|
|
52
|
+
</option>
|
|
53
|
+
<option value="custom" onclick={() => dialog?.showModal()}>
|
|
54
|
+
{text('event_init.recurrence.custom')}
|
|
55
|
+
</option>
|
|
56
|
+
</select>
|
|
57
|
+
|
|
58
|
+
<RecurrenceDialog bind:dialog bind:eventInit />
|
package/routes/calendar/cal.css
CHANGED
|
@@ -65,14 +65,12 @@ div.attendees {
|
|
|
65
65
|
flex: auto;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
label {
|
|
68
|
+
> label {
|
|
69
69
|
flex: none;
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
input,
|
|
74
|
-
select,
|
|
75
|
-
textarea {
|
|
73
|
+
:is(input, select, textarea):not(.recurrence-dialog *) {
|
|
76
74
|
width: 100%;
|
|
77
75
|
}
|
|
78
76
|
|
|
@@ -113,7 +111,18 @@ div.attendees {
|
|
|
113
111
|
.cal-sidebar-item {
|
|
114
112
|
display: flex;
|
|
115
113
|
align-items: center;
|
|
114
|
+
gap: 0.5em;
|
|
116
115
|
justify-content: space-between;
|
|
116
|
+
|
|
117
|
+
label.checkbox {
|
|
118
|
+
border-color: var(--cal-color);
|
|
119
|
+
flex-shrink: 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
& > span {
|
|
123
|
+
flex: 1;
|
|
124
|
+
cursor: pointer;
|
|
125
|
+
}
|
|
117
126
|
}
|
|
118
127
|
}
|
|
119
128
|
|