@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.
- package/LICENSE.md +157 -0
- package/README.md +1 -0
- package/db.json +76 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +12 -0
- package/dist/common.d.ts +461 -0
- package/dist/common.js +194 -0
- package/dist/hooks.d.ts +3 -0
- package/dist/hooks.js +7 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +208 -0
- package/lib/Select.svelte +127 -0
- package/lib/index.ts +1 -0
- package/lib/tsconfig.json +13 -0
- package/package.json +62 -0
- package/routes/calendar/+page.svelte +610 -0
- package/routes/calendar/+page.ts +31 -0
- package/routes/tsconfig.json +12 -0
|
@@ -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
|
+
}
|