@meith/plugin-calendar 0.23.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 +21 -0
- package/README.md +57 -0
- package/package.json +31 -0
- package/src/access.ts +32 -0
- package/src/definition.tsx +98 -0
- package/src/events.ts +249 -0
- package/src/handlers.ts +82 -0
- package/src/ics.ts +89 -0
- package/src/index.ts +4 -0
- package/src/messages/en.json +32 -0
- package/src/messages/index.ts +3 -0
- package/src/schema.ts +38 -0
- package/src/store.ts +134 -0
- package/src/ui/admin.tsx +57 -0
- package/src/ui/event-link.tsx +20 -0
- package/src/ui/page.tsx +221 -0
- package/src/ui/thread-card.tsx +36 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jordan Harrison and the Meith contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @meith/plugin-calendar
|
|
2
|
+
|
|
3
|
+
A shared calendar: events the community can see, linked to the threads that
|
|
4
|
+
discuss them.
|
|
5
|
+
|
|
6
|
+
## What it adds
|
|
7
|
+
|
|
8
|
+
- **A calendar page** at `/plugins/calendar`, and a navigation item: an
|
|
9
|
+
agenda grouped by month, each event with its date block, when it is, how
|
|
10
|
+
soon ("in 4 days", "next week"), and where. Readable by anyone who can
|
|
11
|
+
read the board, with a **Past** view for what has already happened.
|
|
12
|
+
- **Events linked to threads.** An event can name a thread — paste the link
|
|
13
|
+
or the id — and the calendar links to the discussion.
|
|
14
|
+
- **The event, shown in its thread.** A card above the first post says what
|
|
15
|
+
is scheduled and when, so somebody who arrives at the discussion sees the
|
|
16
|
+
event without going looking for it.
|
|
17
|
+
- **A link, in the organiser's own words.** An event can carry one address
|
|
18
|
+
and the text to show for it — *Join online* for a video call, *Get
|
|
19
|
+
tickets* for a Meetup or GDG page. Only `http://` and `https://` are
|
|
20
|
+
accepted; anything else is refused rather than rendered, and the anchor
|
|
21
|
+
carries `nofollow ugc noopener noreferrer` like every other member-supplied
|
|
22
|
+
link on the board.
|
|
23
|
+
- **An organiser roster**, under Admin → Plugins → Calendar.
|
|
24
|
+
- **Add to your calendar** on every event — a `.ics` file the reader's own
|
|
25
|
+
calendar app understands. An event with no end is given an hour, and the
|
|
26
|
+
thread link travels with it as the event URL when the board knows its own
|
|
27
|
+
address.
|
|
28
|
+
|
|
29
|
+
## Who may add an event
|
|
30
|
+
|
|
31
|
+
By default, only the members on the organiser roster. An administrator adds
|
|
32
|
+
them by username. Turning on **Any member may add an event** opens it to
|
|
33
|
+
every signed-in member; guests never may.
|
|
34
|
+
|
|
35
|
+
Removing an event is allowed to whoever added it, and to any organiser.
|
|
36
|
+
|
|
37
|
+
This is a roster rather than a usergroup on purpose. `@meith/plugin-kit`
|
|
38
|
+
gives a plugin no way to read a member's groups, and the board's own guard
|
|
39
|
+
says why: group membership is the Authorizer's business, and a plugin never
|
|
40
|
+
gets an `Actor` to ask with. A roster the plugin owns keeps the decision
|
|
41
|
+
inside the plugin's own surface, where it belongs.
|
|
42
|
+
|
|
43
|
+
In the downloaded `.ics`, the event's own link becomes the calendar entry's
|
|
44
|
+
`URL` — it is the one a reader wants to act on from their calendar app — and
|
|
45
|
+
the thread moves to the description. An event with no link of its own keeps
|
|
46
|
+
the thread as its `URL`, as before.
|
|
47
|
+
|
|
48
|
+
## What it stores
|
|
49
|
+
|
|
50
|
+
Two tables in its own namespace: `plugin_calendar_event` and
|
|
51
|
+
`plugin_calendar_organiser`. The link and its text are two columns added by
|
|
52
|
+
a second migration rather than folded into the first, because the first has
|
|
53
|
+
already been applied wherever the plugin runs and a migration that has run
|
|
54
|
+
is never edited. The thread id is a plain column, not a foreign
|
|
55
|
+
key — a plugin's schema may not reference the board's, so an event whose
|
|
56
|
+
thread has since been deleted simply links to a thread that is not there,
|
|
57
|
+
rather than blocking the deletion.
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meith/plugin-calendar",
|
|
3
|
+
"version": "0.23.0",
|
|
4
|
+
"description": "A shared calendar: events the community can see, linked to the threads that discuss them.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/meith-dev/meith.git",
|
|
9
|
+
"directory": "plugins/calendar"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./src/index.ts",
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"!src/**/*.test.*",
|
|
17
|
+
"!src/**/*.type-test.*"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@meith/plugin-kit": "^0.23.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": "^19.2.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/react": "^19.2.18"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/access.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type AddVerdict = 'allowed' | 'guest' | 'not-an-organiser'
|
|
2
|
+
|
|
3
|
+
export interface CalendarConfig {
|
|
4
|
+
readonly anyMemberMayAdd: boolean
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function resolveCalendarConfig(
|
|
8
|
+
settings: Readonly<Record<string, string | number | boolean>>,
|
|
9
|
+
): CalendarConfig {
|
|
10
|
+
return { anyMemberMayAdd: settings.any_member_may_add === true }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function mayAdd(input: {
|
|
14
|
+
readonly userId: number | null
|
|
15
|
+
readonly config: CalendarConfig
|
|
16
|
+
readonly organisers: readonly number[]
|
|
17
|
+
}): AddVerdict {
|
|
18
|
+
if (input.userId === null) return 'guest'
|
|
19
|
+
if (input.config.anyMemberMayAdd) return 'allowed'
|
|
20
|
+
return input.organisers.includes(input.userId) ? 'allowed' : 'not-an-organiser'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function mayDelete(input: {
|
|
24
|
+
readonly userId: number | null
|
|
25
|
+
readonly createdByUserId: number | null
|
|
26
|
+
readonly config: CalendarConfig
|
|
27
|
+
readonly organisers: readonly number[]
|
|
28
|
+
}): boolean {
|
|
29
|
+
if (input.userId === null) return false
|
|
30
|
+
if (input.createdByUserId === input.userId) return true
|
|
31
|
+
return input.organisers.includes(input.userId)
|
|
32
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { definePlugin } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
handleAddOrganiser,
|
|
5
|
+
handleCreateEvent,
|
|
6
|
+
handleEventIcs,
|
|
7
|
+
handleRemoveOrganiser,
|
|
8
|
+
} from './handlers'
|
|
9
|
+
import en from './messages/en.json'
|
|
10
|
+
import { CALENDAR_MIGRATIONS } from './schema'
|
|
11
|
+
import { OrganisersPage } from './ui/admin'
|
|
12
|
+
import { CalendarPage } from './ui/page'
|
|
13
|
+
import { ThreadEventCard } from './ui/thread-card'
|
|
14
|
+
|
|
15
|
+
export const ADD_RATE_LIMIT = { limit: 20, windowSeconds: 3600 }
|
|
16
|
+
|
|
17
|
+
export const calendarPlugin = definePlugin({
|
|
18
|
+
key: 'calendar',
|
|
19
|
+
name: en['calendar.definition.name'],
|
|
20
|
+
nameKey: 'calendar.definition.name',
|
|
21
|
+
version: '0.22.0',
|
|
22
|
+
description: en['calendar.definition.description'],
|
|
23
|
+
descriptionKey: 'calendar.definition.description',
|
|
24
|
+
apiVersion: '0',
|
|
25
|
+
|
|
26
|
+
settings: [
|
|
27
|
+
{
|
|
28
|
+
key: 'any_member_may_add',
|
|
29
|
+
label: en['calendar.setting.anyMember.label'],
|
|
30
|
+
labelKey: 'calendar.setting.anyMember.label',
|
|
31
|
+
description: en['calendar.setting.anyMember.description'],
|
|
32
|
+
descriptionKey: 'calendar.setting.anyMember.description',
|
|
33
|
+
type: 'boolean',
|
|
34
|
+
default: false,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
|
|
38
|
+
migrations: CALENDAR_MIGRATIONS,
|
|
39
|
+
|
|
40
|
+
pages: [
|
|
41
|
+
{
|
|
42
|
+
path: '',
|
|
43
|
+
title: en['calendar.page.title'],
|
|
44
|
+
titleKey: 'calendar.page.title',
|
|
45
|
+
access: 'anonymous',
|
|
46
|
+
render: CalendarPage,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
|
|
50
|
+
navigation: [
|
|
51
|
+
{
|
|
52
|
+
key: 'calendar',
|
|
53
|
+
label: en['calendar.nav.label'],
|
|
54
|
+
labelKey: 'calendar.nav.label',
|
|
55
|
+
path: '',
|
|
56
|
+
audience: 'all',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
|
|
60
|
+
adminPages: [
|
|
61
|
+
{
|
|
62
|
+
path: 'organisers',
|
|
63
|
+
title: en['calendar.admin.organisers.title'],
|
|
64
|
+
titleKey: 'calendar.admin.organisers.title',
|
|
65
|
+
render: OrganisersPage,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
|
|
69
|
+
routes: [
|
|
70
|
+
{
|
|
71
|
+
path: 'events',
|
|
72
|
+
method: 'POST',
|
|
73
|
+
access: 'member',
|
|
74
|
+
rateLimit: ADD_RATE_LIMIT,
|
|
75
|
+
handler: handleCreateEvent,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
path: 'events/ics',
|
|
79
|
+
method: 'GET',
|
|
80
|
+
access: 'anonymous',
|
|
81
|
+
handler: handleEventIcs,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
path: 'organisers/add',
|
|
85
|
+
method: 'POST',
|
|
86
|
+
access: 'admin',
|
|
87
|
+
handler: handleAddOrganiser,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
path: 'organisers/remove',
|
|
91
|
+
method: 'POST',
|
|
92
|
+
access: 'admin',
|
|
93
|
+
handler: handleRemoveOrganiser,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
|
|
97
|
+
contributions: [{ region: 'thread.header', render: ThreadEventCard }],
|
|
98
|
+
})
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
export const MAX_TITLE = 120
|
|
2
|
+
|
|
3
|
+
export const MAX_LOCATION = 120
|
|
4
|
+
|
|
5
|
+
export const MAX_LINK_LABEL = 40
|
|
6
|
+
|
|
7
|
+
export const MAX_LINK_URL = 500
|
|
8
|
+
|
|
9
|
+
export const MAX_DURATION_HOURS = 24 * 14
|
|
10
|
+
|
|
11
|
+
export interface EventDraft {
|
|
12
|
+
readonly title: string
|
|
13
|
+
readonly startsAt: Date
|
|
14
|
+
readonly endsAt: Date | null
|
|
15
|
+
readonly location: string
|
|
16
|
+
readonly threadId: number | null
|
|
17
|
+
readonly linkUrl: string
|
|
18
|
+
readonly linkLabel: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type DraftProblem =
|
|
22
|
+
| 'title-missing'
|
|
23
|
+
| 'title-too-long'
|
|
24
|
+
| 'location-too-long'
|
|
25
|
+
| 'starts-missing'
|
|
26
|
+
| 'ends-before-starts'
|
|
27
|
+
| 'too-long'
|
|
28
|
+
| 'link-not-a-url'
|
|
29
|
+
| 'link-too-long'
|
|
30
|
+
| 'link-label-too-long'
|
|
31
|
+
| 'link-label-without-link'
|
|
32
|
+
|
|
33
|
+
export interface CalendarEvent {
|
|
34
|
+
readonly id: string
|
|
35
|
+
readonly title: string
|
|
36
|
+
readonly startsAt: Date
|
|
37
|
+
readonly endsAt: Date | null
|
|
38
|
+
readonly location: string
|
|
39
|
+
readonly threadId: number | null
|
|
40
|
+
readonly createdByUserId: number | null
|
|
41
|
+
readonly linkUrl: string
|
|
42
|
+
readonly linkLabel: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const DEFAULT_LINK_LABEL = 'calendar.event.linkFallback'
|
|
46
|
+
|
|
47
|
+
export function safeLinkUrl(raw: string): string | null {
|
|
48
|
+
const trimmed = raw.trim()
|
|
49
|
+
if (trimmed === '' || trimmed.length > MAX_LINK_URL) return null
|
|
50
|
+
|
|
51
|
+
let parsed: URL
|
|
52
|
+
try {
|
|
53
|
+
parsed = new URL(trimmed)
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null
|
|
59
|
+
if (parsed.hostname === '') return null
|
|
60
|
+
|
|
61
|
+
return parsed.toString()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseDate(raw: string): Date | null {
|
|
65
|
+
const trimmed = raw.trim()
|
|
66
|
+
if (trimmed === '') return null
|
|
67
|
+
|
|
68
|
+
const parsed = new Date(trimmed)
|
|
69
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseThreadRef(raw: string): number | null {
|
|
73
|
+
const trimmed = raw.trim()
|
|
74
|
+
if (trimmed === '') return null
|
|
75
|
+
|
|
76
|
+
const fromUrl = /\/threads?\/(\d+)/.exec(trimmed)
|
|
77
|
+
const digits = fromUrl?.[1] ?? (/^\d+$/.test(trimmed) ? trimmed : null)
|
|
78
|
+
if (digits === null) return null
|
|
79
|
+
|
|
80
|
+
const id = Number(digits)
|
|
81
|
+
return Number.isSafeInteger(id) && id > 0 ? id : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function readDraft(form: Readonly<Record<string, string>>): {
|
|
85
|
+
readonly draft: EventDraft | null
|
|
86
|
+
readonly problems: readonly DraftProblem[]
|
|
87
|
+
} {
|
|
88
|
+
const problems: DraftProblem[] = []
|
|
89
|
+
|
|
90
|
+
const title = (form.title ?? '').trim()
|
|
91
|
+
if (title === '') problems.push('title-missing')
|
|
92
|
+
else if (title.length > MAX_TITLE) problems.push('title-too-long')
|
|
93
|
+
|
|
94
|
+
const location = (form.location ?? '').trim()
|
|
95
|
+
if (location.length > MAX_LOCATION) problems.push('location-too-long')
|
|
96
|
+
|
|
97
|
+
const startsAt = parseDate(form.starts_at ?? '')
|
|
98
|
+
if (startsAt === null) problems.push('starts-missing')
|
|
99
|
+
|
|
100
|
+
const endsAt = parseDate(form.ends_at ?? '')
|
|
101
|
+
if (startsAt !== null && endsAt !== null) {
|
|
102
|
+
if (endsAt.getTime() <= startsAt.getTime()) problems.push('ends-before-starts')
|
|
103
|
+
else if (endsAt.getTime() - startsAt.getTime() > MAX_DURATION_HOURS * 3_600_000) {
|
|
104
|
+
problems.push('too-long')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const rawLink = (form.link ?? '').trim()
|
|
109
|
+
const linkLabel = (form.link_text ?? '').trim()
|
|
110
|
+
const linkUrl = rawLink === '' ? '' : (safeLinkUrl(rawLink) ?? '')
|
|
111
|
+
|
|
112
|
+
if (rawLink !== '' && linkUrl === '') {
|
|
113
|
+
problems.push(rawLink.length > MAX_LINK_URL ? 'link-too-long' : 'link-not-a-url')
|
|
114
|
+
}
|
|
115
|
+
if (linkLabel.length > MAX_LINK_LABEL) problems.push('link-label-too-long')
|
|
116
|
+
if (linkLabel !== '' && rawLink === '') problems.push('link-label-without-link')
|
|
117
|
+
|
|
118
|
+
if (problems.length > 0 || startsAt === null) return { draft: null, problems }
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
draft: {
|
|
122
|
+
title,
|
|
123
|
+
startsAt,
|
|
124
|
+
endsAt,
|
|
125
|
+
location,
|
|
126
|
+
threadId: parseThreadRef(form.thread ?? ''),
|
|
127
|
+
linkUrl,
|
|
128
|
+
linkLabel,
|
|
129
|
+
},
|
|
130
|
+
problems: [],
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const DEFAULT_LOCALE = 'en-GB'
|
|
135
|
+
|
|
136
|
+
const DATE_AND_TIME: Intl.DateTimeFormatOptions = {
|
|
137
|
+
dateStyle: 'medium',
|
|
138
|
+
timeStyle: 'short',
|
|
139
|
+
timeZone: 'UTC',
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const TIME_ONLY: Intl.DateTimeFormatOptions = { timeStyle: 'short', timeZone: 'UTC' }
|
|
143
|
+
|
|
144
|
+
function sameUtcDay(a: Date, b: Date): boolean {
|
|
145
|
+
return a.toISOString().slice(0, 10) === b.toISOString().slice(0, 10)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatRange(
|
|
149
|
+
startsAt: Date,
|
|
150
|
+
endsAt: Date | null,
|
|
151
|
+
locale: string = DEFAULT_LOCALE,
|
|
152
|
+
): string {
|
|
153
|
+
const start = new Intl.DateTimeFormat(locale, DATE_AND_TIME).format(startsAt)
|
|
154
|
+
if (endsAt === null) return start
|
|
155
|
+
|
|
156
|
+
const end = sameUtcDay(startsAt, endsAt)
|
|
157
|
+
? new Intl.DateTimeFormat(locale, TIME_ONLY).format(endsAt)
|
|
158
|
+
: new Intl.DateTimeFormat(locale, DATE_AND_TIME).format(endsAt)
|
|
159
|
+
|
|
160
|
+
return `${start} — ${end}`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface DayParts {
|
|
164
|
+
readonly day: string
|
|
165
|
+
readonly weekday: string
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function dayParts(date: Date, locale: string = DEFAULT_LOCALE): DayParts {
|
|
169
|
+
return {
|
|
170
|
+
day: new Intl.DateTimeFormat(locale, { day: '2-digit', timeZone: 'UTC' }).format(date),
|
|
171
|
+
weekday: new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' })
|
|
172
|
+
.format(date)
|
|
173
|
+
.toUpperCase(),
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function monthKey(date: Date): string {
|
|
178
|
+
return date.toISOString().slice(0, 7)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function monthLabel(date: Date, locale: string = DEFAULT_LOCALE): string {
|
|
182
|
+
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' })
|
|
183
|
+
.format(date)
|
|
184
|
+
.toUpperCase()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface EventMonth {
|
|
188
|
+
readonly key: string
|
|
189
|
+
readonly label: string
|
|
190
|
+
readonly events: readonly CalendarEvent[]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function groupByMonth(
|
|
194
|
+
events: readonly CalendarEvent[],
|
|
195
|
+
locale: string = DEFAULT_LOCALE,
|
|
196
|
+
): readonly EventMonth[] {
|
|
197
|
+
const months: EventMonth[] = []
|
|
198
|
+
|
|
199
|
+
for (const event of events) {
|
|
200
|
+
const key = monthKey(event.startsAt)
|
|
201
|
+
const last = months.at(-1)
|
|
202
|
+
|
|
203
|
+
if (last !== undefined && last.key === key) {
|
|
204
|
+
months[months.length - 1] = { ...last, events: [...last.events, event] }
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
months.push({ key, label: monthLabel(event.startsAt, locale), events: [event] })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return months
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function wholeDaysBetween(from: Date, to: Date): number {
|
|
214
|
+
const day = 86_400_000
|
|
215
|
+
const startOf = (date: Date) =>
|
|
216
|
+
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
|
|
217
|
+
return Math.round((startOf(to) - startOf(from)) / day)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function relativeHint(startsAt: Date, now: Date, locale: string = DEFAULT_LOCALE): string {
|
|
221
|
+
const days = wholeDaysBetween(now, startsAt)
|
|
222
|
+
const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
|
|
223
|
+
|
|
224
|
+
if (Math.abs(days) < 7) return format.format(days, 'day')
|
|
225
|
+
if (Math.abs(days) < 31) return format.format(Math.trunc(days / 7), 'week')
|
|
226
|
+
return format.format(Math.trunc(days / 30), 'month')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function isUpcoming(event: CalendarEvent, now: Date): boolean {
|
|
230
|
+
const finishes = event.endsAt ?? event.startsAt
|
|
231
|
+
return finishes.getTime() >= now.getTime()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function pickThreadEvent(events: readonly CalendarEvent[], now: Date): CalendarEvent | null {
|
|
235
|
+
if (events.length === 0) return null
|
|
236
|
+
|
|
237
|
+
const upcoming = events.filter((event) => isUpcoming(event, now)).sort(byStart)
|
|
238
|
+
if (upcoming.length > 0) return upcoming[0] ?? null
|
|
239
|
+
|
|
240
|
+
return [...events].sort(byStart).at(-1) ?? null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function byStart(a: CalendarEvent, b: CalendarEvent): number {
|
|
244
|
+
return a.startsAt.getTime() - b.startsAt.getTime()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function eventHref(event: CalendarEvent): string | null {
|
|
248
|
+
return event.threadId === null ? null : `/thread/${event.threadId}`
|
|
249
|
+
}
|
package/src/handlers.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { PluginRequest, PluginResponse, PluginRuntimeContext } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
import { mayAdd, resolveCalendarConfig } from './access'
|
|
4
|
+
import { readDraft } from './events'
|
|
5
|
+
import { ICS_CONTENT_TYPE, toIcs } from './ics'
|
|
6
|
+
import { addOrganiser, createEvent, eventById, organiserIds, removeOrganiser } from './store'
|
|
7
|
+
|
|
8
|
+
export const CALENDAR_PATH = '/plugins/calendar'
|
|
9
|
+
|
|
10
|
+
function seeCalendar(): PluginResponse {
|
|
11
|
+
return { kind: 'redirect', to: CALENDAR_PATH }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function refused(status: number, message: string): PluginResponse {
|
|
15
|
+
return { kind: 'json', status, body: { error: message } }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function handleCreateEvent(
|
|
19
|
+
request: PluginRequest,
|
|
20
|
+
context: PluginRuntimeContext,
|
|
21
|
+
): Promise<PluginResponse> {
|
|
22
|
+
const form = request.form
|
|
23
|
+
if (form === null) return refused(400, 'form-required')
|
|
24
|
+
|
|
25
|
+
const config = resolveCalendarConfig(context.settings)
|
|
26
|
+
const verdict = mayAdd({
|
|
27
|
+
userId: request.viewer.userId,
|
|
28
|
+
config,
|
|
29
|
+
organisers: await organiserIds(context.data),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
if (verdict !== 'allowed') return refused(403, verdict)
|
|
33
|
+
|
|
34
|
+
const { draft, problems } = readDraft(form)
|
|
35
|
+
if (draft === null) return refused(400, problems.join(','))
|
|
36
|
+
|
|
37
|
+
await createEvent(context.data, draft, request.viewer.userId)
|
|
38
|
+
return seeCalendar()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function handleEventIcs(
|
|
42
|
+
request: PluginRequest,
|
|
43
|
+
context: PluginRuntimeContext,
|
|
44
|
+
): Promise<PluginResponse> {
|
|
45
|
+
const id = request.query.id?.trim() ?? ''
|
|
46
|
+
if (!/^\d+$/.test(id)) return refused(400, 'id-required')
|
|
47
|
+
|
|
48
|
+
const event = await eventById(context.data, id)
|
|
49
|
+
if (event === null) return refused(404, 'no-such-event')
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
kind: 'text',
|
|
53
|
+
body: toIcs(event, request.boardUrl, new Date()),
|
|
54
|
+
contentType: ICS_CONTENT_TYPE,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function handleAddOrganiser(
|
|
59
|
+
request: PluginRequest,
|
|
60
|
+
context: PluginRuntimeContext,
|
|
61
|
+
): Promise<PluginResponse> {
|
|
62
|
+
const username = request.form?.username?.trim() ?? ''
|
|
63
|
+
if (username === '') return refused(400, 'username-required')
|
|
64
|
+
|
|
65
|
+
const member = await context.users.byUsername(username)
|
|
66
|
+
if (member === null) return refused(404, 'unknown-member')
|
|
67
|
+
|
|
68
|
+
await addOrganiser(context.data, member.userId, request.viewer.userId)
|
|
69
|
+
return { kind: 'redirect', to: '/admin/plugins/calendar/organisers' }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function handleRemoveOrganiser(
|
|
73
|
+
request: PluginRequest,
|
|
74
|
+
context: PluginRuntimeContext,
|
|
75
|
+
): Promise<PluginResponse> {
|
|
76
|
+
const raw = request.form?.user_id?.trim() ?? ''
|
|
77
|
+
const userId = Number(raw)
|
|
78
|
+
if (!Number.isSafeInteger(userId) || userId <= 0) return refused(400, 'user-id-required')
|
|
79
|
+
|
|
80
|
+
await removeOrganiser(context.data, userId)
|
|
81
|
+
return { kind: 'redirect', to: '/admin/plugins/calendar/organisers' }
|
|
82
|
+
}
|
package/src/ics.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type CalendarEvent, eventHref } from './events'
|
|
2
|
+
|
|
3
|
+
export const ICS_CONTENT_TYPE = `text/calendar; charset=utf-8`
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_DURATION_MINUTES = 60
|
|
6
|
+
|
|
7
|
+
const PRODID = `PRODID:-//Meith//Calendar//EN`
|
|
8
|
+
|
|
9
|
+
function stamp(date: Date): string {
|
|
10
|
+
return `${date.toISOString().replace(/[-:]/g, '').slice(0, 15)}Z`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function escapeText(value: string): string {
|
|
14
|
+
return value
|
|
15
|
+
.replace(/\\/g, '\\\\')
|
|
16
|
+
.replace(/;/g, '\\;')
|
|
17
|
+
.replace(/,/g, '\\,')
|
|
18
|
+
.replace(/\r?\n/g, '\\n')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function fold(line: string): string {
|
|
22
|
+
if (line.length <= 75) return line
|
|
23
|
+
|
|
24
|
+
const parts: string[] = [line.slice(0, 75)]
|
|
25
|
+
let rest = line.slice(75)
|
|
26
|
+
while (rest.length > 74) {
|
|
27
|
+
parts.push(` ${rest.slice(0, 74)}`)
|
|
28
|
+
rest = rest.slice(74)
|
|
29
|
+
}
|
|
30
|
+
if (rest.length > 0) parts.push(` ${rest}`)
|
|
31
|
+
return parts.join('\r\n')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function icsFileName(event: CalendarEvent): string {
|
|
35
|
+
const slug = event.title
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
38
|
+
.replace(/^-|-$/g, '')
|
|
39
|
+
.slice(0, 60)
|
|
40
|
+
return `${slug === '' ? 'event' : slug}.ics`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function absoluteBase(boardUrl: string): string | null {
|
|
44
|
+
const trimmed = boardUrl.trim().replace(/\/+$/, '')
|
|
45
|
+
if (trimmed === '') return null
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const parsed = new URL(trimmed)
|
|
49
|
+
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? trimmed : null
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function toIcs(event: CalendarEvent, boardUrl: string, now: Date): string {
|
|
56
|
+
const ends =
|
|
57
|
+
event.endsAt ?? new Date(event.startsAt.getTime() + DEFAULT_DURATION_MINUTES * 60_000)
|
|
58
|
+
|
|
59
|
+
const base = absoluteBase(boardUrl)
|
|
60
|
+
const host = base === null ? 'meith' : new URL(base).host
|
|
61
|
+
|
|
62
|
+
const href = eventHref(event)
|
|
63
|
+
const threadUrl = href === null || base === null ? null : `${base}${href}`
|
|
64
|
+
|
|
65
|
+
const hasOwnLink = event.linkUrl !== ''
|
|
66
|
+
const actionUrl = hasOwnLink ? event.linkUrl : threadUrl
|
|
67
|
+
const describedUrl = hasOwnLink ? threadUrl : null
|
|
68
|
+
|
|
69
|
+
const lines = [
|
|
70
|
+
'BEGIN:VCALENDAR',
|
|
71
|
+
'VERSION:2.0',
|
|
72
|
+
PRODID,
|
|
73
|
+
'CALSCALE:GREGORIAN',
|
|
74
|
+
'METHOD:PUBLISH',
|
|
75
|
+
'BEGIN:VEVENT',
|
|
76
|
+
`UID:calendar-${event.id}@${host}`,
|
|
77
|
+
`DTSTAMP:${stamp(now)}`,
|
|
78
|
+
`DTSTART:${stamp(event.startsAt)}`,
|
|
79
|
+
`DTEND:${stamp(ends)}`,
|
|
80
|
+
`SUMMARY:${escapeText(event.title)}`,
|
|
81
|
+
...(event.location === '' ? [] : [`LOCATION:${escapeText(event.location)}`]),
|
|
82
|
+
...(actionUrl === null ? [] : [`URL:${escapeText(actionUrl)}`]),
|
|
83
|
+
...(describedUrl === null ? [] : [`DESCRIPTION:${escapeText(describedUrl)}`]),
|
|
84
|
+
'END:VEVENT',
|
|
85
|
+
'END:VCALENDAR',
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
return `${lines.map(fold).join('\r\n')}\r\n`
|
|
89
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"calendar.admin.organisers.add": "Add organiser",
|
|
3
|
+
"calendar.admin.organisers.empty": "Nobody is an organiser yet, so only the setting above decides who may add events.",
|
|
4
|
+
"calendar.admin.organisers.remove": "Remove",
|
|
5
|
+
"calendar.admin.organisers.title": "Organisers",
|
|
6
|
+
"calendar.admin.organisers.username": "Username",
|
|
7
|
+
"calendar.definition.description": "A shared calendar: events the community can see, linked to the threads that discuss them.",
|
|
8
|
+
"calendar.definition.name": "Calendar",
|
|
9
|
+
"calendar.error.notAnOrganiser": "Only an organiser may add an event to this calendar.",
|
|
10
|
+
"calendar.event.add": "Add event",
|
|
11
|
+
"calendar.event.discuss": "Discussion",
|
|
12
|
+
"calendar.event.download": "Add to your calendar",
|
|
13
|
+
"calendar.event.link": "Link",
|
|
14
|
+
"calendar.event.linkFallback": "Open link",
|
|
15
|
+
"calendar.event.linkHint": "An https:// address people should open — a video call, a ticket page.",
|
|
16
|
+
"calendar.event.linkText": "Link text",
|
|
17
|
+
"calendar.event.linkTextHint": "What the link should say — “Join online”, “Get tickets”.",
|
|
18
|
+
"calendar.event.location": "Location",
|
|
19
|
+
"calendar.event.starts": "Starts",
|
|
20
|
+
"calendar.event.thread": "Thread link or id",
|
|
21
|
+
"calendar.event.title": "Title",
|
|
22
|
+
"calendar.event.until": "Until",
|
|
23
|
+
"calendar.nav.label": "Calendar",
|
|
24
|
+
"calendar.page.empty": "Nothing is scheduled yet.",
|
|
25
|
+
"calendar.page.emptyPast": "Nothing has happened yet.",
|
|
26
|
+
"calendar.page.past": "Past",
|
|
27
|
+
"calendar.page.title": "Calendar",
|
|
28
|
+
"calendar.page.upcoming": "Upcoming",
|
|
29
|
+
"calendar.setting.anyMember.description": "When off, only the organisers named under Admin → Plugins → Calendar may add events.",
|
|
30
|
+
"calendar.setting.anyMember.label": "Any member may add an event",
|
|
31
|
+
"calendar.thread.card": "This thread discusses an event."
|
|
32
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PluginMigration } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
export const CALENDAR_MIGRATIONS: readonly PluginMigration[] = [
|
|
4
|
+
{
|
|
5
|
+
id: '0001_events_and_organisers',
|
|
6
|
+
statements: [
|
|
7
|
+
`create table if not exists plugin_calendar_event (
|
|
8
|
+
id bigint generated by default as identity primary key,
|
|
9
|
+
title text not null,
|
|
10
|
+
starts_at timestamptz not null,
|
|
11
|
+
ends_at timestamptz,
|
|
12
|
+
location text not null default '',
|
|
13
|
+
thread_id integer,
|
|
14
|
+
created_by_user_id integer,
|
|
15
|
+
created_at timestamptz not null default now()
|
|
16
|
+
)`,
|
|
17
|
+
`create index if not exists plugin_calendar_event_starts
|
|
18
|
+
on plugin_calendar_event (starts_at)`,
|
|
19
|
+
`create index if not exists plugin_calendar_event_thread
|
|
20
|
+
on plugin_calendar_event (thread_id)
|
|
21
|
+
where thread_id is not null`,
|
|
22
|
+
`create table if not exists plugin_calendar_organiser (
|
|
23
|
+
user_id integer not null primary key,
|
|
24
|
+
added_by_user_id integer,
|
|
25
|
+
added_at timestamptz not null default now()
|
|
26
|
+
)`,
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: '0002_event_link',
|
|
31
|
+
statements: [
|
|
32
|
+
`alter table plugin_calendar_event
|
|
33
|
+
add column if not exists link_url text not null default ''`,
|
|
34
|
+
`alter table plugin_calendar_event
|
|
35
|
+
add column if not exists link_label text not null default ''`,
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
]
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { PluginData } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
import type { CalendarEvent, EventDraft } from './events'
|
|
4
|
+
|
|
5
|
+
interface EventRow extends Record<string, unknown> {
|
|
6
|
+
readonly id: string | number
|
|
7
|
+
readonly title: string
|
|
8
|
+
readonly starts_at: Date | string
|
|
9
|
+
readonly ends_at: Date | string | null
|
|
10
|
+
readonly location: string
|
|
11
|
+
readonly thread_id: number | null
|
|
12
|
+
readonly created_by_user_id: number | null
|
|
13
|
+
readonly link_url: string | null
|
|
14
|
+
readonly link_label: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toEvent(row: EventRow): CalendarEvent {
|
|
18
|
+
return {
|
|
19
|
+
id: String(row.id),
|
|
20
|
+
title: row.title,
|
|
21
|
+
startsAt: new Date(row.starts_at),
|
|
22
|
+
endsAt: row.ends_at === null ? null : new Date(row.ends_at),
|
|
23
|
+
location: row.location,
|
|
24
|
+
threadId: row.thread_id,
|
|
25
|
+
createdByUserId: row.created_by_user_id,
|
|
26
|
+
linkUrl: row.link_url ?? '',
|
|
27
|
+
linkLabel: row.link_label ?? '',
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const COLUMNS = `id, title, starts_at, ends_at, location, thread_id, created_by_user_id,
|
|
32
|
+
link_url, link_label`
|
|
33
|
+
|
|
34
|
+
export async function createEvent(
|
|
35
|
+
data: PluginData,
|
|
36
|
+
draft: EventDraft,
|
|
37
|
+
createdByUserId: number | null,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
await data.query(
|
|
40
|
+
`insert into plugin_calendar_event
|
|
41
|
+
(title, starts_at, ends_at, location, thread_id, created_by_user_id, link_url, link_label)
|
|
42
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
43
|
+
[
|
|
44
|
+
draft.title,
|
|
45
|
+
draft.startsAt,
|
|
46
|
+
draft.endsAt,
|
|
47
|
+
draft.location,
|
|
48
|
+
draft.threadId,
|
|
49
|
+
createdByUserId,
|
|
50
|
+
draft.linkUrl,
|
|
51
|
+
draft.linkLabel,
|
|
52
|
+
],
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function upcomingEvents(
|
|
57
|
+
data: PluginData,
|
|
58
|
+
limit: number,
|
|
59
|
+
): Promise<readonly CalendarEvent[]> {
|
|
60
|
+
const rows = await data.query<EventRow>(
|
|
61
|
+
`select ${COLUMNS} from plugin_calendar_event
|
|
62
|
+
where coalesce(ends_at, starts_at) >= now()
|
|
63
|
+
order by starts_at
|
|
64
|
+
limit $1`,
|
|
65
|
+
[limit],
|
|
66
|
+
)
|
|
67
|
+
return rows.map(toEvent)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function pastEvents(
|
|
71
|
+
data: PluginData,
|
|
72
|
+
limit: number,
|
|
73
|
+
): Promise<readonly CalendarEvent[]> {
|
|
74
|
+
const rows = await data.query<EventRow>(
|
|
75
|
+
`select ${COLUMNS} from plugin_calendar_event
|
|
76
|
+
where coalesce(ends_at, starts_at) < now()
|
|
77
|
+
order by starts_at desc
|
|
78
|
+
limit $1`,
|
|
79
|
+
[limit],
|
|
80
|
+
)
|
|
81
|
+
return rows.map(toEvent)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function eventById(data: PluginData, id: string): Promise<CalendarEvent | null> {
|
|
85
|
+
const row = await data.one<EventRow>(
|
|
86
|
+
`select ${COLUMNS} from plugin_calendar_event where id = $1`,
|
|
87
|
+
[id],
|
|
88
|
+
)
|
|
89
|
+
return row === null ? null : toEvent(row)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const THREAD_EVENT_SCAN = 20
|
|
93
|
+
|
|
94
|
+
export async function eventsForThread(
|
|
95
|
+
data: PluginData,
|
|
96
|
+
threadId: number,
|
|
97
|
+
): Promise<readonly CalendarEvent[]> {
|
|
98
|
+
const rows = await data.query<EventRow>(
|
|
99
|
+
`select ${COLUMNS} from plugin_calendar_event
|
|
100
|
+
where thread_id = $1
|
|
101
|
+
order by starts_at desc
|
|
102
|
+
limit $2`,
|
|
103
|
+
[threadId, THREAD_EVENT_SCAN],
|
|
104
|
+
)
|
|
105
|
+
return rows.map(toEvent)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function deleteEvent(data: PluginData, id: string): Promise<void> {
|
|
109
|
+
await data.query(`delete from plugin_calendar_event where id = $1`, [id])
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function organiserIds(data: PluginData): Promise<readonly number[]> {
|
|
113
|
+
const rows = await data.query<{ user_id: number }>(
|
|
114
|
+
`select user_id from plugin_calendar_organiser order by added_at`,
|
|
115
|
+
)
|
|
116
|
+
return rows.map((row) => Number(row.user_id))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function addOrganiser(
|
|
120
|
+
data: PluginData,
|
|
121
|
+
userId: number,
|
|
122
|
+
addedByUserId: number | null,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
await data.query(
|
|
125
|
+
`insert into plugin_calendar_organiser (user_id, added_by_user_id)
|
|
126
|
+
values ($1, $2)
|
|
127
|
+
on conflict (user_id) do nothing`,
|
|
128
|
+
[userId, addedByUserId],
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function removeOrganiser(data: PluginData, userId: number): Promise<void> {
|
|
133
|
+
await data.query(`delete from plugin_calendar_organiser where user_id = $1`, [userId])
|
|
134
|
+
}
|
package/src/ui/admin.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { PluginAdminPageContext } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
import en from '../messages/en.json'
|
|
4
|
+
import { organiserIds } from '../store'
|
|
5
|
+
|
|
6
|
+
function translated(context: PluginAdminPageContext, key: keyof typeof en): string {
|
|
7
|
+
return context.t.has(key) ? context.t.t(key) : en[key]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function OrganisersPage(context: PluginAdminPageContext) {
|
|
11
|
+
const ids = await organiserIds(context.data).catch(() => [] as readonly number[])
|
|
12
|
+
|
|
13
|
+
const named = await Promise.all(
|
|
14
|
+
ids.map(async (userId) => ({
|
|
15
|
+
userId,
|
|
16
|
+
username: (await context.users.byId(userId).catch(() => null))?.username ?? String(userId),
|
|
17
|
+
})),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex flex-col gap-4 text-sm">
|
|
22
|
+
{named.length === 0 ? (
|
|
23
|
+
<p className="text-muted-foreground">
|
|
24
|
+
{translated(context, 'calendar.admin.organisers.empty')}
|
|
25
|
+
</p>
|
|
26
|
+
) : (
|
|
27
|
+
<ul className="flex flex-col gap-1">
|
|
28
|
+
{named.map((organiser) => (
|
|
29
|
+
<li key={organiser.userId} className="flex items-center justify-between gap-3">
|
|
30
|
+
<span>{organiser.username}</span>
|
|
31
|
+
<form method="post" action="/admin/api/plugins/calendar/organisers/remove">
|
|
32
|
+
<input type="hidden" name="user_id" value={organiser.userId} />
|
|
33
|
+
<button type="submit" className="rounded border px-2 py-0.5 text-xs">
|
|
34
|
+
{translated(context, 'calendar.admin.organisers.remove')}
|
|
35
|
+
</button>
|
|
36
|
+
</form>
|
|
37
|
+
</li>
|
|
38
|
+
))}
|
|
39
|
+
</ul>
|
|
40
|
+
)}
|
|
41
|
+
|
|
42
|
+
<form
|
|
43
|
+
method="post"
|
|
44
|
+
action="/admin/api/plugins/calendar/organisers/add"
|
|
45
|
+
className="flex items-end gap-2"
|
|
46
|
+
>
|
|
47
|
+
<label className="flex flex-col gap-1">
|
|
48
|
+
{translated(context, 'calendar.admin.organisers.username')}
|
|
49
|
+
<input name="username" required className="rounded border p-1" />
|
|
50
|
+
</label>
|
|
51
|
+
<button type="submit" className="rounded border px-3 py-1">
|
|
52
|
+
{translated(context, 'calendar.admin.organisers.add')}
|
|
53
|
+
</button>
|
|
54
|
+
</form>
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CalendarEvent } from '../events'
|
|
2
|
+
|
|
3
|
+
export const EXTERNAL_REL = 'nofollow ugc noopener noreferrer'
|
|
4
|
+
|
|
5
|
+
export function EventLink({ event, label }: { event: CalendarEvent; label: string }) {
|
|
6
|
+
if (event.linkUrl === '') return null
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<p className="text-sm">
|
|
10
|
+
<a
|
|
11
|
+
className="underline underline-offset-2"
|
|
12
|
+
href={event.linkUrl}
|
|
13
|
+
rel={EXTERNAL_REL}
|
|
14
|
+
target="_blank"
|
|
15
|
+
>
|
|
16
|
+
{event.linkLabel === '' ? label : event.linkLabel}
|
|
17
|
+
</a>
|
|
18
|
+
</p>
|
|
19
|
+
)
|
|
20
|
+
}
|
package/src/ui/page.tsx
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { PluginPageContext } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
import { mayAdd, resolveCalendarConfig } from '../access'
|
|
4
|
+
import {
|
|
5
|
+
type CalendarEvent,
|
|
6
|
+
dayParts,
|
|
7
|
+
eventHref,
|
|
8
|
+
formatRange,
|
|
9
|
+
groupByMonth,
|
|
10
|
+
relativeHint,
|
|
11
|
+
} from '../events'
|
|
12
|
+
import en from '../messages/en.json'
|
|
13
|
+
import { organiserIds, pastEvents, upcomingEvents } from '../store'
|
|
14
|
+
import { EventLink } from './event-link'
|
|
15
|
+
|
|
16
|
+
export const UPCOMING_LIMIT = 50
|
|
17
|
+
|
|
18
|
+
export const PAST_LIMIT = 30
|
|
19
|
+
|
|
20
|
+
function translated(context: PluginPageContext, key: keyof typeof en): string {
|
|
21
|
+
return context.t.has(key) ? context.t.t(key) : en[key]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function DateBlock({ event, locale }: { event: CalendarEvent; locale: string }) {
|
|
25
|
+
const { day, weekday } = dayParts(event.startsAt, locale)
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="bg-muted text-foreground flex h-14 w-14 shrink-0 flex-col items-center justify-center rounded-md border">
|
|
29
|
+
<span className="text-lg font-semibold leading-none tabular-nums">{day}</span>
|
|
30
|
+
<span className="text-muted-foreground mt-1 text-[0.625rem] font-medium tracking-widest">
|
|
31
|
+
{weekday}
|
|
32
|
+
</span>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function EventRow({
|
|
38
|
+
event,
|
|
39
|
+
locale,
|
|
40
|
+
now,
|
|
41
|
+
context,
|
|
42
|
+
}: {
|
|
43
|
+
event: CalendarEvent
|
|
44
|
+
locale: string
|
|
45
|
+
now: Date
|
|
46
|
+
context: PluginPageContext
|
|
47
|
+
}) {
|
|
48
|
+
const href = eventHref(event)
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<li className="flex items-start gap-4 py-4 first:pt-0 last:pb-0">
|
|
52
|
+
<DateBlock event={event} locale={locale} />
|
|
53
|
+
|
|
54
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
55
|
+
<p className="font-semibold leading-tight">{event.title}</p>
|
|
56
|
+
|
|
57
|
+
<p className="text-muted-foreground text-sm">
|
|
58
|
+
<time dateTime={event.startsAt.toISOString()}>
|
|
59
|
+
{formatRange(event.startsAt, event.endsAt, locale)}
|
|
60
|
+
</time>
|
|
61
|
+
{event.location !== '' && <span> · {event.location}</span>}
|
|
62
|
+
</p>
|
|
63
|
+
|
|
64
|
+
<p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
65
|
+
<span className="text-muted-foreground">{relativeHint(event.startsAt, now, locale)}</span>
|
|
66
|
+
{href !== null && (
|
|
67
|
+
<a className="underline underline-offset-2" href={href}>
|
|
68
|
+
{translated(context, 'calendar.event.discuss')}
|
|
69
|
+
</a>
|
|
70
|
+
)}
|
|
71
|
+
<a
|
|
72
|
+
className="underline underline-offset-2"
|
|
73
|
+
href={`/api/plugins/calendar/events/ics?id=${event.id}`}
|
|
74
|
+
>
|
|
75
|
+
{translated(context, 'calendar.event.download')}
|
|
76
|
+
</a>
|
|
77
|
+
</p>
|
|
78
|
+
|
|
79
|
+
<EventLink event={event} label={translated(context, 'calendar.event.linkFallback')} />
|
|
80
|
+
</div>
|
|
81
|
+
</li>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function Agenda({
|
|
86
|
+
events,
|
|
87
|
+
locale,
|
|
88
|
+
now,
|
|
89
|
+
context,
|
|
90
|
+
}: {
|
|
91
|
+
events: readonly CalendarEvent[]
|
|
92
|
+
locale: string
|
|
93
|
+
now: Date
|
|
94
|
+
context: PluginPageContext
|
|
95
|
+
}) {
|
|
96
|
+
return (
|
|
97
|
+
<div className="flex flex-col gap-6">
|
|
98
|
+
{groupByMonth(events, locale).map((month) => (
|
|
99
|
+
<section key={month.key} className="bg-card flex flex-col gap-3 rounded-md border p-4">
|
|
100
|
+
<h2 className="text-muted-foreground text-xs font-semibold tracking-widest">
|
|
101
|
+
{month.label}
|
|
102
|
+
</h2>
|
|
103
|
+
<ul className="divide-border divide-y">
|
|
104
|
+
{month.events.map((event) => (
|
|
105
|
+
<EventRow key={event.id} event={event} locale={locale} now={now} context={context} />
|
|
106
|
+
))}
|
|
107
|
+
</ul>
|
|
108
|
+
</section>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function AddForm({ context }: { context: PluginPageContext }) {
|
|
115
|
+
return (
|
|
116
|
+
<form
|
|
117
|
+
method="post"
|
|
118
|
+
action="/api/plugins/calendar/events"
|
|
119
|
+
className="bg-card flex flex-col gap-3 rounded-md border p-4"
|
|
120
|
+
>
|
|
121
|
+
<h2 className="font-semibold">{translated(context, 'calendar.event.add')}</h2>
|
|
122
|
+
|
|
123
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
124
|
+
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
|
|
125
|
+
{translated(context, 'calendar.event.title')}
|
|
126
|
+
<input name="title" required maxLength={120} className="rounded border p-1.5" />
|
|
127
|
+
</label>
|
|
128
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
129
|
+
{translated(context, 'calendar.event.starts')}
|
|
130
|
+
<input name="starts_at" type="datetime-local" required className="rounded border p-1.5" />
|
|
131
|
+
</label>
|
|
132
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
133
|
+
{translated(context, 'calendar.event.until')}
|
|
134
|
+
<input name="ends_at" type="datetime-local" className="rounded border p-1.5" />
|
|
135
|
+
</label>
|
|
136
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
137
|
+
{translated(context, 'calendar.event.location')}
|
|
138
|
+
<input name="location" maxLength={120} className="rounded border p-1.5" />
|
|
139
|
+
</label>
|
|
140
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
141
|
+
{translated(context, 'calendar.event.thread')}
|
|
142
|
+
<input name="thread" className="rounded border p-1.5" />
|
|
143
|
+
</label>
|
|
144
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
145
|
+
{translated(context, 'calendar.event.link')}
|
|
146
|
+
<input
|
|
147
|
+
name="link"
|
|
148
|
+
type="url"
|
|
149
|
+
maxLength={500}
|
|
150
|
+
placeholder="https://"
|
|
151
|
+
className="rounded border p-1.5"
|
|
152
|
+
/>
|
|
153
|
+
<span className="text-muted-foreground text-xs">
|
|
154
|
+
{translated(context, 'calendar.event.linkHint')}
|
|
155
|
+
</span>
|
|
156
|
+
</label>
|
|
157
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
158
|
+
{translated(context, 'calendar.event.linkText')}
|
|
159
|
+
<input name="link_text" maxLength={40} className="rounded border p-1.5" />
|
|
160
|
+
<span className="text-muted-foreground text-xs">
|
|
161
|
+
{translated(context, 'calendar.event.linkTextHint')}
|
|
162
|
+
</span>
|
|
163
|
+
</label>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<button type="submit" className="bg-muted self-start rounded border px-3 py-1.5 text-sm">
|
|
167
|
+
{translated(context, 'calendar.event.add')}
|
|
168
|
+
</button>
|
|
169
|
+
</form>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function CalendarPage(context: PluginPageContext) {
|
|
174
|
+
const config = resolveCalendarConfig(context.settings)
|
|
175
|
+
const showingPast = context.query.show === 'past'
|
|
176
|
+
const now = new Date()
|
|
177
|
+
|
|
178
|
+
const [events, organisers] = await Promise.all([
|
|
179
|
+
(showingPast
|
|
180
|
+
? pastEvents(context.data, PAST_LIMIT)
|
|
181
|
+
: upcomingEvents(context.data, UPCOMING_LIMIT)
|
|
182
|
+
).catch(() => [] as readonly CalendarEvent[]),
|
|
183
|
+
organiserIds(context.data).catch(() => [] as readonly number[]),
|
|
184
|
+
])
|
|
185
|
+
|
|
186
|
+
const verdict = mayAdd({ userId: context.viewer.userId, config, organisers })
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<div className="flex flex-col gap-6">
|
|
190
|
+
<nav className="flex gap-4 text-sm">
|
|
191
|
+
<a
|
|
192
|
+
className={showingPast ? 'underline underline-offset-2' : 'font-semibold'}
|
|
193
|
+
href="/plugins/calendar"
|
|
194
|
+
>
|
|
195
|
+
{translated(context, 'calendar.page.upcoming')}
|
|
196
|
+
</a>
|
|
197
|
+
<a
|
|
198
|
+
className={showingPast ? 'font-semibold' : 'underline underline-offset-2'}
|
|
199
|
+
href="/plugins/calendar?show=past"
|
|
200
|
+
>
|
|
201
|
+
{translated(context, 'calendar.page.past')}
|
|
202
|
+
</a>
|
|
203
|
+
</nav>
|
|
204
|
+
|
|
205
|
+
{events.length === 0 ? (
|
|
206
|
+
<p className="text-muted-foreground text-sm">
|
|
207
|
+
{translated(context, showingPast ? 'calendar.page.emptyPast' : 'calendar.page.empty')}
|
|
208
|
+
</p>
|
|
209
|
+
) : (
|
|
210
|
+
<Agenda events={events} locale={context.locale} now={now} context={context} />
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{!showingPast && verdict === 'allowed' && <AddForm context={context} />}
|
|
214
|
+
{!showingPast && verdict === 'not-an-organiser' && (
|
|
215
|
+
<p className="text-muted-foreground text-sm">
|
|
216
|
+
{translated(context, 'calendar.error.notAnOrganiser')}
|
|
217
|
+
</p>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PluginRegionContext, PluginRuntimeContext } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
import { type CalendarEvent, formatRange, pickThreadEvent } from '../events'
|
|
4
|
+
import en from '../messages/en.json'
|
|
5
|
+
import { eventsForThread } from '../store'
|
|
6
|
+
import { EventLink } from './event-link'
|
|
7
|
+
|
|
8
|
+
export async function ThreadEventCard(context: PluginRegionContext) {
|
|
9
|
+
if (context.subjectId === null) return null
|
|
10
|
+
|
|
11
|
+
let event: CalendarEvent | null = null
|
|
12
|
+
try {
|
|
13
|
+
const runtime = (await context.runtime()) as PluginRuntimeContext
|
|
14
|
+
event = pickThreadEvent(await eventsForThread(runtime.data, context.subjectId), new Date())
|
|
15
|
+
} catch {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (event === null) return null
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<section className="rounded-md border border-border bg-card p-3 text-sm" data-plugin="calendar">
|
|
23
|
+
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
24
|
+
{en['calendar.thread.card']}
|
|
25
|
+
</p>
|
|
26
|
+
<p className="font-semibold">{event.title}</p>
|
|
27
|
+
<p className="text-muted-foreground">
|
|
28
|
+
<time dateTime={event.startsAt.toISOString()}>
|
|
29
|
+
{formatRange(event.startsAt, event.endsAt)}
|
|
30
|
+
</time>
|
|
31
|
+
{event.location !== '' && <span> · {event.location}</span>}
|
|
32
|
+
</p>
|
|
33
|
+
<EventLink event={event} label={en['calendar.event.linkFallback']} />
|
|
34
|
+
</section>
|
|
35
|
+
)
|
|
36
|
+
}
|