@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/dist/server.js ADDED
@@ -0,0 +1,208 @@
1
+ import { from as aclFrom, userHasAccess } from '@axium/server/acl';
2
+ import { authRequestForItem, checkAuthForUser, requireSession } from '@axium/server/auth';
3
+ import { database } from '@axium/server/database';
4
+ import { error, parseBody, parseSearch, withError } from '@axium/server/requests';
5
+ import { addRoute } from '@axium/server/routes';
6
+ import { sql } from 'kysely';
7
+ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
8
+ import * as z from 'zod';
9
+ import { Attendee, CalendarInit, EventFilter, EventInit } from './common.js';
10
+ addRoute({
11
+ path: '/api/users/:id/calendars',
12
+ params: { id: z.uuid() },
13
+ async GET(request, { id: userId }) {
14
+ const { user } = await checkAuthForUser(request, userId);
15
+ return await database
16
+ .selectFrom('calendars')
17
+ .selectAll()
18
+ .select(aclFrom('calendars'))
19
+ .where(userHasAccess('calendars', user))
20
+ .execute()
21
+ .catch(withError('Could not get calendars'));
22
+ },
23
+ async PUT(request, { id: userId }) {
24
+ const init = await parseBody(request, CalendarInit);
25
+ await checkAuthForUser(request, userId);
26
+ return await database
27
+ .insertInto('calendars')
28
+ .values({ ...init, userId })
29
+ .returningAll()
30
+ .executeTakeFirstOrThrow()
31
+ .catch(withError('Could not create calendar'));
32
+ },
33
+ });
34
+ addRoute({
35
+ path: '/api/calendars/:id',
36
+ params: { id: z.uuid() },
37
+ async GET(request, { id }) {
38
+ const { item } = await authRequestForItem(request, 'calendars', id, { read: true });
39
+ return item;
40
+ },
41
+ async PATCH(request, { id }) {
42
+ const body = await parseBody(request, CalendarInit);
43
+ await authRequestForItem(request, 'calendars', id, { edit: true });
44
+ return await database
45
+ .updateTable('calendars')
46
+ .set(body)
47
+ .where('id', '=', id)
48
+ .returningAll()
49
+ .executeTakeFirstOrThrow()
50
+ .catch(withError('Could not update calendar'));
51
+ },
52
+ async DELETE(request, { id }) {
53
+ await authRequestForItem(request, 'calendars', id, { manage: true });
54
+ return await database
55
+ .deleteFrom('calendars')
56
+ .where('id', '=', id)
57
+ .returningAll()
58
+ .executeTakeFirstOrThrow()
59
+ .catch(withError('Could not delete calendar'));
60
+ },
61
+ });
62
+ function withAttendees(eb) {
63
+ return jsonArrayFrom(eb.selectFrom('attendees').selectAll().whereRef('eventId', '=', 'events.id').$narrowType()).as('attendees');
64
+ }
65
+ function withCalendar(eb) {
66
+ return jsonObjectFrom(eb.selectFrom('calendars').selectAll().whereRef('id', '=', 'events.calId'))
67
+ .$castTo() // json dates
68
+ .as('calendar');
69
+ }
70
+ addRoute({
71
+ path: '/api/calendars/:id/events',
72
+ params: { id: z.uuid() },
73
+ async GET(request, { id }) {
74
+ const filter = parseSearch(request, EventFilter);
75
+ const { item: calendar } = await authRequestForItem(request, 'calendars', id, { read: true });
76
+ const events = await database
77
+ .selectFrom('events')
78
+ .selectAll()
79
+ .select(withAttendees)
80
+ .where('calId', '=', id)
81
+ .where(sql `(${sql.ref('start')}, ${sql.ref('end')}) OVERLAPS (${sql.val(filter.start)}, ${sql.val(filter.end)})`)
82
+ .limit(1000)
83
+ .execute()
84
+ .catch(withError('Could not get events'));
85
+ for (const event of events)
86
+ event.calendar = calendar;
87
+ return events;
88
+ },
89
+ async PUT(request, { id }) {
90
+ const { attendees: attendeesInit = [], sendEmails, recurrenceUpdateAll, ...init } = await parseBody(request, EventInit);
91
+ const { item: calendar } = await authRequestForItem(request, 'calendars', id, { edit: true });
92
+ const tx = await database.startTransaction().execute();
93
+ try {
94
+ const event = await tx
95
+ .insertInto('events')
96
+ .values({ ...init, calId: id })
97
+ .returningAll()
98
+ .executeTakeFirstOrThrow()
99
+ .catch(withError('Could not create event'));
100
+ const attendees = Attendee.array().parse(attendeesInit.length
101
+ ? await tx
102
+ .insertInto('attendees')
103
+ .values(attendeesInit.map(a => ({ ...a, eventId: event.id })))
104
+ .returningAll()
105
+ .execute()
106
+ : []);
107
+ await tx.commit().execute();
108
+ return Object.assign(event, { attendees, calendar });
109
+ }
110
+ catch (e) {
111
+ await tx.rollback().execute();
112
+ throw e;
113
+ }
114
+ },
115
+ });
116
+ addRoute({
117
+ path: '/api/events/:id',
118
+ params: { id: z.uuid() },
119
+ async GET(request, { id }) {
120
+ const { userId } = await requireSession(request);
121
+ const event = await database
122
+ .selectFrom('events')
123
+ .selectAll()
124
+ .select(withAttendees)
125
+ .select(withCalendar)
126
+ .where('id', '=', id)
127
+ .executeTakeFirstOrThrow()
128
+ .catch(withError('Event does not exist', 404));
129
+ if (event.calendar.userId != userId && event.attendees.every(a => a.userId != userId))
130
+ error(403, 'You do not have access to this event');
131
+ return event;
132
+ },
133
+ async PATCH(request, { id }) {
134
+ const { attendees: attendeesInit = [], sendEmails, recurrenceUpdateAll, ...init } = await parseBody(request, EventInit);
135
+ const { calId } = await database
136
+ .selectFrom('events')
137
+ .select('calId')
138
+ .where('id', '=', id)
139
+ .executeTakeFirstOrThrow()
140
+ .catch(withError('Event does not exist', 404));
141
+ await authRequestForItem(request, 'calendars', calId, { edit: true });
142
+ if (calId != init.calId)
143
+ await authRequestForItem(request, 'calendars', init.calId, { edit: true });
144
+ const tx = await database.startTransaction().execute();
145
+ try {
146
+ const event = await tx
147
+ .updateTable('events')
148
+ .set(init)
149
+ .where('id', '=', id)
150
+ .returningAll()
151
+ .returning(withAttendees)
152
+ .returning(withCalendar)
153
+ .executeTakeFirstOrThrow()
154
+ .catch(withError('Could not update event'));
155
+ if (calId != init.calId) {
156
+ await tx
157
+ .updateTable('events')
158
+ .set({ calId: init.calId })
159
+ .where('recurrenceId', '=', id)
160
+ .execute()
161
+ .catch(withError('Failed to update calendar for dependent events'));
162
+ }
163
+ const attendeeInitMap = new Map(attendeesInit.map(a => [a.email, a]));
164
+ for (const attendee of event.attendees) {
165
+ const userInit = attendee.userId && attendeeInitMap.get(attendee.userId);
166
+ const init = attendeeInitMap.get(attendee.email);
167
+ if (!userInit && !init)
168
+ continue;
169
+ /**
170
+ * @todo update attendees
171
+ * Need to handle:
172
+ * - Changing name
173
+ */
174
+ }
175
+ await tx.commit().execute();
176
+ return event;
177
+ }
178
+ catch (e) {
179
+ await tx.rollback().execute();
180
+ throw e;
181
+ }
182
+ },
183
+ async DELETE(request, { id }) {
184
+ const { calId } = await database
185
+ .selectFrom('events')
186
+ .select('calId')
187
+ .where('id', '=', id)
188
+ .executeTakeFirstOrThrow()
189
+ .catch(withError('Event does not exist', 404));
190
+ await authRequestForItem(request, 'calendars', calId, { manage: true });
191
+ const tx = await database.startTransaction().execute();
192
+ try {
193
+ const event = await tx
194
+ .deleteFrom('events')
195
+ .returningAll()
196
+ .returning(withAttendees)
197
+ .returning(withCalendar)
198
+ .where('id', '=', id)
199
+ .executeTakeFirstOrThrow();
200
+ await tx.commit().execute();
201
+ return event;
202
+ }
203
+ catch (e) {
204
+ await tx.rollback().execute();
205
+ throw e;
206
+ }
207
+ },
208
+ });
@@ -0,0 +1,127 @@
1
+ <script lang="ts">
2
+ import { weekOfYear, type EventFilter } from '@axium/calendar/common';
3
+ import { Icon } from '@axium/client/components';
4
+ import { SvelteDate } from 'svelte/reactivity';
5
+
6
+ let { start = $bindable(), end = $bindable() }: Required<EventFilter> = $props();
7
+
8
+ const today = new Date();
9
+ today.setHours(0, 0, 0, 0);
10
+
11
+ let view = new SvelteDate(start);
12
+ $effect(() => {
13
+ view.setTime(start.getTime());
14
+ });
15
+
16
+ const firstOfMonth = $derived(new Date(view.getFullYear(), view.getMonth(), 1));
17
+ const firstWeekOfMonth = $derived(weekOfYear(firstOfMonth, true));
18
+ const lastOfMonth = $derived(new Date(view.getFullYear(), view.getMonth() + 1, 0));
19
+ const sameMonth = (d: Date) => view.getFullYear() == d.getFullYear() && view.getMonth() == d.getMonth();
20
+ </script>
21
+
22
+ <div class="CaldendarSelect">
23
+ <div class="bar">
24
+ <span class="label">{view.toLocaleString('default', { month: 'long', year: 'numeric' })}</span>
25
+ <button style:display="contents" onclick={() => view.setMonth(view.getMonth() - 1)}><Icon i="chevron-left" /></button>
26
+ <button style:display="contents" onclick={() => view.setMonth(view.getMonth() + 1)}><Icon i="chevron-right" /></button>
27
+ </div>
28
+
29
+ <div class="month-grid">
30
+ <div></div>
31
+ {#each ['S', 'M', 'T', 'W', 'T', 'F', 'S'] as day, i}
32
+ <div class={['d-of-w', sameMonth(today) && today.getDay() == i && 'current']}>{day}</div>
33
+ {/each}
34
+
35
+ <div class={['w-of-y', firstWeekOfMonth == weekOfYear(today, true) && 'current']}>{firstWeekOfMonth}</div>
36
+
37
+ {#each { length: firstOfMonth.getDay() }}
38
+ <div class="empty"></div>
39
+ {/each}
40
+ {#each { length: lastOfMonth.getDate() }, i}
41
+ {@const day = i + 1}
42
+ {@const year = view.getFullYear()}
43
+ {@const month = view.getMonth()}
44
+ {@const date = new Date(year, month, day)}
45
+ {@const WofY = weekOfYear(date, true)}
46
+ {#if date.getDay() == 0 && WofY != firstWeekOfMonth}
47
+ <div class={['w-of-y', WofY == weekOfYear(today, true) && 'current']}>{WofY}</div>
48
+ {/if}
49
+ <div
50
+ class={[
51
+ 'grid-day',
52
+ sameMonth(today) && day == today.getDate() && 'today',
53
+ sameMonth(start) && day >= start.getDate() && day < end.getDate() && 'selected',
54
+ ]}
55
+ onclick={() => {
56
+ start.setFullYear(year);
57
+ start.setMonth(month);
58
+ start.setDate(day - date.getDay());
59
+ end.setFullYear(year);
60
+ end.setMonth(month);
61
+ end.setDate(day - date.getDay() + 7);
62
+ }}
63
+ >
64
+ {day}
65
+ </div>
66
+ {/each}
67
+ </div>
68
+ </div>
69
+
70
+ <style>
71
+ .CalendarSelect {
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: space-between;
75
+ }
76
+
77
+ .bar {
78
+ display: grid;
79
+ gap: 1em;
80
+ grid-template-columns: 1fr 1em 1em;
81
+ align-items: center;
82
+ padding-bottom: 1em;
83
+ font-weight: bold;
84
+ }
85
+
86
+ .month-grid {
87
+ display: grid;
88
+ grid-template-columns: repeat(8, 1.5em);
89
+ grid-template-rows: repeat(7, 1.5em);
90
+ gap: 0.25em;
91
+
92
+ > div {
93
+ user-select: none;
94
+ padding: 0.25em;
95
+ width: 1.5em;
96
+ height: 1.5em;
97
+ display: inline-flex;
98
+ text-align: center;
99
+ align-items: center;
100
+ justify-content: center;
101
+ }
102
+
103
+ .grid-day {
104
+ border-radius: 0.25em;
105
+
106
+ &:hover {
107
+ background-color: var(--bg-accent);
108
+ }
109
+
110
+ &.today {
111
+ border: 1px solid var(--border-accent);
112
+ }
113
+
114
+ &.selected {
115
+ color: var(--fg-accent);
116
+ }
117
+ }
118
+
119
+ .d-of-w,
120
+ .w-of-y {
121
+ font-weight: bold;
122
+ &.current {
123
+ color: var(--fg-strong);
124
+ }
125
+ }
126
+ }
127
+ </style>
package/lib/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default as Select } from './Select.svelte';
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": ["../tsconfig.json", "../.svelte-kit/tsconfig.json"],
3
+ "compilerOptions": {
4
+ "rootDir": "..",
5
+ "noEmit": true,
6
+ "module": "preserve",
7
+ "moduleResolution": "Bundler",
8
+ "types": ["@sveltejs/kit"]
9
+ },
10
+ "include": ["**/*.svelte", "**/*.ts"],
11
+ "exclude": [],
12
+ "references": [{ "path": ".." }]
13
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@axium/calendar",
3
+ "version": "0.1.0",
4
+ "author": "James Prevett <axium@jamespre.dev>",
5
+ "description": "Calendar for Axium",
6
+ "funding": {
7
+ "type": "individual",
8
+ "url": "https://github.com/sponsors/james-pre"
9
+ },
10
+ "license": "LGPL-3.0-or-later",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/james-pre/axium.git"
14
+ },
15
+ "homepage": "https://github.com/james-pre/axium#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/james-pre/axium/issues"
18
+ },
19
+ "type": "module",
20
+ "main": "dist/index.js",
21
+ "types": "dist/index.d.ts",
22
+ "exports": {
23
+ ".": "./dist/index.js",
24
+ "./*": "./dist/*.js",
25
+ "./components": "./lib/index.js",
26
+ "./components/*": "./lib/*.svelte"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "lib",
31
+ "routes",
32
+ "db.json"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc"
36
+ },
37
+ "peerDependencies": {
38
+ "@axium/client": ">=0.14.3",
39
+ "@axium/core": ">=0.19.0",
40
+ "@axium/server": ">=0.36.0",
41
+ "@sveltejs/kit": "^2.27.3",
42
+ "utilium": "^2.6.3"
43
+ },
44
+ "dependencies": {
45
+ "zod": "^4.0.5"
46
+ },
47
+ "axium": {
48
+ "server": {
49
+ "routes": "routes",
50
+ "hooks": "./dist/hooks.js",
51
+ "db": "./db.json"
52
+ },
53
+ "apps": [
54
+ {
55
+ "id": "calendar",
56
+ "name": "Calendar",
57
+ "icon": "calendar"
58
+ }
59
+ ],
60
+ "update_checks": true
61
+ }
62
+ }