@axium/calendar 0.1.0

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.
@@ -0,0 +1,610 @@
1
+ <script lang="ts">
2
+ import { getEvents, type EventInitFormData } from '@axium/calendar/client';
3
+ import type { Event, EventData } from '@axium/calendar/common';
4
+ import { AttendeeInit, dateToInputValue, formatEventTimes, getCalPermissionsInfo, weekDaysFor } from '@axium/calendar/common';
5
+ import * as Calendar from '@axium/calendar/components';
6
+ import { contextMenu, dynamicRows } from '@axium/client/attachments';
7
+ import { AccessControlDialog, FormDialog, Icon, Popover, UserDiscovery } from '@axium/client/components';
8
+ import { fetchAPI } from '@axium/client/requests';
9
+ import { SvelteDate } from 'svelte/reactivity';
10
+ import { _throw } from 'utilium';
11
+ import z from 'zod';
12
+ const { data } = $props();
13
+
14
+ const { user } = data.session;
15
+
16
+ const today = new Date();
17
+ today.setHours(0, 0, 0, 0);
18
+
19
+ let start = new SvelteDate(data.filter.start);
20
+ let end = new SvelteDate(data.filter.end);
21
+ let calendars = $state(data.calendars);
22
+ let events = $state<Event[]>([]);
23
+
24
+ $effect(() => {
25
+ getEvents(calendars, { start: new Date(start.getTime()), end: new Date(end.getTime()) }).then(e => (events = e));
26
+ });
27
+
28
+ const tz = new Date().toLocaleString('en', { timeStyle: 'long' }).split(' ').slice(-1)[0];
29
+
30
+ const span = $state('week');
31
+ const spanDays = $derived(span == 'week' ? 7 : _throw('Invalid span value'));
32
+ const weekDays = $derived(weekDaysFor(start));
33
+ const eventsForWeekDays = $derived(
34
+ Object.groupBy(
35
+ events.filter(
36
+ e => e.start < new Date(weekDays[6].getFullYear(), weekDays[6].getMonth(), weekDays[6].getDate() + 1) && e.end > weekDays[0]
37
+ ),
38
+ ev => ev?.start.getDay()
39
+ )
40
+ );
41
+
42
+ let dialogs = $state<Record<string, HTMLDialogElement>>({});
43
+
44
+ let eventInit = $state<EventData & { attendees: AttendeeInit[] }>({
45
+ attendees: [],
46
+ recurrenceExcludes: [],
47
+ recurrenceId: null,
48
+ calId: calendars[0]?.id,
49
+ } as any),
50
+ eventInitStart = $derived(dateToInputValue(eventInit.start)),
51
+ eventInitEnd = $derived(dateToInputValue(eventInit.end)),
52
+ eventEditId = $state<string>(),
53
+ eventEditCalId = $state<string>();
54
+ </script>
55
+
56
+ <svelte:head>
57
+ <title>Calendar</title>
58
+ </svelte:head>
59
+
60
+ <div id="cal-app">
61
+ <button class="event-init icon-text" command="show-modal" commandfor="event-init"><Icon i="plus" /> New Event</button>
62
+ <div class="bar">
63
+ <button onclick={() => start.setTime(today.getTime())}>Today</button>
64
+ <span class="label">{weekDays[0].toLocaleString('default', { month: 'long', year: 'numeric' })}</span>
65
+ <button
66
+ style:display="contents"
67
+ onclick={() => {
68
+ start.setDate(start.getDate() - spanDays);
69
+ end.setDate(end.getDate() - spanDays);
70
+ }}><Icon i="chevron-left" /></button
71
+ >
72
+ <button
73
+ style:display="contents"
74
+ onclick={() => {
75
+ start.setDate(start.getDate() + spanDays);
76
+ end.setDate(end.getDate() + spanDays);
77
+ }}><Icon i="chevron-right" /></button
78
+ >
79
+ </div>
80
+ <div id="cal-list">
81
+ <Calendar.Select bind:start bind:end />
82
+ <div class="cal-list-header">
83
+ <h4>My Calendars</h4>
84
+ <button style:display="contents" command="show-modal" commandfor="add-calendar">
85
+ <Icon i="plus" />
86
+ </button>
87
+ </div>
88
+ {#each calendars.filter(cal => cal.userId == user.id) as cal (cal.id)}
89
+ <div
90
+ class="cal-list-item"
91
+ {@attach contextMenu(
92
+ { i: 'pencil', text: 'Rename', action: () => dialogs['rename:' + cal.id].showModal() },
93
+ { i: 'user-group', text: 'Share', action: () => dialogs['share:' + cal.id].showModal() },
94
+ { i: 'trash', text: 'Delete', action: () => dialogs['delete:' + cal.id].showModal() }
95
+ )}
96
+ >
97
+ <span>{cal.name}</span>
98
+ <Popover showToggle="hover">
99
+ <div class="menu-item" onclick={() => dialogs['rename:' + cal.id].showModal()}>
100
+ <Icon i="pencil" /> Rename
101
+ </div>
102
+ <div class="menu-item" onclick={() => dialogs['share:' + cal.id].showModal()}>
103
+ <Icon i="user-group" /> Share
104
+ </div>
105
+ <div class="menu-item" onclick={() => dialogs['delete:' + cal.id].showModal()}>
106
+ <Icon i="trash" /> Delete
107
+ </div>
108
+ </Popover>
109
+ <FormDialog
110
+ bind:dialog={dialogs['rename:' + cal.id]}
111
+ submitText="Save"
112
+ submit={(input: { name: string }) =>
113
+ fetchAPI('PATCH', 'calendars/:id', input, cal.id).then(result => Object.assign(cal, result))}
114
+ >
115
+ <div>
116
+ <label for="name">Name</label>
117
+ <input name="name" type="text" required value={cal.name} />
118
+ </div>
119
+ </FormDialog>
120
+ <AccessControlDialog editable itemType="calendars" item={cal} bind:dialog={dialogs['share:' + cal.id]} />
121
+ <FormDialog
122
+ bind:dialog={dialogs['delete:' + cal.id]}
123
+ submitText="Delete"
124
+ submitDanger
125
+ submit={() => fetchAPI('DELETE', 'calendars/:id', null, cal.id).then(() => calendars.splice(calendars.indexOf(cal), 1))}
126
+ >
127
+ <p>
128
+ Are you sure you want to delete the calendar "{cal.name}"?<br />
129
+ <strong>This action cannot be undone.</strong>
130
+ </p>
131
+ </FormDialog>
132
+ </div>
133
+ {/each}
134
+ {#if calendars.some(cal => cal.userId != user.id)}
135
+ <div class="cal-list-header">
136
+ <h4>Shared Calendars</h4>
137
+ </div>
138
+ {#each calendars.filter(cal => cal.userId != user.id) as cal (cal.id)}
139
+ {@const { list, icon } = getCalPermissionsInfo(cal, user)}
140
+ <dfn title={list}>
141
+ <Icon i={icon} />
142
+ </dfn>
143
+ {/each}
144
+ {/if}
145
+ </div>
146
+ <div id="cal">
147
+ <div class="hours subtle">
148
+ {#each { length: 24 }, i}
149
+ {#if !i}
150
+ <span class="hour">{tz}</span>
151
+ {:else}
152
+ <span class="hour">{i}:00</span>
153
+ {/if}
154
+ {/each}
155
+ <span class="hour empty"></span>
156
+ </div>
157
+ {#if span == 'week'}
158
+ <div class="cal-content week">
159
+ {#each weekDays as day, i}
160
+ <div class="day">
161
+ <div class="day-header">
162
+ <span class="subtle">{day.toLocaleString('en', { weekday: 'short' })}</span>
163
+ <span class={['day-number', today.getTime() == day.getTime() && 'today']}>{day.getDate()}</span>
164
+ </div>
165
+
166
+ <div class="day-content">
167
+ {#each eventsForWeekDays[i] ?? [] as event}
168
+ <Popover id="event-popover:{event.id}" onclick={e => e.stopPropagation()}>
169
+ {#snippet toggle()}
170
+ {@const start = event.start.getHours() * 60 + event.start.getMinutes()}
171
+ {@const end = event.end.getHours() * 60 + event.end.getMinutes()}
172
+ <div class="event" style:top="{start / 14.4}%" style:height="{(end - start) / 14.4}%">
173
+ <span>{event.summary}</span>
174
+ <span class="subtle">{formatEventTimes(event)}</span>
175
+ </div>
176
+ {/snippet}
177
+
178
+ <div class="event-actions">
179
+ <button
180
+ class="reset"
181
+ onclick={() => {
182
+ eventEditId = event.id;
183
+ eventEditCalId = event.calId;
184
+ eventInit = event;
185
+ }}
186
+ command="show-modal"
187
+ commandfor="event-init"><Icon i="pencil" /></button
188
+ >
189
+ <button
190
+ class="reset"
191
+ onclick={() => (eventEditId = event.id)}
192
+ command="show-modal"
193
+ commandfor="event-delete"><Icon i="trash" /></button
194
+ >
195
+ <button class="reset" command="hide-popover" commandfor="event-popover:{event.id}"
196
+ ><Icon i="xmark" /></button
197
+ >
198
+ </div>
199
+
200
+ <h3>{event.summary}</h3>
201
+
202
+ <div>
203
+ <Icon i="clock" />
204
+ <span>
205
+ {#if event.start.getDate() == event.end.getDate()}
206
+ {event.start.toLocaleDateString()}, {formatEventTimes(event)}
207
+ {:else if event.isAllDay}
208
+ {event.start.toLocaleDateString()} - {event.end.toLocaleDateString()}
209
+ {:else}
210
+ {event.start.toLocaleString()} - {event.end.toLocaleString()}
211
+ {/if}
212
+ </span>
213
+ </div>
214
+
215
+ {#if event.location}
216
+ <div>
217
+ <Icon i="location-dot" />
218
+ <span>{event.location}</span>
219
+ </div>
220
+ {/if}
221
+
222
+ <div>
223
+ <Icon i="calendar" />
224
+ <span>
225
+ {#if event.calendar}
226
+ {event.calendar.name}
227
+ {:else}
228
+ <i>Unknown Calendar</i>
229
+ {/if}
230
+ </span>
231
+ </div>
232
+
233
+ {#if event.attendees.length}
234
+ <div class="attendees-container">
235
+ <Icon i="user-group" />
236
+ <div class="attendees">
237
+ {#each event.attendees ?? [] as attendee (attendee.email)}
238
+ <div class="attendee">{attendee.email}</div>
239
+ {/each}
240
+ </div>
241
+ </div>
242
+ {/if}
243
+
244
+ {#if event.description}
245
+ <div class="description">
246
+ <Icon i="block-quote" />
247
+ <span>{event.description}</span>
248
+ </div>
249
+ {/if}
250
+ </Popover>
251
+ {/each}
252
+ </div>
253
+ </div>
254
+ {/each}
255
+ </div>
256
+ {/if}
257
+ </div>
258
+ </div>
259
+
260
+ <FormDialog
261
+ id="event-init"
262
+ clearOnCancel
263
+ cancel={() => (eventInit = {} as any)}
264
+ submitText={eventEditId ? 'Update' : 'Create'}
265
+ submit={async (data: EventInitFormData) => {
266
+ Object.assign(eventInit, data);
267
+ const calendar = calendars.find(cal => cal.id == eventInit.calId);
268
+ if (!calendar) throw 'Invalid calendar';
269
+ if (!eventEditId) {
270
+ const event: Event = await fetchAPI('PUT', 'calendars/:id/events', { ...eventInit, sendEmails: false }, eventInit.calId);
271
+ event.calendar = calendar;
272
+ events.push(event);
273
+ return;
274
+ }
275
+
276
+ const event: Event = await fetchAPI('PATCH', 'events/:id', { ...eventInit, sendEmails: false }, eventEditId);
277
+ event.calendar = calendar;
278
+ const existing = events.find(e => e.id == eventEditId);
279
+ if (!existing) console.warn('Could not find event to update');
280
+ else Object.assign(existing, event);
281
+ return;
282
+ }}
283
+ >
284
+ <input name="summary" type="text" required placeholder="Add title" bind:value={eventInit.summary} />
285
+ <div class="event-times-container">
286
+ <label for="eventInit.start"><Icon i="clock" /></label>
287
+ <div class="event-times">
288
+ <input
289
+ type="datetime-local"
290
+ name="start"
291
+ id="eventInit.start"
292
+ bind:value={eventInitStart}
293
+ onchange={e => (eventInit.start = new Date(e.currentTarget.value))}
294
+ required
295
+ />
296
+ <input
297
+ type="datetime-local"
298
+ name="end"
299
+ id="eventInit.end"
300
+ bind:value={eventInitEnd}
301
+ onchange={e => (eventInit.end = new Date(e.currentTarget.value))}
302
+ required
303
+ />
304
+ <div class="event-time-options">
305
+ <input bind:checked={eventInit.isAllDay} id="eventInit.isAllDay:checkbox" type="checkbox" />
306
+ <label for="eventInit.isAllDay:checkbox" class="checkbox">
307
+ {#if eventInit.isAllDay}<Icon i="check" --size="1.3em" />{/if}
308
+ </label>
309
+ <label for="eventInit.isAllDay:checkbox">All day</label>
310
+ <div class="spacing"></div>
311
+ <select class="recurrence">
312
+ <!-- @todo -->
313
+ </select>
314
+ </div>
315
+ </div>
316
+ </div>
317
+
318
+ <div>
319
+ <label for="eventInit.calId"><Icon i="calendar" /></label>
320
+ <select id="eventInit.calId" name="calId" required bind:value={eventInit.calId}>
321
+ {#each calendars.filter(cal => cal.userId == user.id || getCalPermissionsInfo(cal, user).perms.edit) as cal (cal.id)}
322
+ <option value={cal.id}>{cal.name}</option>
323
+ {/each}
324
+ </select>
325
+ </div>
326
+
327
+ <div class="attendees-container">
328
+ <label for="eventInit.attendee"><Icon i="user-group" /></label>
329
+ <div class="attendees">
330
+ <UserDiscovery
331
+ noRoles
332
+ allowExact
333
+ onSelect={target => {
334
+ const { data: userId } = z.uuid().safeParse(target);
335
+ const { data: email } = z.email().safeParse(target);
336
+ if (!userId && !email) throw 'Can not determine attendee: ' + target;
337
+ if (!email) throw 'Specifying attendees without an email is not supported yet';
338
+ // @todo supports roles and also contacts
339
+ eventInit.attendees.push({ userId, email });
340
+ }}
341
+ />
342
+ {#each eventInit.attendees as attendee (attendee.email)}
343
+ <div class="attendee">{attendee.email}</div>
344
+ {/each}
345
+ </div>
346
+ </div>
347
+
348
+ <div>
349
+ <label for="eventInit.location"><Icon i="location-dot" /></label>
350
+ <input name="location" id="eventInit.location" placeholder="Add location" bind:value={eventInit.location} />
351
+ </div>
352
+
353
+ <div>
354
+ <label for="eventInit.description"><Icon i="block-quote" /></label>
355
+ <textarea
356
+ name="description"
357
+ id="eventInit.description"
358
+ placeholder="Add description"
359
+ bind:value={eventInit.description}
360
+ {@attach dynamicRows()}
361
+ ></textarea>
362
+ </div>
363
+ </FormDialog>
364
+
365
+ <FormDialog
366
+ id="event-delete"
367
+ submitText="Delete"
368
+ submitDanger
369
+ submit={async () => {
370
+ if (!eventEditId) throw new Error('No event to delete');
371
+ await fetchAPI('DELETE', 'events/:id', null, eventEditId);
372
+ const i = events.findIndex(e => e.id == eventEditId);
373
+ if (i == -1) console.warn('Could not find event to delete');
374
+ else events.splice(i, 1);
375
+ eventEditId = undefined;
376
+ }}
377
+ >
378
+ <p>Are you sure you want to delete this event?</p>
379
+ </FormDialog>
380
+
381
+ <FormDialog
382
+ id="add-calendar"
383
+ submitText="Create"
384
+ submit={(input: { name: string }) =>
385
+ fetchAPI('PUT', 'users/:id/calendars', input, user.id).then(cal => calendars.push({ ...cal, acl: [] }))}
386
+ >
387
+ <div>
388
+ <label for="name">Name</label>
389
+ <input name="name" type="text" required />
390
+ </div>
391
+ </FormDialog>
392
+
393
+ <style>
394
+ :global(body) {
395
+ top: 5em;
396
+ }
397
+
398
+ #cal-app {
399
+ display: grid;
400
+ grid-template-rows: 3em 1fr;
401
+ grid-template-columns: 15em 1fr;
402
+ inset: 0;
403
+ position: absolute;
404
+ }
405
+
406
+ button.event-init {
407
+ margin: 0.5em;
408
+ background-color: var(--bg-alt);
409
+ text-align: center;
410
+ justify-content: center;
411
+ }
412
+
413
+ :global {
414
+ form {
415
+ div:has(label ~ input),
416
+ div:has(label ~ textarea),
417
+ div:has(label ~ select) {
418
+ display: flex;
419
+ flex-direction: row;
420
+ gap: 0.5em;
421
+ }
422
+ }
423
+ }
424
+
425
+ div.event-times-container,
426
+ div.attendees-container {
427
+ display: flex;
428
+ flex-direction: row;
429
+ gap: 0.5em;
430
+ }
431
+
432
+ div.event-times,
433
+ div.attendees {
434
+ display: flex;
435
+ flex-direction: column;
436
+ gap: 0.5em;
437
+ flex-grow: 1;
438
+ }
439
+
440
+ h3,
441
+ h4 {
442
+ margin: 0;
443
+ }
444
+
445
+ :global {
446
+ #event-init form {
447
+ width: 25em;
448
+
449
+ .event-time-options {
450
+ display: flex;
451
+ align-items: center;
452
+ gap: 0.5em;
453
+
454
+ .spacing {
455
+ flex-grow: 1;
456
+ }
457
+ }
458
+
459
+ input,
460
+ select,
461
+ textarea {
462
+ flex-grow: 1;
463
+ }
464
+
465
+ textarea {
466
+ padding: 0.5em;
467
+ font-size: 16px;
468
+ }
469
+ }
470
+ }
471
+
472
+ .bar {
473
+ display: flex;
474
+ gap: 1em;
475
+ align-items: center;
476
+ font-weight: bold;
477
+ }
478
+
479
+ #cal-list {
480
+ display: flex;
481
+ flex-direction: column;
482
+ gap: 1em;
483
+ grid-column: 1;
484
+ padding: 1em;
485
+
486
+ .cal-list-header,
487
+ .cal-list-item {
488
+ display: flex;
489
+ align-items: center;
490
+ justify-content: space-between;
491
+ }
492
+ }
493
+
494
+ #cal {
495
+ display: flex;
496
+ width: 100%;
497
+ height: 100%;
498
+ padding: 1em;
499
+ border-radius: 1em;
500
+ background-color: var(--bg-menu);
501
+
502
+ .hours {
503
+ display: flex;
504
+ flex-direction: column;
505
+ align-items: stretch;
506
+ text-align: right;
507
+ justify-content: space-around;
508
+ overflow-x: visible;
509
+ margin-top: 5em;
510
+ }
511
+
512
+ .hour {
513
+ position: relative;
514
+ padding-right: 1em;
515
+ }
516
+
517
+ .hour:not(.empty)::after {
518
+ content: '';
519
+ position: absolute;
520
+ left: calc(100% - 0.5em);
521
+ width: calc(100vw - 22em);
522
+ border-bottom: 1px solid var(--border-accent);
523
+ top: 50%;
524
+ z-index: 0;
525
+ pointer-events: none;
526
+ }
527
+
528
+ .cal-content {
529
+ display: flex;
530
+ width: 100%;
531
+ height: 100%;
532
+ justify-content: space-around;
533
+ }
534
+
535
+ .day {
536
+ width: 100%;
537
+ height: 100%;
538
+ border-left: 1px solid var(--border-accent);
539
+ display: flex;
540
+ flex-direction: column;
541
+
542
+ .day-header {
543
+ display: flex;
544
+ text-align: center;
545
+ align-items: center;
546
+ justify-content: center;
547
+ gap: 0.5em;
548
+ user-select: none;
549
+ height: 5.9em;
550
+ flex-shrink: 0;
551
+
552
+ .day-number {
553
+ border-radius: 0.3em;
554
+ width: 2em;
555
+ height: 2em;
556
+ display: inline-flex;
557
+ align-items: center;
558
+ justify-content: center;
559
+
560
+ &.today {
561
+ border: 1px solid var(--border-accent);
562
+ color: var(--fg-accent);
563
+ }
564
+ }
565
+ }
566
+
567
+ .day-content {
568
+ flex-grow: 1;
569
+ position: relative;
570
+ }
571
+
572
+ .event {
573
+ width: 100%;
574
+ position: absolute;
575
+ border-radius: 0.5em;
576
+ padding: 0.25em;
577
+ background-color: var(--bg-alt);
578
+ display: flex;
579
+ flex-direction: column;
580
+ align-items: flex-start;
581
+ justify-content: flex-start;
582
+ container-type: size;
583
+ overflow: hidden;
584
+
585
+ @container (height < 2.5em) {
586
+ .subtle {
587
+ display: none;
588
+ }
589
+ }
590
+
591
+ :global(& + :popover-open) {
592
+ gap: 0.75em;
593
+ padding: 1em;
594
+
595
+ .event-actions {
596
+ display: flex;
597
+ align-items: center;
598
+ justify-content: flex-end;
599
+ gap: 0.25em;
600
+
601
+ button {
602
+ padding: 0.5em;
603
+ border-radius: 0.5em;
604
+ }
605
+ }
606
+ }
607
+ }
608
+ }
609
+ }
610
+ </style>
@@ -0,0 +1,31 @@
1
+ import { EventFilter, getSpanFilter, type Calendar } from '@axium/calendar/common';
2
+ import { fetchAPI } from '@axium/client/requests';
3
+ import { getCurrentSession } from '@axium/client/user';
4
+ import type { Session, User } from '@axium/core';
5
+ import { redirect } from '@sveltejs/kit';
6
+ import { prettifyError } from 'zod';
7
+ import type { WithRequired } from 'utilium';
8
+
9
+ export const ssr = false;
10
+
11
+ export async function load({ parent, url }) {
12
+ let { session }: { session?: (Session & { user: User }) | null } = await parent();
13
+
14
+ try {
15
+ session ||= await getCurrentSession();
16
+ } catch (e) {
17
+ redirect(307, '/login?after=/calendar');
18
+ }
19
+
20
+ const filter: EventFilter = getSpanFilter('week', new Date());
21
+ try {
22
+ const parsed = EventFilter.partial().parse(Object.fromEntries(url.searchParams));
23
+ Object.assign(filter, parsed);
24
+ } catch (e: any) {
25
+ throw prettifyError(e);
26
+ }
27
+
28
+ const calendars: WithRequired<Calendar, 'acl'>[] = await fetchAPI('GET', 'users/:id/calendars', {}, session.userId);
29
+
30
+ return { calendars, session, filter };
31
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": ["../tsconfig.json", "../.svelte-kit/tsconfig.json"],
3
+ "compilerOptions": {
4
+ "moduleResolution": "bundler",
5
+ "module": "esnext",
6
+ "noEmit": true,
7
+ "target": "esnext",
8
+ "rootDir": ".."
9
+ },
10
+ "include": ["**/*", "../lib/*"],
11
+ "references": [{ "path": ".." }]
12
+ }