@meith/plugin-calendar 0.30.0 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/plugin-calendar",
3
- "version": "0.30.0",
3
+ "version": "0.30.1",
4
4
  "description": "A shared calendar: events the community can see, linked to the threads that discuss them.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,7 +20,7 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "@meith/plugin-kit": "^0.30.0"
23
+ "@meith/plugin-kit": "^0.30.1"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "react": "^19.2.0"
package/src/access.ts CHANGED
@@ -20,10 +20,9 @@ export function mayAdd(input: {
20
20
  return input.organisers.includes(input.userId) ? 'allowed' : 'not-an-organiser'
21
21
  }
22
22
 
23
- export function mayDelete(input: {
23
+ export function mayManage(input: {
24
24
  readonly userId: number | null
25
25
  readonly createdByUserId: number | null
26
- readonly config: CalendarConfig
27
26
  readonly organisers: readonly number[]
28
27
  }): boolean {
29
28
  if (input.userId === null) return false
@@ -3,8 +3,10 @@ import { definePlugin } from '@meith/plugin-kit'
3
3
  import {
4
4
  handleAddOrganiser,
5
5
  handleCreateEvent,
6
+ handleDeleteEvent,
6
7
  handleEventIcs,
7
8
  handleRemoveOrganiser,
9
+ handleUpdateEvent,
8
10
  } from './handlers'
9
11
  import en from './messages/en.json'
10
12
  import { CALENDAR_MIGRATIONS } from './schema'
@@ -18,7 +20,7 @@ export const calendarPlugin = definePlugin({
18
20
  key: 'calendar',
19
21
  name: en['calendar.definition.name'],
20
22
  nameKey: 'calendar.definition.name',
21
- version: '0.30.0',
23
+ version: '0.30.1',
22
24
  description: en['calendar.definition.description'],
23
25
  descriptionKey: 'calendar.definition.description',
24
26
  apiVersion: '0',
@@ -74,6 +76,20 @@ export const calendarPlugin = definePlugin({
74
76
  rateLimit: ADD_RATE_LIMIT,
75
77
  handler: handleCreateEvent,
76
78
  },
79
+ {
80
+ path: 'events/update',
81
+ method: 'POST',
82
+ access: 'member',
83
+ rateLimit: ADD_RATE_LIMIT,
84
+ handler: handleUpdateEvent,
85
+ },
86
+ {
87
+ path: 'events/delete',
88
+ method: 'POST',
89
+ access: 'member',
90
+ rateLimit: ADD_RATE_LIMIT,
91
+ handler: handleDeleteEvent,
92
+ },
77
93
  {
78
94
  path: 'events/ics',
79
95
  method: 'GET',
package/src/handlers.ts CHANGED
@@ -1,9 +1,17 @@
1
1
  import type { PluginRequest, PluginResponse, PluginRuntimeContext } from '@meith/plugin-kit'
2
2
 
3
- import { mayAdd, resolveCalendarConfig } from './access'
4
- import { readDraft } from './events'
3
+ import { mayAdd, mayManage, resolveCalendarConfig } from './access'
4
+ import { type CalendarEvent, readDraft } from './events'
5
5
  import { ICS_CONTENT_TYPE, toIcs } from './ics'
6
- import { addOrganiser, createEvent, eventById, organiserIds, removeOrganiser } from './store'
6
+ import {
7
+ addOrganiser,
8
+ createEvent,
9
+ deleteEvent,
10
+ eventById,
11
+ organiserIds,
12
+ removeOrganiser,
13
+ updateEvent,
14
+ } from './store'
7
15
 
8
16
  export const CALENDAR_PATH = '/plugins/calendar'
9
17
 
@@ -38,6 +46,56 @@ export async function handleCreateEvent(
38
46
  return seeCalendar()
39
47
  }
40
48
 
49
+ async function manageableEvent(
50
+ request: PluginRequest,
51
+ context: PluginRuntimeContext,
52
+ ): Promise<{ event: CalendarEvent } | { refusal: PluginResponse }> {
53
+ const id = request.form?.id?.trim() ?? ''
54
+ if (!/^\d+$/.test(id)) return { refusal: refused(400, 'id-required') }
55
+
56
+ const event = await eventById(context.data, id)
57
+ if (event === null) return { refusal: refused(404, 'no-such-event') }
58
+
59
+ const allowed = mayManage({
60
+ userId: request.viewer.userId,
61
+ createdByUserId: event.createdByUserId,
62
+ organisers: await organiserIds(context.data),
63
+ })
64
+ if (!allowed) return { refusal: refused(403, 'not-yours') }
65
+
66
+ return { event }
67
+ }
68
+
69
+ export async function handleUpdateEvent(
70
+ request: PluginRequest,
71
+ context: PluginRuntimeContext,
72
+ ): Promise<PluginResponse> {
73
+ const form = request.form
74
+ if (form === null) return refused(400, 'form-required')
75
+
76
+ const found = await manageableEvent(request, context)
77
+ if ('refusal' in found) return found.refusal
78
+
79
+ const { draft, problems } = readDraft(form)
80
+ if (draft === null) return refused(400, problems.join(','))
81
+
82
+ await updateEvent(context.data, found.event.id, draft)
83
+ return seeCalendar()
84
+ }
85
+
86
+ export async function handleDeleteEvent(
87
+ request: PluginRequest,
88
+ context: PluginRuntimeContext,
89
+ ): Promise<PluginResponse> {
90
+ if (request.form === null) return refused(400, 'form-required')
91
+
92
+ const found = await manageableEvent(request, context)
93
+ if ('refusal' in found) return found.refusal
94
+
95
+ await deleteEvent(context.data, found.event.id)
96
+ return seeCalendar()
97
+ }
98
+
41
99
  export async function handleEventIcs(
42
100
  request: PluginRequest,
43
101
  context: PluginRuntimeContext,
@@ -8,14 +8,19 @@
8
8
  "calendar.definition.name": "Calendar",
9
9
  "calendar.error.notAnOrganiser": "Only an organiser may add an event to this calendar.",
10
10
  "calendar.event.add": "Add event",
11
+ "calendar.event.cancel": "Cancel",
12
+ "calendar.event.delete": "Delete",
11
13
  "calendar.event.discuss": "Discussion",
12
14
  "calendar.event.download": "Add to your calendar",
15
+ "calendar.event.edit": "Edit",
16
+ "calendar.event.editTitle": "Edit event",
13
17
  "calendar.event.link": "Link",
14
18
  "calendar.event.linkFallback": "Open link",
15
19
  "calendar.event.linkHint": "An https:// address people should open — a video call, a ticket page.",
16
20
  "calendar.event.linkText": "Link text",
17
21
  "calendar.event.linkTextHint": "What the link should say — “Join online”, “Get tickets”.",
18
22
  "calendar.event.location": "Location",
23
+ "calendar.event.save": "Save changes",
19
24
  "calendar.event.starts": "Starts",
20
25
  "calendar.event.thread": "Thread link or id",
21
26
  "calendar.event.title": "Title",
package/src/store.ts CHANGED
@@ -53,6 +53,25 @@ export async function createEvent(
53
53
  )
54
54
  }
55
55
 
56
+ export async function updateEvent(data: PluginData, id: string, draft: EventDraft): Promise<void> {
57
+ await data.query(
58
+ `update plugin_calendar_event
59
+ set title = $2, starts_at = $3, ends_at = $4, location = $5, thread_id = $6,
60
+ link_url = $7, link_label = $8
61
+ where id = $1`,
62
+ [
63
+ id,
64
+ draft.title,
65
+ draft.startsAt,
66
+ draft.endsAt,
67
+ draft.location,
68
+ draft.threadId,
69
+ draft.linkUrl,
70
+ draft.linkLabel,
71
+ ],
72
+ )
73
+ }
74
+
56
75
  export async function upcomingEvents(
57
76
  data: PluginData,
58
77
  limit: number,
package/src/ui/page.tsx CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  pluginTabClass,
7
7
  } from '@meith/plugin-kit'
8
8
 
9
- import { mayAdd, resolveCalendarConfig } from '../access'
9
+ import { mayAdd, mayManage, resolveCalendarConfig } from '../access'
10
10
  import {
11
11
  type CalendarEvent,
12
12
  dayParts,
@@ -16,7 +16,7 @@ import {
16
16
  relativeHint,
17
17
  } from '../events'
18
18
  import en from '../messages/en.json'
19
- import { organiserIds, pastEvents, upcomingEvents } from '../store'
19
+ import { eventById, organiserIds, pastEvents, upcomingEvents } from '../store'
20
20
  import { EventLink } from './event-link'
21
21
 
22
22
  export const UPCOMING_LIMIT = 50
@@ -45,11 +45,13 @@ function EventRow({
45
45
  locale,
46
46
  now,
47
47
  context,
48
+ manageable,
48
49
  }: {
49
50
  event: CalendarEvent
50
51
  locale: string
51
52
  now: Date
52
53
  context: PluginPageContext
54
+ manageable: boolean
53
55
  }) {
54
56
  const href = eventHref(event)
55
57
 
@@ -67,7 +69,7 @@ function EventRow({
67
69
  {event.location !== '' && <span> · {event.location}</span>}
68
70
  </p>
69
71
 
70
- <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
72
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
71
73
  <span className="text-muted-foreground">{relativeHint(event.startsAt, now, locale)}</span>
72
74
  {href !== null && (
73
75
  <a className="underline underline-offset-2" href={href}>
@@ -80,7 +82,23 @@ function EventRow({
80
82
  >
81
83
  {translated(context, 'calendar.event.download')}
82
84
  </a>
83
- </p>
85
+ {manageable && (
86
+ <>
87
+ <a
88
+ className="underline underline-offset-2"
89
+ href={`/plugins/calendar?edit=${event.id}`}
90
+ >
91
+ {translated(context, 'calendar.event.edit')}
92
+ </a>
93
+ <form method="post" action="/api/plugins/calendar/events/delete">
94
+ <input type="hidden" name="id" value={event.id} />
95
+ <button type="submit" className="underline underline-offset-2">
96
+ {translated(context, 'calendar.event.delete')}
97
+ </button>
98
+ </form>
99
+ </>
100
+ )}
101
+ </div>
84
102
 
85
103
  <EventLink event={event} label={translated(context, 'calendar.event.linkFallback')} />
86
104
  </div>
@@ -93,11 +111,13 @@ function Agenda({
93
111
  locale,
94
112
  now,
95
113
  context,
114
+ organisers,
96
115
  }: {
97
116
  events: readonly CalendarEvent[]
98
117
  locale: string
99
118
  now: Date
100
119
  context: PluginPageContext
120
+ organisers: readonly number[]
101
121
  }) {
102
122
  return (
103
123
  <div className="flex flex-col gap-6">
@@ -108,7 +128,18 @@ function Agenda({
108
128
  </h2>
109
129
  <ul className="divide-border divide-y">
110
130
  {month.events.map((event) => (
111
- <EventRow key={event.id} event={event} locale={locale} now={now} context={context} />
131
+ <EventRow
132
+ key={event.id}
133
+ event={event}
134
+ locale={locale}
135
+ now={now}
136
+ context={context}
137
+ manageable={mayManage({
138
+ userId: context.viewer.userId,
139
+ createdByUserId: event.createdByUserId,
140
+ organisers,
141
+ })}
142
+ />
112
143
  ))}
113
144
  </ul>
114
145
  </section>
@@ -117,31 +148,76 @@ function Agenda({
117
148
  )
118
149
  }
119
150
 
120
- function AddForm({ context }: { context: PluginPageContext }) {
151
+ function toDateTimeInput(date: Date | null): string {
152
+ return date === null ? '' : date.toISOString().slice(0, 16)
153
+ }
154
+
155
+ function EventForm({
156
+ context,
157
+ event,
158
+ }: {
159
+ context: PluginPageContext
160
+ event: CalendarEvent | null
161
+ }) {
162
+ const action =
163
+ event === null ? '/api/plugins/calendar/events' : '/api/plugins/calendar/events/update'
164
+ const heading = translated(
165
+ context,
166
+ event === null ? 'calendar.event.add' : 'calendar.event.editTitle',
167
+ )
168
+ const submit = translated(context, event === null ? 'calendar.event.add' : 'calendar.event.save')
169
+
121
170
  return (
122
- <form method="post" action="/api/plugins/calendar/events" className={PLUGIN_CARD}>
123
- <h2 className="font-semibold">{translated(context, 'calendar.event.add')}</h2>
171
+ <form method="post" action={action} className={PLUGIN_CARD}>
172
+ <h2 className="font-semibold">{heading}</h2>
173
+ {event !== null && <input type="hidden" name="id" value={event.id} />}
124
174
 
125
175
  <div className="grid gap-3 sm:grid-cols-2">
126
176
  <label className="flex flex-col gap-1 text-sm sm:col-span-2">
127
177
  {translated(context, 'calendar.event.title')}
128
- <input name="title" required maxLength={120} className="rounded border p-1.5" />
178
+ <input
179
+ name="title"
180
+ required
181
+ maxLength={120}
182
+ defaultValue={event?.title ?? ''}
183
+ className="rounded border p-1.5"
184
+ />
129
185
  </label>
130
186
  <label className="flex flex-col gap-1 text-sm">
131
187
  {translated(context, 'calendar.event.starts')}
132
- <input name="starts_at" type="datetime-local" required className="rounded border p-1.5" />
188
+ <input
189
+ name="starts_at"
190
+ type="datetime-local"
191
+ required
192
+ defaultValue={toDateTimeInput(event?.startsAt ?? null)}
193
+ className="rounded border p-1.5"
194
+ />
133
195
  </label>
134
196
  <label className="flex flex-col gap-1 text-sm">
135
197
  {translated(context, 'calendar.event.until')}
136
- <input name="ends_at" type="datetime-local" className="rounded border p-1.5" />
198
+ <input
199
+ name="ends_at"
200
+ type="datetime-local"
201
+ defaultValue={toDateTimeInput(event?.endsAt ?? null)}
202
+ className="rounded border p-1.5"
203
+ />
137
204
  </label>
138
205
  <label className="flex flex-col gap-1 text-sm">
139
206
  {translated(context, 'calendar.event.location')}
140
- <input name="location" maxLength={120} className="rounded border p-1.5" />
207
+ <input
208
+ name="location"
209
+ maxLength={120}
210
+ defaultValue={event?.location ?? ''}
211
+ className="rounded border p-1.5"
212
+ />
141
213
  </label>
142
214
  <label className="flex flex-col gap-1 text-sm">
143
215
  {translated(context, 'calendar.event.thread')}
144
- <input name="thread" className="rounded border p-1.5" />
216
+ <input
217
+ name="thread"
218
+ defaultValue={event?.threadId == null ? '' : String(event.threadId)}
219
+ className="rounded border p-1.5"
220
+ />
145
221
  </label>
146
222
  <label className="flex flex-col gap-1 text-sm">
147
223
  {translated(context, 'calendar.event.link')}
@@ -150,6 +226,7 @@ function AddForm({ context }: { context: PluginPageContext }) {
150
226
  type="url"
151
227
  maxLength={500}
152
228
  placeholder="https://"
229
+ defaultValue={event?.linkUrl ?? ''}
153
230
  className="rounded border p-1.5"
154
231
  />
155
232
  <span className="text-muted-foreground text-xs">
@@ -158,16 +235,28 @@ function AddForm({ context }: { context: PluginPageContext }) {
158
235
  </label>
159
236
  <label className="flex flex-col gap-1 text-sm">
160
237
  {translated(context, 'calendar.event.linkText')}
161
- <input name="link_text" maxLength={40} className="rounded border p-1.5" />
238
+ <input
239
+ name="link_text"
240
+ maxLength={40}
241
+ defaultValue={event?.linkLabel ?? ''}
242
+ className="rounded border p-1.5"
243
+ />
162
244
  <span className="text-muted-foreground text-xs">
163
245
  {translated(context, 'calendar.event.linkTextHint')}
164
246
  </span>
165
247
  </label>
166
248
  </div>
167
249
 
168
- <button type="submit" className="bg-muted self-start rounded border px-3 py-1.5 text-sm">
169
- {translated(context, 'calendar.event.add')}
170
- </button>
250
+ <div className="flex items-center gap-3">
251
+ <button type="submit" className="bg-muted rounded border px-3 py-1.5 text-sm">
252
+ {submit}
253
+ </button>
254
+ {event !== null && (
255
+ <a className="text-sm underline underline-offset-2" href="/plugins/calendar">
256
+ {translated(context, 'calendar.event.cancel')}
257
+ </a>
258
+ )}
259
+ </div>
171
260
  </form>
172
261
  )
173
262
  }
@@ -187,6 +276,22 @@ export async function CalendarPage(context: PluginPageContext) {
187
276
 
188
277
  const verdict = mayAdd({ userId: context.viewer.userId, config, organisers })
189
278
 
279
+ const editId = context.query.edit?.trim() ?? ''
280
+ let editing: CalendarEvent | null = null
281
+ if (/^\d+$/.test(editId)) {
282
+ const found = await eventById(context.data, editId).catch(() => null)
283
+ if (
284
+ found !== null &&
285
+ mayManage({
286
+ userId: context.viewer.userId,
287
+ createdByUserId: found.createdByUserId,
288
+ organisers,
289
+ })
290
+ ) {
291
+ editing = found
292
+ }
293
+ }
294
+
190
295
  return (
191
296
  <div className="flex flex-col gap-6">
192
297
  <nav aria-label={translated(context, 'calendar.page.views')}>
@@ -217,12 +322,24 @@ export async function CalendarPage(context: PluginPageContext) {
217
322
  {translated(context, showingPast ? 'calendar.page.emptyPast' : 'calendar.page.empty')}
218
323
  </p>
219
324
  ) : (
220
- <Agenda events={events} locale={context.locale} now={now} context={context} />
325
+ <Agenda
326
+ events={events}
327
+ locale={context.locale}
328
+ now={now}
329
+ context={context}
330
+ organisers={organisers}
331
+ />
221
332
  )}
222
333
 
223
- {!showingPast && verdict === 'allowed' && <AddForm context={context} />}
224
- {!showingPast && verdict === 'not-an-organiser' && (
225
- <p className={PLUGIN_NOTE}>{translated(context, 'calendar.error.notAnOrganiser')}</p>
334
+ {editing !== null ? (
335
+ <EventForm context={context} event={editing} />
336
+ ) : (
337
+ <>
338
+ {!showingPast && verdict === 'allowed' && <EventForm context={context} event={null} />}
339
+ {!showingPast && verdict === 'not-an-organiser' && (
340
+ <p className={PLUGIN_NOTE}>{translated(context, 'calendar.error.notAnOrganiser')}</p>
341
+ )}
342
+ </>
226
343
  )}
227
344
  </div>
228
345
  )