domma-cms 1.6.59 → 1.7.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/CHANGELOG.md +4344 -0
- package/README.md +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/draft-owner.js +1 -0
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +2 -2
- package/admin/js/lib/page-editor-reference.js +5 -5
- package/admin/js/lib/shortcode-tags.js +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +39 -0
- package/admin/js/views/user-editor.js +1 -1
- package/docs/DommaFeatures.md +3005 -0
- package/docs/MultiTenant.md +176 -0
- package/docs/api-reference.md +1667 -0
- package/docs/cache.md +96 -0
- package/docs/configuration.md +1811 -0
- package/docs/editions-and-licences.md +190 -0
- package/docs/getting-started.md +201 -0
- package/docs/guides/form-triggers.md +260 -0
- package/docs/guides/mongodb-storage-adapter.md +282 -0
- package/docs/guides/multi-page-forms.md +121 -0
- package/docs/images/scribe-composer.png +0 -0
- package/docs/markdown-shortcodes.md +1738 -0
- package/docs/pentest-report-2026-04-03.md +181 -0
- package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
- package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
- package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
- package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
- package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
- package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
- package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
- package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
- package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
- package/docs/plans/2026-03-05-user-types-design.md +139 -0
- package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
- package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
- package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
- package/docs/plans/2026-06-10-core-project-design.md +240 -0
- package/docs/plugin-development.md +827 -0
- package/docs/recruitment-recipe.md +501 -0
- package/docs/scaffolding.md +312 -0
- package/docs/screenshots/calculator-floating.png +0 -0
- package/docs/screenshots/calculator-standard.png +0 -0
- package/docs/screenshots/email-settings.png +0 -0
- package/docs/screenshots/messages-count.png +0 -0
- package/docs/structure/user-types.md +107 -0
- package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
- package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
- package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
- package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
- package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
- package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
- package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
- package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
- package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
- package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
- package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
- package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
- package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
- package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
- package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
- package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
- package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
- package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
- package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
- package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
- package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
- package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
- package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
- package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
- package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
- package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
- package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
- package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
- package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
- package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
- package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
- package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
- package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
- package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
- package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
- package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
- package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
- package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
- package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
- package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
- package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
- package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
- package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
- package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
- package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
- package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
- package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
- package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
- package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
- package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
- package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
- package/docs/theming.md +320 -0
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/keyboard.js +49 -0
- package/plugins/_lib/admin/mail/reader-view.js +4 -2
- package/public/js/celebrations/core/canvas.js +1 -1
- package/public/js/celebrations/index.js +1 -1
- package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
- package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
- package/public/js/celebrations/message-attrs.mjs +1 -0
- package/public/js/celebrations/themes/anniversary.js +1 -0
- package/public/js/celebrations/themes/autumn.js +1 -0
- package/public/js/celebrations/themes/birthday.js +1 -0
- package/public/js/celebrations/themes/butterfly-flight.js +1 -0
- package/public/js/celebrations/themes/calendar.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year.js +1 -0
- package/public/js/celebrations/themes/christmas.js +1 -1
- package/public/js/celebrations/themes/easter.js +1 -0
- package/public/js/celebrations/themes/garden-drawing.js +1 -0
- package/public/js/celebrations/themes/garden.js +1 -0
- package/public/js/celebrations/themes/graduation.js +1 -0
- package/public/js/celebrations/themes/greeting.js +1 -0
- package/public/js/celebrations/themes/make-it-rain.js +1 -0
- package/public/js/celebrations/themes/money-rain.js +1 -0
- package/public/js/celebrations/themes/new-year.js +1 -0
- package/public/js/celebrations/themes/occasion-drawing.js +1 -0
- package/public/js/celebrations/themes/occasions.js +1 -0
- package/public/js/celebrations/themes/rain-control.js +1 -0
- package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
- package/public/js/celebrations/themes/sparkler-font.js +1 -0
- package/public/js/celebrations/themes/sparkler-writing.js +1 -0
- package/public/js/celebrations/themes/summer-garden.js +1 -0
- package/public/js/celebrations/themes/winter.js +1 -0
- package/public/js/celebrations/trait-data.mjs +1 -1
- package/public/js/effects.js +1 -1
- package/scripts/build.js +4 -0
- package/server/services/markdown.js +5 -2
- package/server/templates/page.html +1 -1
|
@@ -0,0 +1,1526 @@
|
|
|
1
|
+
# Calendar Plugin Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build a standalone Calendar plugin with public event pages, iCal feed, RSVP registration, recurring events (materialised occurrences), three admin view modes, two plugin roles, and a `service.js` layer for Jobs integration.
|
|
6
|
+
|
|
7
|
+
**Architecture:** `plugin.js` registers API routes + the `[calendar /]` shortcode at startup; `plugin.public.js` registers SSR public routes at the Fastify root; `service.js` is a plain ES module exporting CRUD helpers that Jobs imports directly. Recurrence is pre-materialised as individual `calendar-occurrences` entries (rolling 12-month window). Seeded assets (form, page, block, views) are created in the `onEnable` lifecycle and removed in `onDisable`.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Fastify 5 (ESM), Domma SPA (admin), `server/services/collections.js` (CRUD), `server/services/renderer.js` (`renderBlogPage`), `server/services/hooks.js` (`registerShortcode`).
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Critical CMS conventions - read before implementing any task
|
|
14
|
+
|
|
15
|
+
- **HTTP in admin views**: Always use `H.get/post/put/delete` - never `fetch()`.
|
|
16
|
+
- **DOM selection**: `$container.find('#id')` - never `document.querySelector()`.
|
|
17
|
+
- **Native DOM element**: `$container.find('#id').get(0)` - never `$container.find('#id')[0]` (returns `undefined`).
|
|
18
|
+
- **Storage**: `S.get/set` - never `localStorage`.
|
|
19
|
+
- **Icons**: `<span data-icon="name">` + call `I.scan()` after DOM updates.
|
|
20
|
+
- **Tables**: `T.create(el, { data, columns })` - pass native DOM element via `.get(0)`.
|
|
21
|
+
- **Toasts**: `E.toast('msg', { type: 'success' })`.
|
|
22
|
+
- **Confirm**: `await E.confirm('message?')`.
|
|
23
|
+
- **Templates**: `templateUrl: '/plugins/calendar/admin/templates/foo.html'` (absolute path).
|
|
24
|
+
- **DOM updates with HTML strings**: Use `$(el).html(htmlString)` - Domma wraps DOMPurify. Avoid setting `.innerHTML` directly on raw DOM elements.
|
|
25
|
+
- **Interactive elements inside HTML strings**: Build them with `document.createElement` + `appendChild` - Domma's `.html()` strips buttons and inputs.
|
|
26
|
+
- **Route registration order in `plugin.public.js`**: Static segments before `/:slug`. Register `/feed.ics` first.
|
|
27
|
+
- **`listEntries` returns `{ entries, total }`** - destructure: `const { entries } = await listEntries(SLUG, { limit: 100000 })`.
|
|
28
|
+
- **Entry shape**: `{ id, data: { ...fields }, meta: { createdAt, updatedAt } }`. Flatten with helper: `const toEvent = e => ({ id: e.id, ...e.data, createdAt: e.meta.createdAt })`.
|
|
29
|
+
- **`escapeHtml`** must be defined in every file that renders untrusted data into HTML strings.
|
|
30
|
+
- **Contacts slug**: Check `plugins/contacts/plugin.js` for the current `CONTACTS_SLUG` constant before using it in RSVP upsert code. It should be `contacts-contacts` after the namespace rename.
|
|
31
|
+
- **Server is always running on port 4096** - never kill or restart it. Use `curl` to verify endpoints.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## File Structure
|
|
36
|
+
|
|
37
|
+
### New files
|
|
38
|
+
```
|
|
39
|
+
plugins/calendar/
|
|
40
|
+
plugin.json
|
|
41
|
+
plugin.js API routes + shortcode registration + onEnable/onDisable
|
|
42
|
+
plugin.public.js Public SSR routes (listing, feed.ics, event page, RSVP POST)
|
|
43
|
+
service.js Exported createEvent/updateEvent/cancelEvent/createOccurrence/etc.
|
|
44
|
+
config.js
|
|
45
|
+
CLAUDE.md
|
|
46
|
+
collections/
|
|
47
|
+
calendar-events/schema.json
|
|
48
|
+
calendar-occurrences/schema.json
|
|
49
|
+
calendar-rsvps/schema.json
|
|
50
|
+
roles/
|
|
51
|
+
calendar-organiser.json
|
|
52
|
+
calendar-admin.json
|
|
53
|
+
forms/
|
|
54
|
+
calendar-rsvp.json
|
|
55
|
+
pages/
|
|
56
|
+
calendar.md
|
|
57
|
+
blocks/
|
|
58
|
+
upcoming-events.html
|
|
59
|
+
templates/
|
|
60
|
+
index.html Public events listing body template
|
|
61
|
+
event.html Public event detail body template (includes inline RSVP form)
|
|
62
|
+
admin/
|
|
63
|
+
views/
|
|
64
|
+
calendar.js Events list with Grid / List / Split modes
|
|
65
|
+
event-editor.js Create / edit event + recurrence + RSVP panels
|
|
66
|
+
rsvps.js RSVPs list
|
|
67
|
+
settings.js Plugin settings form
|
|
68
|
+
templates/
|
|
69
|
+
calendar.html
|
|
70
|
+
event-editor.html
|
|
71
|
+
rsvps.html
|
|
72
|
+
settings.html
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Modified files
|
|
76
|
+
```
|
|
77
|
+
config/plugins.json Add calendar entry (enabled: false initially, true after smoke test)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Task 1: Scaffolding - schemas, roles, forms, pages, blocks, manifest, config
|
|
83
|
+
|
|
84
|
+
**Files:**
|
|
85
|
+
- Create: `plugins/calendar/collections/calendar-events/schema.json`
|
|
86
|
+
- Create: `plugins/calendar/collections/calendar-occurrences/schema.json`
|
|
87
|
+
- Create: `plugins/calendar/collections/calendar-rsvps/schema.json`
|
|
88
|
+
- Create: `plugins/calendar/roles/calendar-organiser.json`
|
|
89
|
+
- Create: `plugins/calendar/roles/calendar-admin.json`
|
|
90
|
+
- Create: `plugins/calendar/forms/calendar-rsvp.json`
|
|
91
|
+
- Create: `plugins/calendar/pages/calendar.md`
|
|
92
|
+
- Create: `plugins/calendar/blocks/upcoming-events.html`
|
|
93
|
+
- Create: `plugins/calendar/plugin.json`
|
|
94
|
+
- Create: `plugins/calendar/config.js`
|
|
95
|
+
- Create: `plugins/calendar/CLAUDE.md`
|
|
96
|
+
|
|
97
|
+
- [ ] **Step 1: Create directory structure**
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
mkdir -p plugins/calendar/collections/calendar-events
|
|
101
|
+
mkdir -p plugins/calendar/collections/calendar-occurrences
|
|
102
|
+
mkdir -p plugins/calendar/collections/calendar-rsvps
|
|
103
|
+
mkdir -p plugins/calendar/roles
|
|
104
|
+
mkdir -p plugins/calendar/forms
|
|
105
|
+
mkdir -p plugins/calendar/pages
|
|
106
|
+
mkdir -p plugins/calendar/blocks
|
|
107
|
+
mkdir -p plugins/calendar/templates
|
|
108
|
+
mkdir -p plugins/calendar/admin/views
|
|
109
|
+
mkdir -p plugins/calendar/admin/templates
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- [ ] **Step 2: Write `plugins/calendar/collections/calendar-events/schema.json`**
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"title": "Calendar Events",
|
|
117
|
+
"slug": "calendar-events",
|
|
118
|
+
"description": "Parent event records with recurrence rules.",
|
|
119
|
+
"plugin": "calendar",
|
|
120
|
+
"bundled": false,
|
|
121
|
+
"fields": [
|
|
122
|
+
{ "key": "title", "label": "Title", "type": "text", "required": true },
|
|
123
|
+
{ "key": "slug", "label": "Slug", "type": "text", "required": true },
|
|
124
|
+
{ "key": "description", "label": "Description", "type": "markdown", "required": false },
|
|
125
|
+
{ "key": "type", "label": "Type", "type": "select", "required": true,
|
|
126
|
+
"options": ["meeting","interview","public-event","webinar","other"] },
|
|
127
|
+
{ "key": "visibility", "label": "Visibility", "type": "select", "required": true,
|
|
128
|
+
"options": ["public","private"] },
|
|
129
|
+
{ "key": "location", "label": "Location", "type": "text", "required": false },
|
|
130
|
+
{ "key": "organiserId", "label": "Organiser ID", "type": "text", "required": true },
|
|
131
|
+
{ "key": "recurrence", "label": "Recurrence", "type": "object", "required": false },
|
|
132
|
+
{ "key": "windowGeneratedTo", "label": "Window Generated To", "type": "datetime", "required": false },
|
|
133
|
+
{ "key": "rsvpEnabled", "label": "RSVP Enabled", "type": "boolean", "required": false },
|
|
134
|
+
{ "key": "rsvpLimit", "label": "RSVP Limit", "type": "number", "required": false },
|
|
135
|
+
{ "key": "status", "label": "Status", "type": "select", "required": true,
|
|
136
|
+
"options": ["active","cancelled"], "default": "active" }
|
|
137
|
+
],
|
|
138
|
+
"api": { "list": true, "get": true, "create": true, "update": true, "delete": true },
|
|
139
|
+
"storage": { "adapter": "file" }
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- [ ] **Step 3: Write `plugins/calendar/collections/calendar-occurrences/schema.json`**
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{
|
|
147
|
+
"title": "Calendar Occurrences",
|
|
148
|
+
"slug": "calendar-occurrences",
|
|
149
|
+
"description": "Materialised event instances (one per occurrence, including recurring).",
|
|
150
|
+
"plugin": "calendar",
|
|
151
|
+
"bundled": false,
|
|
152
|
+
"fields": [
|
|
153
|
+
{ "key": "eventId", "label": "Event ID", "type": "text", "required": false },
|
|
154
|
+
{ "key": "title", "label": "Title", "type": "text", "required": true },
|
|
155
|
+
{ "key": "slug", "label": "Slug", "type": "text", "required": false,
|
|
156
|
+
"notes": "Denormalised from parent event at creation time. Null for Jobs-created standalone occurrences." },
|
|
157
|
+
{ "key": "startAt", "label": "Start", "type": "datetime", "required": true },
|
|
158
|
+
{ "key": "endAt", "label": "End", "type": "datetime", "required": true },
|
|
159
|
+
{ "key": "type", "label": "Type", "type": "select", "required": true,
|
|
160
|
+
"options": ["meeting","interview","public-event","webinar","other"] },
|
|
161
|
+
{ "key": "visibility", "label": "Visibility", "type": "select", "required": true,
|
|
162
|
+
"options": ["public","private"] },
|
|
163
|
+
{ "key": "status", "label": "Status", "type": "select", "required": true,
|
|
164
|
+
"options": ["active","cancelled","rescheduled"], "default": "active" },
|
|
165
|
+
{ "key": "rsvpCount", "label": "RSVP Count", "type": "number", "required": false },
|
|
166
|
+
{ "key": "rsvpLimit", "label": "RSVP Limit", "type": "number", "required": false },
|
|
167
|
+
{ "key": "source", "label": "Source", "type": "text", "required": false }
|
|
168
|
+
],
|
|
169
|
+
"api": { "list": true, "get": true, "create": true, "update": true, "delete": true },
|
|
170
|
+
"storage": { "adapter": "file" }
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- [ ] **Step 4: Write `plugins/calendar/collections/calendar-rsvps/schema.json`**
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{
|
|
178
|
+
"title": "Calendar RSVPs",
|
|
179
|
+
"slug": "calendar-rsvps",
|
|
180
|
+
"description": "RSVP registrations for calendar occurrences.",
|
|
181
|
+
"plugin": "calendar",
|
|
182
|
+
"bundled": false,
|
|
183
|
+
"fields": [
|
|
184
|
+
{ "key": "occurrenceId", "label": "Occurrence ID", "type": "text", "required": true },
|
|
185
|
+
{ "key": "name", "label": "Name", "type": "text", "required": true },
|
|
186
|
+
{ "key": "email", "label": "Email", "type": "text", "required": true },
|
|
187
|
+
{ "key": "status", "label": "Status", "type": "select", "required": true,
|
|
188
|
+
"options": ["confirmed","cancelled"], "default": "confirmed" },
|
|
189
|
+
{ "key": "contactId", "label": "Contact ID", "type": "text", "required": false },
|
|
190
|
+
{ "key": "createdAt", "label": "Created At", "type": "datetime","required": true }
|
|
191
|
+
],
|
|
192
|
+
"api": { "list": true, "get": true, "create": true, "update": true, "delete": true },
|
|
193
|
+
"storage": { "adapter": "file" }
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- [ ] **Step 5: Write role files**
|
|
198
|
+
|
|
199
|
+
`plugins/calendar/roles/calendar-organiser.json`:
|
|
200
|
+
```json
|
|
201
|
+
{
|
|
202
|
+
"name": "calendar-organiser",
|
|
203
|
+
"label": "Calendar Organiser",
|
|
204
|
+
"level": 2,
|
|
205
|
+
"permissions": ["calendar-events","calendar-occurrences"],
|
|
206
|
+
"badgeClass": "badge-info",
|
|
207
|
+
"plugin": "calendar",
|
|
208
|
+
"resources": [
|
|
209
|
+
{ "key": "calendar-events", "label": "Calendar Events", "actions": ["read","create","update","delete"] },
|
|
210
|
+
{ "key": "calendar-occurrences", "label": "Calendar Occurrences", "actions": ["read","update","delete"] }
|
|
211
|
+
]
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`plugins/calendar/roles/calendar-admin.json`:
|
|
216
|
+
```json
|
|
217
|
+
{
|
|
218
|
+
"name": "calendar-admin",
|
|
219
|
+
"label": "Calendar Admin",
|
|
220
|
+
"level": 1,
|
|
221
|
+
"permissions": ["calendar-events","calendar-occurrences","calendar-rsvps","calendar-settings"],
|
|
222
|
+
"badgeClass": "badge-success",
|
|
223
|
+
"plugin": "calendar",
|
|
224
|
+
"resources": [
|
|
225
|
+
{ "key": "calendar-events", "label": "Calendar Events", "actions": ["read","create","update","delete"] },
|
|
226
|
+
{ "key": "calendar-occurrences", "label": "Calendar Occurrences", "actions": ["read","create","update","delete"] },
|
|
227
|
+
{ "key": "calendar-rsvps", "label": "Calendar RSVPs", "actions": ["read","update","delete"] },
|
|
228
|
+
{ "key": "calendar-settings", "label": "Calendar Settings", "actions": ["read","update"] }
|
|
229
|
+
]
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
- [ ] **Step 6: Write `plugins/calendar/forms/calendar-rsvp.json`**
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"slug": "calendar-rsvp",
|
|
238
|
+
"title": "Event RSVP",
|
|
239
|
+
"description": "RSVP registration form for calendar events.",
|
|
240
|
+
"plugin": "calendar",
|
|
241
|
+
"submitAction": "/calendar/rsvp",
|
|
242
|
+
"fields": [
|
|
243
|
+
{ "name": "name", "type": "string", "label": "Name", "required": true, "placeholder": "Your name", "helper": "" },
|
|
244
|
+
{ "name": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@email.com", "helper": "" },
|
|
245
|
+
{ "name": "website", "type": "string", "label": "Website", "required": false, "placeholder": "", "helper": "" }
|
|
246
|
+
],
|
|
247
|
+
"settings": {
|
|
248
|
+
"submitText": "Register",
|
|
249
|
+
"successMessage": "You're registered!",
|
|
250
|
+
"layout": "stacked",
|
|
251
|
+
"honeypot": true,
|
|
252
|
+
"rateLimitPerMinute": 5
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Note: `submitAction` is set at seed time. If `basePath` changes in settings, disable then re-enable the plugin to reseed the form with the new path. The standalone shortcode embed (`[form slug="calendar-rsvp"]`) is **out of scope for v1** - the event detail page renders the RSVP form inline in `event.html`.
|
|
258
|
+
|
|
259
|
+
- [ ] **Step 7: Write `plugins/calendar/pages/calendar.md`**
|
|
260
|
+
|
|
261
|
+
```markdown
|
|
262
|
+
---
|
|
263
|
+
title: Events
|
|
264
|
+
layout: default
|
|
265
|
+
description: Upcoming events
|
|
266
|
+
visibility: public
|
|
267
|
+
plugin: calendar
|
|
268
|
+
---
|
|
269
|
+
[calendar /]
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
- [ ] **Step 8: Write `plugins/calendar/blocks/upcoming-events.html`**
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
[calendar limit="3" /]
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
- [ ] **Step 9: Write `plugins/calendar/plugin.json`**
|
|
279
|
+
|
|
280
|
+
```json
|
|
281
|
+
{
|
|
282
|
+
"name": "calendar",
|
|
283
|
+
"displayName": "Calendar",
|
|
284
|
+
"version": "1.0.0",
|
|
285
|
+
"description": "Public events calendar with RSVP, iCal feed, and recurring events.",
|
|
286
|
+
"author": "Domma CMS",
|
|
287
|
+
"date": "2026-04-07",
|
|
288
|
+
"icon": "calendar",
|
|
289
|
+
"admin": {
|
|
290
|
+
"sidebar": [
|
|
291
|
+
{
|
|
292
|
+
"id": "calendar",
|
|
293
|
+
"text": "Calendar",
|
|
294
|
+
"icon": "calendar",
|
|
295
|
+
"url": "#/plugins/calendar",
|
|
296
|
+
"section": "#/plugins/calendar",
|
|
297
|
+
"items": [
|
|
298
|
+
{ "id": "calendar-events", "text": "Events", "url": "#/plugins/calendar" },
|
|
299
|
+
{ "id": "calendar-rsvps", "text": "RSVPs", "url": "#/plugins/calendar/rsvps" },
|
|
300
|
+
{ "id": "calendar-settings", "text": "Settings", "url": "#/plugins/calendar/settings" }
|
|
301
|
+
]
|
|
302
|
+
}
|
|
303
|
+
],
|
|
304
|
+
"routes": [
|
|
305
|
+
{ "path": "/plugins/calendar", "view": "plugin-calendar", "title": "Calendar - Domma CMS" },
|
|
306
|
+
{ "path": "/plugins/calendar/events/new", "view": "plugin-calendar-editor", "title": "New Event - Domma CMS" },
|
|
307
|
+
{ "path": "/plugins/calendar/events/:id", "view": "plugin-calendar-editor", "title": "Edit Event - Domma CMS" },
|
|
308
|
+
{ "path": "/plugins/calendar/rsvps", "view": "plugin-calendar-rsvps", "title": "Calendar RSVPs - Domma CMS" },
|
|
309
|
+
{ "path": "/plugins/calendar/settings", "view": "plugin-calendar-settings", "title": "Calendar Settings - Domma CMS" }
|
|
310
|
+
],
|
|
311
|
+
"views": {
|
|
312
|
+
"plugin-calendar": { "entry": "calendar/admin/views/calendar.js?v=1", "exportName": "calendarView" },
|
|
313
|
+
"plugin-calendar-editor": { "entry": "calendar/admin/views/event-editor.js?v=1", "exportName": "eventEditorView" },
|
|
314
|
+
"plugin-calendar-rsvps": { "entry": "calendar/admin/views/rsvps.js?v=1", "exportName": "rsvpsView" },
|
|
315
|
+
"plugin-calendar-settings": { "entry": "calendar/admin/views/settings.js?v=1", "exportName": "settingsView" }
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
- [ ] **Step 10: Write `plugins/calendar/config.js`**
|
|
322
|
+
|
|
323
|
+
```js
|
|
324
|
+
export default {
|
|
325
|
+
basePath: '/calendar',
|
|
326
|
+
eventsPerPage: 12,
|
|
327
|
+
rsvpEnabled: true,
|
|
328
|
+
defaultView: 'list',
|
|
329
|
+
occurrenceWindow: 365,
|
|
330
|
+
feedLength: 50,
|
|
331
|
+
storage: { adapter: 'file' }
|
|
332
|
+
};
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
- [ ] **Step 11: Write `plugins/calendar/CLAUDE.md`**
|
|
336
|
+
|
|
337
|
+
```markdown
|
|
338
|
+
# Calendar Plugin - AI Assistant Guide
|
|
339
|
+
|
|
340
|
+
## Purpose
|
|
341
|
+
Public-facing events calendar with RSVP, iCal feed, recurring events, and a `service.js`
|
|
342
|
+
layer for direct import by the Jobs plugin.
|
|
343
|
+
|
|
344
|
+
## Files
|
|
345
|
+
- `plugin.js` - API routes + `[calendar /]` shortcode + `onEnable`/`onDisable` lifecycle
|
|
346
|
+
- `plugin.public.js` - SSR public routes (listing, feed.ics, event page, RSVP POST)
|
|
347
|
+
- `service.js` - `createEvent`, `updateEvent`, `cancelEvent`, `createOccurrence`, `getOccurrence`, `cancelOccurrence`, `generateOccurrences`
|
|
348
|
+
- `config.js` - defaults: `basePath`, `eventsPerPage`, `rsvpEnabled`, `defaultView`, `occurrenceWindow`, `feedLength`
|
|
349
|
+
|
|
350
|
+
## Collections
|
|
351
|
+
| Slug | Purpose |
|
|
352
|
+
|---|---|
|
|
353
|
+
| `calendar-events` | Parent events with recurrence rules |
|
|
354
|
+
| `calendar-occurrences` | Materialised instances (one per occurrence) |
|
|
355
|
+
| `calendar-rsvps` | RSVP registrations |
|
|
356
|
+
|
|
357
|
+
## API Routes (`/api/plugins/calendar`) - all require `authenticate`
|
|
358
|
+
|
|
359
|
+
| Method | Path | Notes |
|
|
360
|
+
|---|---|---|
|
|
361
|
+
| GET/POST | `/events` | List / create parent events |
|
|
362
|
+
| GET/PUT/DELETE | `/events/:id` | Get / update / cancel event |
|
|
363
|
+
| GET | `/occurrences` | List occurrences (`?from=&to=&type=&visibility=&eventId=`) |
|
|
364
|
+
| GET/PUT/DELETE | `/occurrences/:id` | Get / update / cancel single occurrence |
|
|
365
|
+
| GET/PUT/DELETE | `/rsvps` / `/rsvps/:id` | RSVPs |
|
|
366
|
+
| GET/PUT | `/settings` | Plugin settings |
|
|
367
|
+
|
|
368
|
+
## Public Routes (under `basePath`, default `/calendar`)
|
|
369
|
+
| Method | Path | Notes |
|
|
370
|
+
|---|---|---|
|
|
371
|
+
| GET | `/feed.ics` | iCal feed - registered BEFORE event slug route |
|
|
372
|
+
| GET | `/event/:slug` | Event detail + inline RSVP form |
|
|
373
|
+
| GET | `/page/:n` | Paginated listing |
|
|
374
|
+
| GET | `/` | Events listing (page 1) |
|
|
375
|
+
| POST | `/rsvp` | RSVP submission - no auth, honeypot protected |
|
|
376
|
+
|
|
377
|
+
## Roles
|
|
378
|
+
- `calendar-organiser` (level 2) - CRUD own events/occurrences
|
|
379
|
+
- `calendar-admin` (level 1) - full access including RSVPs and settings
|
|
380
|
+
|
|
381
|
+
## Ownership gate pattern
|
|
382
|
+
```js
|
|
383
|
+
const ADMIN_ROLES = ['calendar-admin', 'admin', 'super-admin'];
|
|
384
|
+
if (!ADMIN_ROLES.includes(request.user.role) && event.organiserId !== request.user.id) {
|
|
385
|
+
return reply.code(403).send({ error: 'Forbidden' });
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
Do NOT use `getRoleLevel()` - organiser and base user are both level 2.
|
|
389
|
+
|
|
390
|
+
## Shortcode handler signature
|
|
391
|
+
```js
|
|
392
|
+
options.hooks.registerShortcode('calendar', async (attrStr, body, context) => {
|
|
393
|
+
const attrs = context.parseShortcodeAttrs(attrStr);
|
|
394
|
+
// body is null for [calendar /] (self-closing)
|
|
395
|
+
// return HTML string
|
|
396
|
+
});
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
## Gotchas
|
|
400
|
+
- **Route order**: In `plugin.public.js`, register `/feed.ics` before `/event/:slug` before base `/`.
|
|
401
|
+
- **Occurrence slug**: Denormalised from parent event. Null for Jobs-created occurrences.
|
|
402
|
+
- **Top-up trigger**: Only fires on `GET /occurrences?eventId=<id>` when `to` is within 30 days of `event.windowGeneratedTo`.
|
|
403
|
+
- **Soft cancel**: `DELETE /events/:id` sets `status: cancelled` - no hard deletes.
|
|
404
|
+
- **Jobs integration**: `import { createOccurrence } from '../calendar/service.js'` - one-way dependency.
|
|
405
|
+
- **Seeded assets**: form, page, block, views - seeded via `onEnable`, removed via `onDisable`.
|
|
406
|
+
- **DOM updates**: Use `$(el).html(str)` not raw `.innerHTML` - Domma sanitises via DOMPurify.
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
- [ ] **Step 12: Commit scaffolding**
|
|
410
|
+
|
|
411
|
+
```bash
|
|
412
|
+
git add plugins/calendar/
|
|
413
|
+
git commit -m "feat(calendar): add plugin scaffolding - schemas, roles, forms, pages, blocks, manifest"
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
> **Note - `setupPlugin` auto-seeding:** When Calendar is enabled via the admin UI, `setupPlugin` automatically copies `plugins/calendar/forms/calendar-rsvp.json` → `content/forms/`, `plugins/calendar/pages/calendar.md` → `content/pages/`, `plugins/calendar/roles/*.json` → `content/collections/roles/data.json`, and creates all three collection directories. Creating the files in this task is all that is required - no additional implementation needed. The `onEnable` export (Task 3) handles only blocks and views, which `setupPlugin` does NOT seed.
|
|
417
|
+
>
|
|
418
|
+
> **Note - `teardownPlugin`:** `teardownPlugin` is called automatically by the plugin system on disable. The plugin implementer does NOT write a `teardownPlugin` function - it is handled by the CMS. It removes the seeded page, form, and roles. `onDisable` (Task 3) handles block and views removal - those are NOT touched by `teardownPlugin`.
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Task 2: `service.js` - core business logic
|
|
423
|
+
|
|
424
|
+
**Files:**
|
|
425
|
+
- Create: `plugins/calendar/service.js`
|
|
426
|
+
|
|
427
|
+
Service functions are used by both `plugin.js` (API routes) and Jobs (via direct import). No Fastify, no HTTP - pure collection CRUD.
|
|
428
|
+
|
|
429
|
+
### Dependencies
|
|
430
|
+
|
|
431
|
+
```js
|
|
432
|
+
import {
|
|
433
|
+
listEntries, createEntry, updateEntry, deleteEntry, getEntry
|
|
434
|
+
} from '../../server/services/collections.js';
|
|
435
|
+
import { getConfig } from '../../server/config.js';
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
No Fastify, no `options` object. Config is read via `getConfig()` at call time.
|
|
439
|
+
|
|
440
|
+
**Disabled-state guard:** If Calendar is disabled its collections do not exist and `createEntry` will throw. Callers (e.g. Jobs) must check plugin state before calling:
|
|
441
|
+
|
|
442
|
+
```js
|
|
443
|
+
const calendarEnabled = options.config?.plugins?.calendar?.enabled ?? false;
|
|
444
|
+
if (calendarEnabled) {
|
|
445
|
+
const { createOccurrence } = await import('../calendar/service.js');
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
`service.js` itself does not guard - it trusts callers.
|
|
450
|
+
|
|
451
|
+
- [ ] **Step 1: Write `plugins/calendar/service.js`**
|
|
452
|
+
|
|
453
|
+
Write the full file with these exported constants and functions:
|
|
454
|
+
|
|
455
|
+
**Constants (export these so plugin.js and plugin.public.js can import them):**
|
|
456
|
+
```js
|
|
457
|
+
export const EVENTS_SLUG = 'calendar-events';
|
|
458
|
+
export const OCCURRENCES_SLUG = 'calendar-occurrences';
|
|
459
|
+
export const RSVPS_SLUG = 'calendar-rsvps';
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
**Flatten helpers:**
|
|
463
|
+
```js
|
|
464
|
+
export const toEvent = e => ({ id: e.id, ...e.data, createdAt: e.meta.createdAt, updatedAt: e.meta.updatedAt });
|
|
465
|
+
export const toOccurrence = e => ({ id: e.id, ...e.data, createdAt: e.meta.createdAt, updatedAt: e.meta.updatedAt });
|
|
466
|
+
export const toRsvp = e => ({ id: e.id, ...e.data });
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**`slugify(str)`** - lowercases, replaces non-alphanumeric with `-`, strips leading/trailing `-`.
|
|
470
|
+
|
|
471
|
+
**`generateOccurrences(event, windowDays)`:**
|
|
472
|
+
- Reads `occurrenceWindow` from `getConfig()` as fallback if `windowDays` is undefined
|
|
473
|
+
- Uses `event.startAt` as the series origin; skips past dates relative to today
|
|
474
|
+
- Supports `freq`: `daily`, `weekly`, `monthly`, `yearly`
|
|
475
|
+
- Supports `interval` (default 1), `byday` array (e.g. `['MO','WE']`) for weekly, `until` date string, `count` limit
|
|
476
|
+
- For each generated date, calls `createEntry(OCCURRENCES_SLUG, { eventId, title, slug, startAt, endAt, type, visibility, status: 'active', rsvpCount: 0, rsvpLimit, source: 'calendar' })`
|
|
477
|
+
- After generating, calls `updateEntry(EVENTS_SLUG, event.id, { ...event, windowGeneratedTo: effectiveEnd.toISOString() })`
|
|
478
|
+
- Returns array of created occurrence IDs
|
|
479
|
+
|
|
480
|
+
**`createEvent(data)`:**
|
|
481
|
+
- Derives slug from `data.slug` or `slugify(data.title)`
|
|
482
|
+
- Calls `createEntry(EVENTS_SLUG, { title, slug, description, type, visibility, location, organiserId, recurrence, windowGeneratedTo: null, rsvpEnabled, rsvpLimit, status: 'active', startAt, endAt })`
|
|
483
|
+
- If recurring: calls `generateOccurrences(event, occurrenceWindow)`
|
|
484
|
+
- If one-off: calls `createEntry(OCCURRENCES_SLUG, { eventId, title, slug, startAt, endAt, type, visibility, status: 'active', rsvpCount: 0, rsvpLimit, source: 'calendar' })`
|
|
485
|
+
- Returns flattened event
|
|
486
|
+
|
|
487
|
+
**`updateEvent(id, data)`:**
|
|
488
|
+
- Deletes all occurrences where `eventId === id && startAt > now` (future occurrences only - past preserved)
|
|
489
|
+
- Merges `data` into `existing.data` and calls `updateEntry`
|
|
490
|
+
- If recurring: calls `generateOccurrences(updatedEvent, occurrenceWindow)`
|
|
491
|
+
- Returns updated event
|
|
492
|
+
|
|
493
|
+
**`cancelEvent(id)`:**
|
|
494
|
+
- Sets `status: 'cancelled'` on the parent event
|
|
495
|
+
- Fetches occurrences with `listEntries(OCCURRENCES_SLUG, { limit: 100000 })` - must use high limit to avoid pagination truncation
|
|
496
|
+
- Sets `status: 'cancelled'` on all occurrences where `eventId === id && startAt >= now`
|
|
497
|
+
- Returns cancelled event
|
|
498
|
+
|
|
499
|
+
**`createOccurrence(data)`:**
|
|
500
|
+
- Primary Jobs entry point - creates standalone occurrence with `eventId: data.eventId ?? null`, `slug: data.slug ?? null`, `source: data.source ?? 'calendar'`
|
|
501
|
+
- Returns flattened occurrence
|
|
502
|
+
|
|
503
|
+
**`getOccurrence(id)`:** - calls `getEntry(OCCURRENCES_SLUG, id)`, returns flattened or `null`
|
|
504
|
+
|
|
505
|
+
**`cancelOccurrence(id)`:** - sets `status: 'cancelled'` on a single occurrence, returns updated
|
|
506
|
+
|
|
507
|
+
- [ ] **Step 2: Verify importable**
|
|
508
|
+
|
|
509
|
+
```bash
|
|
510
|
+
node --input-type=module <<'EOF'
|
|
511
|
+
import { createEvent, createOccurrence, generateOccurrences } from './plugins/calendar/service.js';
|
|
512
|
+
console.log(typeof createEvent, typeof createOccurrence, typeof generateOccurrences);
|
|
513
|
+
EOF
|
|
514
|
+
```
|
|
515
|
+
Expected: `function function function`
|
|
516
|
+
|
|
517
|
+
- [ ] **Step 3: Commit**
|
|
518
|
+
|
|
519
|
+
```bash
|
|
520
|
+
git add plugins/calendar/service.js
|
|
521
|
+
git commit -m "feat(calendar): add service.js - createEvent, generateOccurrences, createOccurrence and friends"
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
---
|
|
525
|
+
|
|
526
|
+
## Task 3: `plugin.js` - API routes, shortcode, lifecycle
|
|
527
|
+
|
|
528
|
+
**Files:**
|
|
529
|
+
- Create: `plugins/calendar/plugin.js`
|
|
530
|
+
|
|
531
|
+
All routes under `/api/plugins/calendar`. Shortcode `[calendar /]` registered via `options.hooks.registerShortcode`. `onEnable` / `onDisable` exports seed/remove block and views.
|
|
532
|
+
|
|
533
|
+
- [ ] **Step 1: Write `plugins/calendar/plugin.js`**
|
|
534
|
+
|
|
535
|
+
Structure:
|
|
536
|
+
|
|
537
|
+
```js
|
|
538
|
+
import fs from 'node:fs/promises';
|
|
539
|
+
import path from 'node:path';
|
|
540
|
+
import defaultConfig from './config.js';
|
|
541
|
+
import { listEntries, getEntry, createEntry, updateEntry, deleteEntry } from '../../server/services/collections.js';
|
|
542
|
+
import { getConfig, saveConfig } from '../../server/config.js';
|
|
543
|
+
import {
|
|
544
|
+
EVENTS_SLUG, OCCURRENCES_SLUG, RSVPS_SLUG,
|
|
545
|
+
toEvent, toOccurrence, toRsvp,
|
|
546
|
+
createEvent, updateEvent, cancelEvent,
|
|
547
|
+
createOccurrence, cancelOccurrence, generateOccurrences
|
|
548
|
+
} from './service.js';
|
|
549
|
+
|
|
550
|
+
const ADMIN_ROLES = ['calendar-admin', 'admin', 'super-admin'];
|
|
551
|
+
const isAdmin = user => ADMIN_ROLES.includes(user?.role);
|
|
552
|
+
const isOrg = user => [...ADMIN_ROLES, 'calendar-organiser'].includes(user?.role);
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
**Shortcode registration** (inside the exported Fastify plugin function, after `const settings = ...`):
|
|
556
|
+
|
|
557
|
+
> **IMPORTANT - signature discrepancy:** The spec's shortcode example shows a two-argument signature `(attrs, context)` where `attrs` is pre-parsed. That is WRONG. The CMS hook system always calls handlers as `(attrStr, body, context)` where `attrStr` is the raw attribute string. Always use the three-argument form below and parse with `context.parseShortcodeAttrs(attrStr)`.
|
|
558
|
+
|
|
559
|
+
```js
|
|
560
|
+
const escapeHtml = s => String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
561
|
+
|
|
562
|
+
options.hooks.registerShortcode('calendar', async (attrStr, body, context) => {
|
|
563
|
+
const attrs = context.parseShortcodeAttrs(attrStr);
|
|
564
|
+
const limit = parseInt(attrs.limit ?? settings.eventsPerPage, 10) || 12;
|
|
565
|
+
const typeFilter = attrs.type ?? null;
|
|
566
|
+
try {
|
|
567
|
+
const now = new Date().toISOString();
|
|
568
|
+
const { entries } = await listEntries(OCCURRENCES_SLUG, { limit: 100000, sort: 'startAt', order: 'asc' });
|
|
569
|
+
let upcoming = entries.map(toOccurrence)
|
|
570
|
+
.filter(o => o.visibility === 'public' && o.status === 'active' && o.startAt >= now);
|
|
571
|
+
if (typeFilter) upcoming = upcoming.filter(o => o.type === typeFilter);
|
|
572
|
+
upcoming = upcoming.slice(0, limit);
|
|
573
|
+
if (!upcoming.length) return '<p class="text-muted">No upcoming events.</p>';
|
|
574
|
+
const base = (settings.basePath ?? '/calendar').replace(/\/$/, '');
|
|
575
|
+
const items = upcoming.map(o => {
|
|
576
|
+
const date = new Date(o.startAt).toLocaleDateString('en-GB', { weekday:'short', day:'numeric', month:'short' });
|
|
577
|
+
const href = o.slug ? `${base}/event/${encodeURIComponent(o.slug)}` : null;
|
|
578
|
+
const title = escapeHtml(o.title);
|
|
579
|
+
const type = escapeHtml(o.type);
|
|
580
|
+
return `<div class="card mb-3"><div class="card-body"><div class="text-muted small">${date}</div>`
|
|
581
|
+
+ `<h5 class="card-title">${href ? `<a href="${href}">${title}</a>` : title}</h5>`
|
|
582
|
+
+ (type ? `<span class="badge badge-secondary">${type}</span>` : '')
|
|
583
|
+
+ '</div></div>';
|
|
584
|
+
}).join('');
|
|
585
|
+
return `<div class="calendar-shortcode">${items}</div>`;
|
|
586
|
+
} catch { return ''; }
|
|
587
|
+
});
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
**Routes to implement** (follow blog/plugin.js pattern exactly):
|
|
591
|
+
|
|
592
|
+
Events:
|
|
593
|
+
- `GET /events` - list; organisers see own only, admins see all
|
|
594
|
+
- `POST /events` - calls `createEvent({ ...body, organiserId: uid })`; 201 response
|
|
595
|
+
- `GET /events/:id` - 404 for not-found; 404 for organiser viewing others' events
|
|
596
|
+
- `PUT /events/:id` - ownership gate with `ADMIN_ROLES` named list; read-only if `source === 'jobs'`; calls `updateEvent()`
|
|
597
|
+
- `DELETE /events/:id` - admin-only; calls `cancelEvent()`
|
|
598
|
+
|
|
599
|
+
Occurrences:
|
|
600
|
+
- `GET /occurrences` - filters: `from`, `to`, `type`, `visibility`, `eventId`; top-up logic when `eventId` present and `to` is within 30 days of `event.windowGeneratedTo`; organisers see occurrences from own events only
|
|
601
|
+
- `GET /occurrences/:id` - simple fetch
|
|
602
|
+
- `PUT /occurrences/:id` - ownership gate via parent event's `organiserId`
|
|
603
|
+
- `DELETE /occurrences/:id` - calls `cancelOccurrence()`
|
|
604
|
+
|
|
605
|
+
RSVPs:
|
|
606
|
+
- `GET /rsvps` - filters: `occurrenceId`, `status`; organisers scoped to own events' occurrences
|
|
607
|
+
- `PUT /rsvps/:id` - update status
|
|
608
|
+
- `DELETE /rsvps/:id` - hard delete
|
|
609
|
+
|
|
610
|
+
Settings:
|
|
611
|
+
- `GET /settings` - admin-only; returns current `settings` object
|
|
612
|
+
- `PUT /settings` - admin-only; merges into `config/plugins.json` under `plugins.calendar.settings` using `saveConfig`
|
|
613
|
+
|
|
614
|
+
**Lifecycle exports** (these are named exports from `plugin.js`, NOT Fastify routes):
|
|
615
|
+
|
|
616
|
+
```js
|
|
617
|
+
export async function onEnable({ fastify, services }) {
|
|
618
|
+
// Seed block
|
|
619
|
+
const blockPath = path.resolve('content/blocks/upcoming-events.html');
|
|
620
|
+
try { await fs.access(blockPath); } catch {
|
|
621
|
+
await fs.writeFile(blockPath, '[calendar limit="3" /]\n', 'utf8');
|
|
622
|
+
}
|
|
623
|
+
// Seed views (advisory - for admin Collections UI)
|
|
624
|
+
const views = [
|
|
625
|
+
{ slug: 'calendar-upcoming', label: 'Upcoming Events', collection: 'calendar-occurrences',
|
|
626
|
+
filter: { status: 'active' }, sort: { field: 'startAt', direction: 'asc' } },
|
|
627
|
+
{ slug: 'calendar-past', label: 'Past Events', collection: 'calendar-occurrences',
|
|
628
|
+
filter: {}, sort: { field: 'startAt', direction: 'desc' } },
|
|
629
|
+
{ slug: 'calendar-my-events', label: 'My Events', collection: 'calendar-occurrences',
|
|
630
|
+
filter: {}, sort: { field: 'startAt', direction: 'asc' } }
|
|
631
|
+
];
|
|
632
|
+
for (const view of views) {
|
|
633
|
+
const p = path.resolve(`content/views/${view.slug}.json`);
|
|
634
|
+
try { await fs.access(p); } catch {
|
|
635
|
+
await fs.writeFile(p, JSON.stringify(view, null, 2), 'utf8');
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export async function onDisable({ fastify, services }) {
|
|
641
|
+
await fs.unlink(path.resolve('content/blocks/upcoming-events.html')).catch(() => {});
|
|
642
|
+
for (const slug of ['calendar-upcoming', 'calendar-past', 'calendar-my-events']) {
|
|
643
|
+
await fs.unlink(path.resolve(`content/views/${slug}.json`)).catch(() => {});
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
- [ ] **Step 2: Add calendar to `config/plugins.json`**
|
|
649
|
+
|
|
650
|
+
Open `config/plugins.json` and add: `"calendar": { "enabled": false, "bundled": false, "settings": {} }`
|
|
651
|
+
|
|
652
|
+
- [ ] **Step 3: Smoke-test routes respond**
|
|
653
|
+
|
|
654
|
+
```bash
|
|
655
|
+
curl -s http://localhost:4096/api/plugins/calendar/events \
|
|
656
|
+
-H "Authorization: Bearer INVALID_TOKEN" | grep -o '"error"\|"message"'
|
|
657
|
+
```
|
|
658
|
+
Expected: `"error"` or `"message"` (not a 404 on the route itself - the plugin is loaded)
|
|
659
|
+
|
|
660
|
+
- [ ] **Step 4: Commit**
|
|
661
|
+
|
|
662
|
+
```bash
|
|
663
|
+
git add plugins/calendar/plugin.js config/plugins.json
|
|
664
|
+
git commit -m "feat(calendar): add plugin.js - API routes, shortcode registration, onEnable/onDisable lifecycle"
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## Task 4: `plugin.public.js` - public SSR routes
|
|
670
|
+
|
|
671
|
+
**Files:**
|
|
672
|
+
- Create: `plugins/calendar/plugin.public.js`
|
|
673
|
+
|
|
674
|
+
Receives `options = { settings, auth: { authenticate, requireAdmin }, config }`.
|
|
675
|
+
`basePath` resolved as `(options.settings?.basePath ?? '/calendar').replace(/\/$/, '')`.
|
|
676
|
+
|
|
677
|
+
**CRITICAL: Route registration order** - register `feed.ics` FIRST, then `rsvp`, then `/event/:slug`, then paginated `/page/:n`, then base `/`. Fastify matches in registration order; `/event/:slug` registered last (of the static routes) to avoid swallowing the feed.
|
|
678
|
+
|
|
679
|
+
- [ ] **Step 1: Before writing - read `server/services/renderer.js`**
|
|
680
|
+
|
|
681
|
+
Open `server/services/renderer.js` and find `renderBlogPage`. Confirm its signature (typically `renderBlogPage(templatePath, data, seoMeta, reply)`). The Blog plugin uses it for plugin SSR pages; Calendar follows the same pattern. If the signature differs from what the plan describes, adjust the calls accordingly.
|
|
682
|
+
|
|
683
|
+
- [ ] **Step 2: Write `plugins/calendar/plugin.public.js`**
|
|
684
|
+
|
|
685
|
+
Imports:
|
|
686
|
+
```js
|
|
687
|
+
import path from 'node:path';
|
|
688
|
+
import { fileURLToPath } from 'url';
|
|
689
|
+
import { renderBlogPage } from '../../server/services/renderer.js';
|
|
690
|
+
import { listEntries, getEntry, createEntry, updateEntry } from '../../server/services/collections.js';
|
|
691
|
+
import { getConfig } from '../../server/config.js';
|
|
692
|
+
import { EVENTS_SLUG, OCCURRENCES_SLUG, RSVPS_SLUG, toOccurrence } from './service.js';
|
|
693
|
+
|
|
694
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
**Helpers:**
|
|
698
|
+
```js
|
|
699
|
+
function escapeHtml(s) { return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
700
|
+
function toIcalDate(iso) { return iso.replace(/[-:]/g,'').replace(/\.\d{3}/,''); }
|
|
701
|
+
const isPublicActive = o => o.visibility === 'public' && o.status === 'active';
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
**`GET {base}/feed.ics`** - iCal 2.0 feed:
|
|
705
|
+
- Fetch public active occurrences (sorted by `startAt`, limited to `feedLength`)
|
|
706
|
+
- For each occurrence build a `VEVENT` with: `UID:<id>@<hostname>`, `DTSTAMP:<now>`, `DTSTART`, `DTEND`, `SUMMARY`, `DESCRIPTION`, `LOCATION` (if set), `URL:<siteUrl>/calendar/event/<slug>` (omit if `slug` is null)
|
|
707
|
+
- Wrap in `BEGIN:VCALENDAR` / `END:VCALENDAR` with `CRLF` line endings
|
|
708
|
+
- Reply with `Content-Type: text/calendar`
|
|
709
|
+
|
|
710
|
+
**`POST {base}/rsvp`** - RSVP submission:
|
|
711
|
+
1. Honeypot check: if `website` field non-empty → return 201 silently
|
|
712
|
+
2. Validate `occurrenceId`, `name`, `email` - return 400 if missing
|
|
713
|
+
3. Fetch occurrence - 404 if not found
|
|
714
|
+
4. Capacity: if `rsvpLimit` set and `rsvpCount >= rsvpLimit` → 409
|
|
715
|
+
5. Duplicate: check `calendar-rsvps` for same `occurrenceId` + `email` + `status: confirmed` → 200 if found
|
|
716
|
+
6. Build `rsvpData = { occurrenceId, name, email, status: 'confirmed', contactId: null, createdAt: new Date().toISOString() }`
|
|
717
|
+
7. Contacts upsert: if `getConfig()?.plugins?.calendar?.enabled && getConfig()?.plugins?.contacts?.enabled` → check `contacts-contacts` for matching email; upsert (verify slug constant first); store `contactId`
|
|
718
|
+
8. `createEntry(RSVPS_SLUG, rsvpData)`
|
|
719
|
+
9. Increment `rsvpCount` on occurrence via `updateEntry`
|
|
720
|
+
10. If `Accept` header includes `text/html` → redirect to `{base}/event/{slug}?rsvp=success`; else return `201 { ok: true }`
|
|
721
|
+
|
|
722
|
+
**`GET {base}/event/:slug`** - event detail page:
|
|
723
|
+
- List occurrences, find first public active one matching the slug
|
|
724
|
+
- 404 if not found
|
|
725
|
+
- If `eventId` is set, fetch parent event for `description` and `location`
|
|
726
|
+
- Call `renderBlogPage(path.join(templateDir, 'event.html'), { occ, description, location, ... }, seoMeta, reply)`
|
|
727
|
+
|
|
728
|
+
**Listing helper `renderListing(reply, page)`:**
|
|
729
|
+
- List public active occurrences with `startAt >= now`, sorted ascending
|
|
730
|
+
- Paginate with `eventsPerPage`
|
|
731
|
+
- Call `renderBlogPage(path.join(templateDir, 'index.html'), { events, page, totalPages, base }, { title: 'Events' }, reply)`
|
|
732
|
+
|
|
733
|
+
**`GET {base}/page/:n`** → `renderListing(reply, parseInt(n))`
|
|
734
|
+
|
|
735
|
+
**`GET {base}`** → `renderListing(reply, 1)`
|
|
736
|
+
|
|
737
|
+
- [ ] **Step 3: Verify public plugin exports a default function**
|
|
738
|
+
|
|
739
|
+
```bash
|
|
740
|
+
node --input-type=module <<'EOF'
|
|
741
|
+
import plugin from './plugins/calendar/plugin.public.js';
|
|
742
|
+
console.log(typeof plugin);
|
|
743
|
+
EOF
|
|
744
|
+
```
|
|
745
|
+
Expected: `function`
|
|
746
|
+
|
|
747
|
+
- [ ] **Step 4: Commit**
|
|
748
|
+
|
|
749
|
+
```bash
|
|
750
|
+
git add plugins/calendar/plugin.public.js
|
|
751
|
+
git commit -m "feat(calendar): add plugin.public.js - public listing, iCal feed, event page, RSVP POST"
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
---
|
|
755
|
+
|
|
756
|
+
## Task 5: Public body templates
|
|
757
|
+
|
|
758
|
+
**Files:**
|
|
759
|
+
- Create: `plugins/calendar/templates/index.html`
|
|
760
|
+
- Create: `plugins/calendar/templates/event.html`
|
|
761
|
+
|
|
762
|
+
**CRITICAL: Before writing these, read how `renderBlogPage` works:**
|
|
763
|
+
|
|
764
|
+
```bash
|
|
765
|
+
sed -n '/export async function renderBlogPage/,/^}/p' server/services/renderer.js | head -60
|
|
766
|
+
cat plugins/blog/templates/index.html
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
Follow the exact same pattern that the blog plugin uses. The `data` object passed to `renderBlogPage` is used to construct the HTML body string - the exact mechanism (string interpolation, template engine, etc.) depends on what `renderBlogPage` does. Match the blog's approach exactly.
|
|
770
|
+
|
|
771
|
+
- [ ] **Step 1: Read blog's `renderBlogPage` implementation and `plugins/blog/templates/index.html`**
|
|
772
|
+
|
|
773
|
+
Run:
|
|
774
|
+
```bash
|
|
775
|
+
sed -n '/export async function renderBlogPage/,/^}/p' server/services/renderer.js | head -80
|
|
776
|
+
cat plugins/blog/templates/index.html
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
- [ ] **Step 2: Write `plugins/calendar/templates/index.html`**
|
|
780
|
+
|
|
781
|
+
Build to match the blog template pattern. Include:
|
|
782
|
+
- Page heading "Events"
|
|
783
|
+
- List/grid of event cards: title (linked to `/calendar/event/{slug}` if slug exists), date, time, type badge, location (if set)
|
|
784
|
+
- Pagination nav (Previous / Next links when `totalPages > 1`)
|
|
785
|
+
- "No upcoming events" empty state
|
|
786
|
+
|
|
787
|
+
- [ ] **Step 3: Write `plugins/calendar/templates/event.html`**
|
|
788
|
+
|
|
789
|
+
Include:
|
|
790
|
+
- Event title as `<h1>`
|
|
791
|
+
- Date / time formatted as human-readable string
|
|
792
|
+
- Location (if set)
|
|
793
|
+
- Description rendered as HTML (check how blog renders Markdown in public templates)
|
|
794
|
+
- RSVP success banner (shown when `?rsvp=success` query param is present - check the `rsvpSuccess` data field)
|
|
795
|
+
- Inline RSVP form when `showRsvp` is true and `atCapacity` is false:
|
|
796
|
+
|
|
797
|
+
```html
|
|
798
|
+
<form method="POST" action="{base}/rsvp">
|
|
799
|
+
<input type="hidden" name="occurrenceId" value="{occ.id}">
|
|
800
|
+
<div class="mb-3">
|
|
801
|
+
<label>Name *</label>
|
|
802
|
+
<input type="text" name="name" required class="form-control">
|
|
803
|
+
</div>
|
|
804
|
+
<div class="mb-3">
|
|
805
|
+
<label>Email *</label>
|
|
806
|
+
<input type="email" name="email" required class="form-control">
|
|
807
|
+
</div>
|
|
808
|
+
<div style="display:none">
|
|
809
|
+
<label>Website<br><input type="text" name="website"></label>
|
|
810
|
+
</div>
|
|
811
|
+
<button type="submit" class="btn btn-primary">Register</button>
|
|
812
|
+
</form>
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
- "At capacity" message when `atCapacity` is true
|
|
816
|
+
- Current RSVP count when `rsvpCount > 0`
|
|
817
|
+
|
|
818
|
+
- [ ] **Step 4: Enable calendar and smoke-test public routes**
|
|
819
|
+
|
|
820
|
+
Set `"calendar": { "enabled": true }` in `config/plugins.json`, then:
|
|
821
|
+
|
|
822
|
+
```bash
|
|
823
|
+
# Public listing
|
|
824
|
+
curl -s http://localhost:4096/calendar | grep -c '<title>'
|
|
825
|
+
|
|
826
|
+
# iCal feed
|
|
827
|
+
curl -s http://localhost:4096/calendar/feed.ics | head -3
|
|
828
|
+
```
|
|
829
|
+
Expected for feed:
|
|
830
|
+
```
|
|
831
|
+
BEGIN:VCALENDAR
|
|
832
|
+
VERSION:2.0
|
|
833
|
+
PRODID:-//Domma CMS//Calendar//EN
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
- [ ] **Step 5: Commit**
|
|
837
|
+
|
|
838
|
+
```bash
|
|
839
|
+
git add plugins/calendar/templates/
|
|
840
|
+
git commit -m "feat(calendar): add public body templates - events listing and event detail with RSVP form"
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
---
|
|
844
|
+
|
|
845
|
+
## Task 6: Admin HTML templates
|
|
846
|
+
|
|
847
|
+
**Files:**
|
|
848
|
+
- Create: `plugins/calendar/admin/templates/calendar.html`
|
|
849
|
+
- Create: `plugins/calendar/admin/templates/event-editor.html`
|
|
850
|
+
- Create: `plugins/calendar/admin/templates/rsvps.html`
|
|
851
|
+
- Create: `plugins/calendar/admin/templates/settings.html`
|
|
852
|
+
|
|
853
|
+
Admin templates are static HTML shells - all dynamic content is injected by the corresponding `.js` view using Domma APIs. Follow the blog plugin template pattern (`plugins/blog/admin/templates/blog.html`).
|
|
854
|
+
|
|
855
|
+
- [ ] **Step 1: Write `plugins/calendar/admin/templates/calendar.html`**
|
|
856
|
+
|
|
857
|
+
```html
|
|
858
|
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
859
|
+
<h2 class="mb-0">Events</h2>
|
|
860
|
+
<div class="d-flex gap-2 align-items-center">
|
|
861
|
+
<div class="btn-group" role="group">
|
|
862
|
+
<button id="btn-view-grid" class="btn btn-sm btn-outline" title="Grid view">
|
|
863
|
+
<span data-icon="grid" data-icon-size="14"></span>
|
|
864
|
+
</button>
|
|
865
|
+
<button id="btn-view-list" class="btn btn-sm btn-outline" title="List view">
|
|
866
|
+
<span data-icon="list" data-icon-size="14"></span>
|
|
867
|
+
</button>
|
|
868
|
+
<button id="btn-view-split" class="btn btn-sm btn-outline" title="Split view">
|
|
869
|
+
<span data-icon="columns" data-icon-size="14"></span>
|
|
870
|
+
</button>
|
|
871
|
+
</div>
|
|
872
|
+
<button id="btn-new-event" class="btn btn-sm btn-primary">
|
|
873
|
+
<span data-icon="plus" data-icon-size="14"></span> New Event
|
|
874
|
+
</button>
|
|
875
|
+
</div>
|
|
876
|
+
</div>
|
|
877
|
+
|
|
878
|
+
<div class="d-flex gap-2 mb-3">
|
|
879
|
+
<select id="filter-type" class="form-control form-control-sm" style="width:auto;">
|
|
880
|
+
<option value="">All types</option>
|
|
881
|
+
<option value="meeting">Meeting</option>
|
|
882
|
+
<option value="interview">Interview</option>
|
|
883
|
+
<option value="public-event">Public Event</option>
|
|
884
|
+
<option value="webinar">Webinar</option>
|
|
885
|
+
<option value="other">Other</option>
|
|
886
|
+
</select>
|
|
887
|
+
<select id="filter-visibility" class="form-control form-control-sm" style="width:auto;">
|
|
888
|
+
<option value="">All visibility</option>
|
|
889
|
+
<option value="public">Public</option>
|
|
890
|
+
<option value="private">Private</option>
|
|
891
|
+
</select>
|
|
892
|
+
</div>
|
|
893
|
+
|
|
894
|
+
<!-- Grid view -->
|
|
895
|
+
<div id="view-grid" style="display:none;">
|
|
896
|
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
897
|
+
<button id="btn-prev-month" class="btn btn-sm btn-outline">«</button>
|
|
898
|
+
<strong id="grid-month-label"></strong>
|
|
899
|
+
<button id="btn-next-month" class="btn btn-sm btn-outline">»</button>
|
|
900
|
+
</div>
|
|
901
|
+
<div id="calendar-grid"></div>
|
|
902
|
+
</div>
|
|
903
|
+
|
|
904
|
+
<!-- List view -->
|
|
905
|
+
<div id="view-list" style="display:none;">
|
|
906
|
+
<div id="events-table"></div>
|
|
907
|
+
</div>
|
|
908
|
+
|
|
909
|
+
<!-- Split view -->
|
|
910
|
+
<div id="view-split" style="display:none;">
|
|
911
|
+
<div class="d-flex gap-3">
|
|
912
|
+
<div style="width:240px;flex-shrink:0;">
|
|
913
|
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
914
|
+
<button id="btn-split-prev" class="btn btn-sm btn-outline">«</button>
|
|
915
|
+
<strong id="split-month-label"></strong>
|
|
916
|
+
<button id="btn-split-next" class="btn btn-sm btn-outline">»</button>
|
|
917
|
+
</div>
|
|
918
|
+
<div id="split-mini-cal"></div>
|
|
919
|
+
</div>
|
|
920
|
+
<div style="flex:1;">
|
|
921
|
+
<div id="split-day-label" class="text-muted small mb-2"></div>
|
|
922
|
+
<div id="split-day-events"></div>
|
|
923
|
+
</div>
|
|
924
|
+
</div>
|
|
925
|
+
</div>
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
- [ ] **Step 2: Write `plugins/calendar/admin/templates/event-editor.html`**
|
|
929
|
+
|
|
930
|
+
```html
|
|
931
|
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
932
|
+
<h2 class="mb-0" id="editor-heading">New Event</h2>
|
|
933
|
+
<div class="d-flex gap-2">
|
|
934
|
+
<button id="btn-cancel" class="btn btn-sm btn-outline">Cancel</button>
|
|
935
|
+
<button id="btn-save" class="btn btn-sm btn-primary">Save Event</button>
|
|
936
|
+
</div>
|
|
937
|
+
</div>
|
|
938
|
+
|
|
939
|
+
<div id="jobs-notice" style="display:none;" class="alert alert-info mb-3">
|
|
940
|
+
This event is managed by Jobs and is read-only.
|
|
941
|
+
</div>
|
|
942
|
+
|
|
943
|
+
<div class="d-flex gap-3" id="editor-body">
|
|
944
|
+
<div style="flex:1;">
|
|
945
|
+
<div class="mb-3">
|
|
946
|
+
<label class="form-label">Title *</label>
|
|
947
|
+
<input id="field-title" type="text" class="form-control" placeholder="Event title">
|
|
948
|
+
</div>
|
|
949
|
+
<div class="mb-3">
|
|
950
|
+
<label class="form-label">Slug</label>
|
|
951
|
+
<div class="d-flex gap-2 align-items-center">
|
|
952
|
+
<input id="field-slug" type="text" class="form-control" placeholder="auto-derived">
|
|
953
|
+
<label style="white-space:nowrap;font-size:12px;">
|
|
954
|
+
<input type="checkbox" id="slug-auto" checked> Auto
|
|
955
|
+
</label>
|
|
956
|
+
</div>
|
|
957
|
+
</div>
|
|
958
|
+
<div class="mb-3">
|
|
959
|
+
<label class="form-label">Description (Markdown)</label>
|
|
960
|
+
<textarea id="field-description" class="form-control" rows="5" placeholder="Optional description"></textarea>
|
|
961
|
+
</div>
|
|
962
|
+
<div class="d-flex gap-2 mb-3">
|
|
963
|
+
<div style="flex:1;">
|
|
964
|
+
<label class="form-label">Type</label>
|
|
965
|
+
<select id="field-type" class="form-control">
|
|
966
|
+
<option value="meeting">Meeting</option>
|
|
967
|
+
<option value="interview">Interview</option>
|
|
968
|
+
<option value="public-event">Public Event</option>
|
|
969
|
+
<option value="webinar">Webinar</option>
|
|
970
|
+
<option value="other">Other</option>
|
|
971
|
+
</select>
|
|
972
|
+
</div>
|
|
973
|
+
<div style="flex:1;">
|
|
974
|
+
<label class="form-label">Visibility</label>
|
|
975
|
+
<select id="field-visibility" class="form-control">
|
|
976
|
+
<option value="private">Private</option>
|
|
977
|
+
<option value="public">Public</option>
|
|
978
|
+
</select>
|
|
979
|
+
</div>
|
|
980
|
+
</div>
|
|
981
|
+
<div class="mb-3">
|
|
982
|
+
<label class="form-label">Location</label>
|
|
983
|
+
<input id="field-location" type="text" class="form-control" placeholder="Address or video URL">
|
|
984
|
+
</div>
|
|
985
|
+
<div class="d-flex gap-2 mb-3">
|
|
986
|
+
<div style="flex:1;">
|
|
987
|
+
<label class="form-label">Start *</label>
|
|
988
|
+
<input id="field-start" type="datetime-local" class="form-control">
|
|
989
|
+
</div>
|
|
990
|
+
<div style="flex:1;">
|
|
991
|
+
<label class="form-label">End *</label>
|
|
992
|
+
<input id="field-end" type="datetime-local" class="form-control">
|
|
993
|
+
</div>
|
|
994
|
+
</div>
|
|
995
|
+
</div>
|
|
996
|
+
<div style="width:220px;flex-shrink:0;">
|
|
997
|
+
<div class="card mb-3">
|
|
998
|
+
<div class="card-header" style="font-size:12px;font-weight:600;">Recurrence</div>
|
|
999
|
+
<div class="card-body" style="font-size:12px;">
|
|
1000
|
+
<label class="d-flex align-items-center gap-2 mb-2">
|
|
1001
|
+
<input type="checkbox" id="recurrence-enabled"> Recurring event
|
|
1002
|
+
</label>
|
|
1003
|
+
<div id="recurrence-fields" style="display:none;">
|
|
1004
|
+
<div class="mb-2">
|
|
1005
|
+
<label>Frequency</label>
|
|
1006
|
+
<select id="field-freq" class="form-control form-control-sm">
|
|
1007
|
+
<option value="daily">Daily</option>
|
|
1008
|
+
<option value="weekly" selected>Weekly</option>
|
|
1009
|
+
<option value="monthly">Monthly</option>
|
|
1010
|
+
<option value="yearly">Yearly</option>
|
|
1011
|
+
</select>
|
|
1012
|
+
</div>
|
|
1013
|
+
<div class="mb-2">
|
|
1014
|
+
<label>Every</label>
|
|
1015
|
+
<input id="field-interval" type="number" min="1" value="1" class="form-control form-control-sm">
|
|
1016
|
+
</div>
|
|
1017
|
+
<div id="byday-section" class="mb-2">
|
|
1018
|
+
<label>On days</label>
|
|
1019
|
+
<div class="d-flex gap-1 flex-wrap" id="byday-checks">
|
|
1020
|
+
<label><input type="checkbox" value="MO"> Mo</label>
|
|
1021
|
+
<label><input type="checkbox" value="TU"> Tu</label>
|
|
1022
|
+
<label><input type="checkbox" value="WE"> We</label>
|
|
1023
|
+
<label><input type="checkbox" value="TH"> Th</label>
|
|
1024
|
+
<label><input type="checkbox" value="FR"> Fr</label>
|
|
1025
|
+
<label><input type="checkbox" value="SA"> Sa</label>
|
|
1026
|
+
<label><input type="checkbox" value="SU"> Su</label>
|
|
1027
|
+
</div>
|
|
1028
|
+
</div>
|
|
1029
|
+
<div class="mb-2">
|
|
1030
|
+
<label>Until</label>
|
|
1031
|
+
<input id="field-until" type="date" class="form-control form-control-sm">
|
|
1032
|
+
</div>
|
|
1033
|
+
</div>
|
|
1034
|
+
</div>
|
|
1035
|
+
</div>
|
|
1036
|
+
<div class="card">
|
|
1037
|
+
<div class="card-header" style="font-size:12px;font-weight:600;">RSVP</div>
|
|
1038
|
+
<div class="card-body" style="font-size:12px;">
|
|
1039
|
+
<label class="d-flex align-items-center gap-2 mb-2">
|
|
1040
|
+
<input type="checkbox" id="rsvp-enabled"> Accept RSVPs
|
|
1041
|
+
</label>
|
|
1042
|
+
<div id="rsvp-fields" style="display:none;">
|
|
1043
|
+
<label>Capacity (blank = unlimited)</label>
|
|
1044
|
+
<input id="field-rsvp-limit" type="number" min="1" class="form-control form-control-sm" placeholder="Unlimited">
|
|
1045
|
+
</div>
|
|
1046
|
+
</div>
|
|
1047
|
+
</div>
|
|
1048
|
+
</div>
|
|
1049
|
+
</div>
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
- [ ] **Step 3: Write `plugins/calendar/admin/templates/rsvps.html`**
|
|
1053
|
+
|
|
1054
|
+
```html
|
|
1055
|
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
1056
|
+
<h2 class="mb-0">RSVPs</h2>
|
|
1057
|
+
</div>
|
|
1058
|
+
<div class="d-flex gap-2 mb-3">
|
|
1059
|
+
<select id="filter-occurrence" class="form-control form-control-sm" style="max-width:320px;">
|
|
1060
|
+
<option value="">All events</option>
|
|
1061
|
+
</select>
|
|
1062
|
+
<select id="filter-status" class="form-control form-control-sm" style="width:auto;">
|
|
1063
|
+
<option value="">All statuses</option>
|
|
1064
|
+
<option value="confirmed">Confirmed</option>
|
|
1065
|
+
<option value="cancelled">Cancelled</option>
|
|
1066
|
+
</select>
|
|
1067
|
+
</div>
|
|
1068
|
+
<div id="rsvps-table"></div>
|
|
1069
|
+
```
|
|
1070
|
+
|
|
1071
|
+
- [ ] **Step 4: Write `plugins/calendar/admin/templates/settings.html`**
|
|
1072
|
+
|
|
1073
|
+
```html
|
|
1074
|
+
<div class="mb-3">
|
|
1075
|
+
<h2>Settings</h2>
|
|
1076
|
+
</div>
|
|
1077
|
+
<div class="card" style="max-width:520px;">
|
|
1078
|
+
<div class="card-body">
|
|
1079
|
+
<div id="settings-form"></div>
|
|
1080
|
+
</div>
|
|
1081
|
+
</div>
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
- [ ] **Step 5: Commit**
|
|
1085
|
+
|
|
1086
|
+
```bash
|
|
1087
|
+
git add plugins/calendar/admin/templates/
|
|
1088
|
+
git commit -m "feat(calendar): add admin HTML templates"
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
---
|
|
1092
|
+
|
|
1093
|
+
## Task 7: Admin view: `calendar.js` - events list (Grid, List, Split)
|
|
1094
|
+
|
|
1095
|
+
**Files:**
|
|
1096
|
+
- Create: `plugins/calendar/admin/views/calendar.js`
|
|
1097
|
+
|
|
1098
|
+
This is the most complex admin view. Manages three rendering modes, month navigation for Grid/Split, and a filter bar. All event data fetched via `H.get`. View mode preference persisted via `S.set('calendar_view', mode)`.
|
|
1099
|
+
|
|
1100
|
+
**Key implementation notes:**
|
|
1101
|
+
- Use `T.create()` for the List mode - same pattern as `plugins/blog/admin/views/blog.js`
|
|
1102
|
+
- For Grid and Split calendar grids: build HTML strings with `escapeHtml()` for all user data, then use `$(el).html(htmlString)` to update the DOM - this goes through Domma's DOMPurify sanitisation
|
|
1103
|
+
- For Split day events: same pattern - build HTML string, set via `$(el).html(str)`
|
|
1104
|
+
- Interactive elements (edit buttons in Split view): build with `document.createElement` and `appendChild`, or use event delegation on the container after setting HTML (since `$(el).html()` strips buttons)
|
|
1105
|
+
- For the Split day events panel buttons specifically: render event cards as plain HTML (no buttons inside) and add separate DOM-built action buttons, OR navigate via `<a href="...">` links instead of buttons
|
|
1106
|
+
|
|
1107
|
+
- [ ] **Step 1: Write `plugins/calendar/admin/views/calendar.js`**
|
|
1108
|
+
|
|
1109
|
+
Export `calendarView = { templateUrl: '/plugins/calendar/admin/templates/calendar.html', async onMount($container) { ... } }`.
|
|
1110
|
+
|
|
1111
|
+
**`onMount` structure:**
|
|
1112
|
+
|
|
1113
|
+
```js
|
|
1114
|
+
async onMount($container) {
|
|
1115
|
+
let allEvents = [], allOccurrences = [];
|
|
1116
|
+
let eventsTable = null;
|
|
1117
|
+
let gridMonth = new Date(); gridMonth.setDate(1);
|
|
1118
|
+
let splitSelectedDate = new Date().toISOString().slice(0, 10);
|
|
1119
|
+
|
|
1120
|
+
const BASE = '/api/plugins/calendar';
|
|
1121
|
+
|
|
1122
|
+
function escapeHtml(s) { return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
1123
|
+
|
|
1124
|
+
async function loadData() { /* H.get events + occurrences */ }
|
|
1125
|
+
|
|
1126
|
+
// View mode
|
|
1127
|
+
let currentMode = S.get('calendar_view') ?? 'list';
|
|
1128
|
+
function activateMode(mode) {
|
|
1129
|
+
currentMode = mode;
|
|
1130
|
+
S.set('calendar_view', mode);
|
|
1131
|
+
// Hide all view containers
|
|
1132
|
+
['grid','list','split'].forEach(m => {
|
|
1133
|
+
const el = $container.find(`#view-${m}`).get(0);
|
|
1134
|
+
if (el) el.style.display = m === mode ? '' : 'none';
|
|
1135
|
+
});
|
|
1136
|
+
// Update button styles
|
|
1137
|
+
['grid','list','split'].forEach(m => {
|
|
1138
|
+
const btn = $container.find(`#btn-view-${m}`).get(0);
|
|
1139
|
+
if (!btn) return;
|
|
1140
|
+
btn.className = `btn btn-sm ${m === mode ? 'btn-primary' : 'btn-outline'}`;
|
|
1141
|
+
});
|
|
1142
|
+
renderCurrentMode();
|
|
1143
|
+
}
|
|
1144
|
+
```
|
|
1145
|
+
|
|
1146
|
+
**List mode (`renderList`):**
|
|
1147
|
+
- Use `T.create()` with columns: title (linked), next occurrence date, type badge, visibility badge, RSVP count, actions (Edit link, Delete button)
|
|
1148
|
+
- Type badge colours: `meeting→badge-info`, `interview→badge-warning`, `public-event→badge-success`, `webinar→badge-primary`, `other→badge-secondary`
|
|
1149
|
+
- `nextOccurrence(eventId)` helper: finds first occurrence where `eventId` matches, `startAt >= now`, `status === 'active'`, sorted ascending
|
|
1150
|
+
- Guard: `if (eventsTable) { eventsTable.setData(filteredEvents); return; }` to avoid duplicate tables
|
|
1151
|
+
- Delete via event delegation on `tableEl`: confirm with `E.confirm()`, call `H.delete`, reload data, call `eventsTable.setData()`
|
|
1152
|
+
|
|
1153
|
+
**Grid mode (`renderGrid`):**
|
|
1154
|
+
- Build 7-column CSS grid HTML string (Monday-first week)
|
|
1155
|
+
- Day cells: show date number + up to 3 occurrence badges (type-coloured) + "+N more" if more
|
|
1156
|
+
- Use CSS custom properties for theming: `var(--dm-bg)`, `var(--dm-text)`, `var(--dm-primary)`, `var(--dm-border-color)`, `var(--dm-text-muted)`, `var(--dm-bg-subtle)`
|
|
1157
|
+
- Highlight today's date with primary colour border/weight
|
|
1158
|
+
- After building the HTML string: `$(gridEl).html(html)` - this goes through DOMPurify (no interactive elements in grid cells)
|
|
1159
|
+
|
|
1160
|
+
**Split mode (`renderSplit`):**
|
|
1161
|
+
- Mini-cal (left): 7-column grid showing month days; clicking a day sets `splitSelectedDate` and calls `renderSplit()`
|
|
1162
|
+
- Mini-cal day cells: plain divs with `data-date` attribute; after `$(calEl).html(miniCalHtml)`, attach click listener via `calEl.addEventListener('click', e => { const d = e.target.closest('[data-date]'); if (d) { splitSelectedDate = d.dataset.date; renderSplit(); } })`
|
|
1163
|
+
- Day events panel (right): for the selected date, render event cards as `<a>` links (no buttons) pointing to `#/plugins/calendar/events/{eventId}` - avoids DOMPurify stripping buttons
|
|
1164
|
+
|
|
1165
|
+
**Filter bar:**
|
|
1166
|
+
- `$container.find('#filter-type, #filter-visibility').each(function() { this.addEventListener('change', () => { eventsTable = null; renderCurrentMode(); }); })`
|
|
1167
|
+
|
|
1168
|
+
**Month navigation:**
|
|
1169
|
+
- Prev/Next buttons adjust `gridMonth` (for Grid) or trigger `renderSplit()` (for Split mini-cal)
|
|
1170
|
+
|
|
1171
|
+
**Init sequence:**
|
|
1172
|
+
```js
|
|
1173
|
+
await loadData();
|
|
1174
|
+
// Bind all buttons
|
|
1175
|
+
// Activate saved mode
|
|
1176
|
+
activateMode(currentMode);
|
|
1177
|
+
I.scan();
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
- [ ] **Step 2: Commit**
|
|
1181
|
+
|
|
1182
|
+
```bash
|
|
1183
|
+
git add plugins/calendar/admin/views/calendar.js
|
|
1184
|
+
git commit -m "feat(calendar): add calendar.js admin view - Grid, List, Split modes"
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
---
|
|
1188
|
+
|
|
1189
|
+
## Task 8: Admin view: `event-editor.js` - create / edit + recurrence panel
|
|
1190
|
+
|
|
1191
|
+
**Files:**
|
|
1192
|
+
- Create: `plugins/calendar/admin/views/event-editor.js`
|
|
1193
|
+
|
|
1194
|
+
Reads `R.current?.params?.id` - if missing or `'new'`, create mode; otherwise edit mode. Jobs-sourced events are shown read-only (all inputs disabled, save button disabled).
|
|
1195
|
+
|
|
1196
|
+
- [ ] **Step 1: Write `plugins/calendar/admin/views/event-editor.js`**
|
|
1197
|
+
|
|
1198
|
+
Export `eventEditorView = { templateUrl: '/plugins/calendar/admin/templates/event-editor.html', async onMount($container) { ... } }`.
|
|
1199
|
+
|
|
1200
|
+
**`onMount` structure:**
|
|
1201
|
+
|
|
1202
|
+
```js
|
|
1203
|
+
const rawId = R.current?.params?.id;
|
|
1204
|
+
const isNew = !rawId || rawId === 'new';
|
|
1205
|
+
const id = isNew ? null : rawId;
|
|
1206
|
+
let event = null;
|
|
1207
|
+
let autoSlug = true;
|
|
1208
|
+
const BASE = '/api/plugins/calendar';
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
**Load for edit:**
|
|
1212
|
+
```js
|
|
1213
|
+
if (!isNew) {
|
|
1214
|
+
const res = await H.get(`${BASE}/events/${id}`);
|
|
1215
|
+
if (res.error) { E.toast('Failed to load event.', { type: 'error' }); R.navigate('#/plugins/calendar'); return; }
|
|
1216
|
+
event = res.data ?? res;
|
|
1217
|
+
}
|
|
1218
|
+
```
|
|
1219
|
+
|
|
1220
|
+
**Populate fields:**
|
|
1221
|
+
```js
|
|
1222
|
+
function setField(fieldId, value) {
|
|
1223
|
+
const el = $container.find(`#${fieldId}`).get(0);
|
|
1224
|
+
if (el) el.value = value ?? '';
|
|
1225
|
+
}
|
|
1226
|
+
```
|
|
1227
|
+
Set: title, slug, description, type, visibility, location, start (as `datetime-local` format: `iso.slice(0,16)`), end.
|
|
1228
|
+
|
|
1229
|
+
**Read-only for Jobs events:**
|
|
1230
|
+
```js
|
|
1231
|
+
const isJobsEvent = event?.source === 'jobs';
|
|
1232
|
+
if (isJobsEvent) {
|
|
1233
|
+
$container.find('#jobs-notice').get(0).style.display = '';
|
|
1234
|
+
$container.find('#editor-body').get(0)
|
|
1235
|
+
.querySelectorAll('input, select, textarea, button').forEach(el => el.disabled = true);
|
|
1236
|
+
}
|
|
1237
|
+
```
|
|
1238
|
+
|
|
1239
|
+
**Recurrence panel:**
|
|
1240
|
+
- `#recurrence-enabled` checkbox toggles `#recurrence-fields` visibility
|
|
1241
|
+
- When populated from existing event: set freq, interval, until, byday checkboxes
|
|
1242
|
+
- `#field-freq` change → show/hide `#byday-section` (only visible when freq is `weekly`)
|
|
1243
|
+
|
|
1244
|
+
**RSVP panel:**
|
|
1245
|
+
- `#rsvp-enabled` checkbox toggles `#rsvp-fields` visibility
|
|
1246
|
+
|
|
1247
|
+
**Auto-slug:**
|
|
1248
|
+
- `#slug-auto` checkbox: when checked, `#field-slug` is `disabled = true` and its value mirrors `slugify($('#field-title').value)` on input events
|
|
1249
|
+
- For edit mode with existing slug: set `autoSlug = false`, uncheck `#slug-auto`, leave slug editable
|
|
1250
|
+
|
|
1251
|
+
**`collectValues()` helper:**
|
|
1252
|
+
```js
|
|
1253
|
+
function collectValues() {
|
|
1254
|
+
const recurrenceEnabled = $container.find('#recurrence-enabled').get(0)?.checked;
|
|
1255
|
+
let recurrence = null;
|
|
1256
|
+
if (recurrenceEnabled) {
|
|
1257
|
+
const freq = $container.find('#field-freq').get(0)?.value ?? 'weekly';
|
|
1258
|
+
const interval = parseInt($container.find('#field-interval').get(0)?.value ?? '1', 10);
|
|
1259
|
+
const until = $container.find('#field-until').get(0)?.value || null;
|
|
1260
|
+
const byday = [];
|
|
1261
|
+
$container.find('#byday-checks input').each(function() { if (this.checked) byday.push(this.value); });
|
|
1262
|
+
recurrence = { freq, interval, byday: freq === 'weekly' ? byday : [], until, count: null };
|
|
1263
|
+
}
|
|
1264
|
+
// Convert datetime-local string 'YYYY-MM-DDTHH:mm' to ISO 8601
|
|
1265
|
+
const startRaw = $container.find('#field-start').get(0)?.value ?? '';
|
|
1266
|
+
const endRaw = $container.find('#field-end').get(0)?.value ?? '';
|
|
1267
|
+
return {
|
|
1268
|
+
title: $container.find('#field-title').get(0)?.value.trim() ?? '',
|
|
1269
|
+
slug: $container.find('#field-slug').get(0)?.value.trim() ?? '',
|
|
1270
|
+
description: $container.find('#field-description').get(0)?.value ?? '',
|
|
1271
|
+
type: $container.find('#field-type').get(0)?.value ?? 'other',
|
|
1272
|
+
visibility: $container.find('#field-visibility').get(0)?.value ?? 'private',
|
|
1273
|
+
location: $container.find('#field-location').get(0)?.value.trim() ?? '',
|
|
1274
|
+
startAt: startRaw ? new Date(startRaw).toISOString() : null,
|
|
1275
|
+
endAt: endRaw ? new Date(endRaw).toISOString() : null,
|
|
1276
|
+
recurrence,
|
|
1277
|
+
rsvpEnabled: $container.find('#rsvp-enabled').get(0)?.checked ?? false,
|
|
1278
|
+
rsvpLimit: $container.find('#field-rsvp-limit').get(0)?.value
|
|
1279
|
+
? parseInt($container.find('#field-rsvp-limit').get(0)?.value, 10) : null
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
```
|
|
1283
|
+
|
|
1284
|
+
**`doSave()` async function:**
|
|
1285
|
+
- Validate title + startAt + endAt; show `E.toast` on failure and return null
|
|
1286
|
+
- `isNew ? H.post(...) : H.put(...)`
|
|
1287
|
+
- On success: `E.toast('Event saved.', { type: 'success' })` then `R.navigate('#/plugins/calendar')`
|
|
1288
|
+
|
|
1289
|
+
**Button bindings:**
|
|
1290
|
+
- `#btn-save`: calls `doSave()`; disabled if `isJobsEvent`
|
|
1291
|
+
- `#btn-cancel`: `R.navigate('#/plugins/calendar')`
|
|
1292
|
+
|
|
1293
|
+
**End of `onMount`:** `I.scan()`
|
|
1294
|
+
|
|
1295
|
+
- [ ] **Step 2: Commit**
|
|
1296
|
+
|
|
1297
|
+
```bash
|
|
1298
|
+
git add plugins/calendar/admin/views/event-editor.js
|
|
1299
|
+
git commit -m "feat(calendar): add event-editor.js - create/edit event with recurrence and RSVP panels"
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
---
|
|
1303
|
+
|
|
1304
|
+
## Task 9: Admin view: `rsvps.js`
|
|
1305
|
+
|
|
1306
|
+
**Files:**
|
|
1307
|
+
- Create: `plugins/calendar/admin/views/rsvps.js`
|
|
1308
|
+
|
|
1309
|
+
- [ ] **Step 1: Write `plugins/calendar/admin/views/rsvps.js`**
|
|
1310
|
+
|
|
1311
|
+
Export `rsvpsView = { templateUrl: '/plugins/calendar/admin/templates/rsvps.html', async onMount($container) { ... } }`.
|
|
1312
|
+
|
|
1313
|
+
```js
|
|
1314
|
+
const BASE = '/api/plugins/calendar';
|
|
1315
|
+
function escapeHtml(s) { ... }
|
|
1316
|
+
function formatDate(iso) { return iso ? new Date(iso).toLocaleDateString('en-GB', {...}) : '-'; }
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
**`onMount` structure:**
|
|
1320
|
+
```js
|
|
1321
|
+
let allRsvps = [], allOccurrences = [];
|
|
1322
|
+
let rsvpsTable = null;
|
|
1323
|
+
|
|
1324
|
+
async function loadData() {
|
|
1325
|
+
const [rsvpRes, occRes] = await Promise.all([H.get(`${BASE}/rsvps`), H.get(`${BASE}/occurrences`)]);
|
|
1326
|
+
if (rsvpRes.error || occRes.error) { E.toast('Failed to load RSVPs.', { type: 'error' }); return; }
|
|
1327
|
+
allRsvps = Array.isArray(rsvpRes) ? rsvpRes : (rsvpRes.data ?? []);
|
|
1328
|
+
allOccurrences = Array.isArray(occRes) ? occRes : (occRes.data ?? []);
|
|
1329
|
+
}
|
|
1330
|
+
```
|
|
1331
|
+
|
|
1332
|
+
**`buildOccurrenceFilter()`:** Populates `#filter-occurrence` select with upcoming occurrences (sorted by `startAt` ascending), `option value = occ.id`, `textContent = "${occ.title} - ${formatDate(occ.startAt)}"`.
|
|
1333
|
+
|
|
1334
|
+
**`filteredRsvps()`:** Reads filter select values, returns filtered `allRsvps`.
|
|
1335
|
+
|
|
1336
|
+
**`renderTable()`:**
|
|
1337
|
+
- Guard: `if (rsvpsTable) { rsvpsTable.setData(filteredRsvps()); return; }`
|
|
1338
|
+
- `T.create()` columns: Name, Email, Event (resolve occurrence title from `allOccurrences`), Status (badge), Registered (date), Actions
|
|
1339
|
+
- Actions column: toggle button (Cancel ↔ Confirm) + Delete button
|
|
1340
|
+
- Event delegation on `tableEl`: Cancel/Confirm call `H.put(..., { status: ... })`, Delete calls `E.confirm()` then `H.delete`; on success: `await loadData(); rsvpsTable = null; renderTable()`
|
|
1341
|
+
|
|
1342
|
+
**Init:**
|
|
1343
|
+
```js
|
|
1344
|
+
await loadData();
|
|
1345
|
+
buildOccurrenceFilter();
|
|
1346
|
+
renderTable();
|
|
1347
|
+
$container.find('#filter-occurrence, #filter-status').each(function() {
|
|
1348
|
+
this.addEventListener('change', () => { rsvpsTable = null; renderTable(); });
|
|
1349
|
+
});
|
|
1350
|
+
```
|
|
1351
|
+
|
|
1352
|
+
- [ ] **Step 2: Commit**
|
|
1353
|
+
|
|
1354
|
+
```bash
|
|
1355
|
+
git add plugins/calendar/admin/views/rsvps.js
|
|
1356
|
+
git commit -m "feat(calendar): add rsvps.js admin view"
|
|
1357
|
+
```
|
|
1358
|
+
|
|
1359
|
+
---
|
|
1360
|
+
|
|
1361
|
+
## Task 10: Admin view: `settings.js`, enable plugin, smoke test
|
|
1362
|
+
|
|
1363
|
+
**Files:**
|
|
1364
|
+
- Create: `plugins/calendar/admin/views/settings.js`
|
|
1365
|
+
- Modify: `config/plugins.json` (set `enabled: true`)
|
|
1366
|
+
|
|
1367
|
+
- [ ] **Step 1: Write `plugins/calendar/admin/views/settings.js`**
|
|
1368
|
+
|
|
1369
|
+
Export `settingsView = { templateUrl: '/plugins/calendar/admin/templates/settings.html', async onMount($container) { ... } }`.
|
|
1370
|
+
|
|
1371
|
+
```js
|
|
1372
|
+
const BASE = '/api/plugins/calendar';
|
|
1373
|
+
|
|
1374
|
+
async onMount($container) {
|
|
1375
|
+
let settings = {};
|
|
1376
|
+
try {
|
|
1377
|
+
const res = await H.get(`${BASE}/settings`);
|
|
1378
|
+
if (res.error) throw new Error(res.error);
|
|
1379
|
+
settings = res.data ?? res ?? {};
|
|
1380
|
+
} catch { E.toast('Failed to load settings.', { type: 'error' }); }
|
|
1381
|
+
|
|
1382
|
+
const formEl = $container.find('#settings-form').get(0);
|
|
1383
|
+
if (!formEl) return;
|
|
1384
|
+
|
|
1385
|
+
F.create(formEl, {
|
|
1386
|
+
blueprint: [
|
|
1387
|
+
{ key: 'basePath', label: 'Base path', type: 'text', value: settings.basePath ?? '/calendar',
|
|
1388
|
+
hint: 'URL prefix for all calendar pages. Changes require a server restart.' },
|
|
1389
|
+
{ key: 'eventsPerPage', label: 'Events per page', type: 'number', value: settings.eventsPerPage ?? 12 },
|
|
1390
|
+
{ key: 'rsvpEnabled', label: 'Enable RSVPs', type: 'toggle', value: settings.rsvpEnabled ?? true },
|
|
1391
|
+
{ key: 'defaultView', label: 'Default admin view', type: 'select', value: settings.defaultView ?? 'list',
|
|
1392
|
+
options: ['list','grid','split'] },
|
|
1393
|
+
{ key: 'occurrenceWindow', label: 'Occurrence window (days)', type: 'number', value: settings.occurrenceWindow ?? 365,
|
|
1394
|
+
hint: 'How many days ahead to pre-generate recurring occurrences.' },
|
|
1395
|
+
{ key: 'feedLength', label: 'iCal feed length', type: 'number', value: settings.feedLength ?? 50 }
|
|
1396
|
+
],
|
|
1397
|
+
onSubmit: async (values) => {
|
|
1398
|
+
try {
|
|
1399
|
+
const res = await H.put(`${BASE}/settings`, {
|
|
1400
|
+
basePath: values.basePath,
|
|
1401
|
+
eventsPerPage: parseInt(values.eventsPerPage, 10),
|
|
1402
|
+
rsvpEnabled: Boolean(values.rsvpEnabled),
|
|
1403
|
+
defaultView: values.defaultView,
|
|
1404
|
+
occurrenceWindow: parseInt(values.occurrenceWindow, 10),
|
|
1405
|
+
feedLength: parseInt(values.feedLength, 10)
|
|
1406
|
+
});
|
|
1407
|
+
if (res.error) throw new Error(res.error);
|
|
1408
|
+
E.toast('Settings saved. Base path changes require a server restart.', { type: 'success' });
|
|
1409
|
+
} catch (err) {
|
|
1410
|
+
E.toast(err?.message ?? 'Failed to save settings.', { type: 'error' });
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
```
|
|
1416
|
+
|
|
1417
|
+
- [ ] **Step 2: Enable calendar in `config/plugins.json`**
|
|
1418
|
+
|
|
1419
|
+
Set `"calendar": { "enabled": true, "bundled": false, "settings": {} }`.
|
|
1420
|
+
|
|
1421
|
+
- [ ] **Step 3: Smoke-test API routes with valid token**
|
|
1422
|
+
|
|
1423
|
+
Get a token first:
|
|
1424
|
+
```bash
|
|
1425
|
+
TOKEN=$(curl -s -X POST http://localhost:4096/api/auth/login \
|
|
1426
|
+
-H "Content-Type: application/json" \
|
|
1427
|
+
-d '{"email":"admin@example.com","password":"YOUR_ADMIN_PASSWORD"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
|
|
1428
|
+
```
|
|
1429
|
+
|
|
1430
|
+
```bash
|
|
1431
|
+
# Create a one-off public event
|
|
1432
|
+
curl -s -X POST http://localhost:4096/api/plugins/calendar/events \
|
|
1433
|
+
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
1434
|
+
-d '{"title":"Launch Webinar","type":"public-event","visibility":"public","startAt":"2026-06-01T10:00:00.000Z","endAt":"2026-06-01T11:30:00.000Z"}' | grep -o '"id":"[^"]*"'
|
|
1435
|
+
```
|
|
1436
|
+
Expected: `"id":"<uuid>"`
|
|
1437
|
+
|
|
1438
|
+
```bash
|
|
1439
|
+
# Verify occurrence was materialised
|
|
1440
|
+
curl -s http://localhost:4096/api/plugins/calendar/occurrences \
|
|
1441
|
+
-H "Authorization: Bearer $TOKEN" | grep -o '"title":"Launch Webinar"'
|
|
1442
|
+
```
|
|
1443
|
+
Expected: `"title":"Launch Webinar"`
|
|
1444
|
+
|
|
1445
|
+
- [ ] **Step 4: Smoke-test public routes**
|
|
1446
|
+
|
|
1447
|
+
```bash
|
|
1448
|
+
curl -s http://localhost:4096/calendar/feed.ics | head -4
|
|
1449
|
+
```
|
|
1450
|
+
Expected:
|
|
1451
|
+
```
|
|
1452
|
+
BEGIN:VCALENDAR
|
|
1453
|
+
VERSION:2.0
|
|
1454
|
+
PRODID:-//Domma CMS//Calendar//EN
|
|
1455
|
+
```
|
|
1456
|
+
|
|
1457
|
+
```bash
|
|
1458
|
+
curl -s http://localhost:4096/calendar | grep -i 'events\|calendar' | head -3
|
|
1459
|
+
```
|
|
1460
|
+
|
|
1461
|
+
- [ ] **Step 5: Smoke-test admin panel in browser**
|
|
1462
|
+
|
|
1463
|
+
Open `http://192.168.1.4:4096/admin` in browser. Log in as admin.
|
|
1464
|
+
|
|
1465
|
+
1. Verify "Calendar" appears in the sidebar
|
|
1466
|
+
2. Navigate to Calendar → Events → confirm List view table loads
|
|
1467
|
+
3. Toggle Grid view → confirm month calendar renders (days with type-coloured badges)
|
|
1468
|
+
4. Toggle Split view → confirm mini calendar + day detail panel render
|
|
1469
|
+
5. Click "New Event" → confirm editor opens
|
|
1470
|
+
6. Fill in title, type=Public Event, visibility=Public, start + end datetimes → Save → confirm event appears in list
|
|
1471
|
+
7. Enable recurrence (weekly, Monday + Wednesday) → save → check that multiple occurrences appear when switching to Grid view
|
|
1472
|
+
8. Navigate to Calendar → RSVPs → confirm table loads (may be empty)
|
|
1473
|
+
9. Navigate to Calendar → Settings → confirm F.create form renders with all fields
|
|
1474
|
+
|
|
1475
|
+
- [ ] **Step 6: Smoke-test RSVP submission**
|
|
1476
|
+
|
|
1477
|
+
After creating a public event with `rsvpEnabled: true`:
|
|
1478
|
+
```bash
|
|
1479
|
+
# Find the occurrence ID (UUID - used as occurrenceId in the RSVP body)
|
|
1480
|
+
OCC_RESPONSE=$(curl -s http://localhost:4096/api/plugins/calendar/occurrences \
|
|
1481
|
+
-H "Authorization: Bearer $TOKEN")
|
|
1482
|
+
OCC_ID=$(echo "$OCC_RESPONSE" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
1483
|
+
echo "Occurrence ID: $OCC_ID"
|
|
1484
|
+
|
|
1485
|
+
# Submit RSVP
|
|
1486
|
+
curl -s -X POST http://localhost:4096/calendar/rsvp \
|
|
1487
|
+
-H "Content-Type: application/json" \
|
|
1488
|
+
-d "{\"occurrenceId\":\"$OCC_ID\",\"name\":\"Jane Smith\",\"email\":\"jane@example.com\"}" | grep -o '"ok"'
|
|
1489
|
+
```
|
|
1490
|
+
Expected: `"ok"`
|
|
1491
|
+
|
|
1492
|
+
```bash
|
|
1493
|
+
# Verify RSVP in admin
|
|
1494
|
+
curl -s http://localhost:4096/api/plugins/calendar/rsvps \
|
|
1495
|
+
-H "Authorization: Bearer $TOKEN" | grep -o '"email":"jane@example.com"'
|
|
1496
|
+
```
|
|
1497
|
+
Expected: `"email":"jane@example.com"`
|
|
1498
|
+
|
|
1499
|
+
- [ ] **Step 7: Final commit**
|
|
1500
|
+
|
|
1501
|
+
```bash
|
|
1502
|
+
git add plugins/calendar/admin/views/settings.js config/plugins.json
|
|
1503
|
+
git commit -m "feat(calendar): add settings.js admin view and enable plugin"
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1506
|
+
---
|
|
1507
|
+
|
|
1508
|
+
## Reference: what `setupPlugin` seeds automatically on `onEnable`
|
|
1509
|
+
|
|
1510
|
+
When Calendar is enabled via the admin UI, the plugin system calls `setupPlugin` with full services, then calls the plugin's exported `onEnable({ fastify, services })`. Together these seed:
|
|
1511
|
+
|
|
1512
|
+
| Asset | Source | Destination |
|
|
1513
|
+
|---|---|---|
|
|
1514
|
+
| Collection `calendar-events` | `plugins/calendar/collections/calendar-events/schema.json` | `content/collections/calendar-events/` |
|
|
1515
|
+
| Collection `calendar-occurrences` | `plugins/calendar/collections/calendar-occurrences/schema.json` | `content/collections/calendar-occurrences/` |
|
|
1516
|
+
| Collection `calendar-rsvps` | `plugins/calendar/collections/calendar-rsvps/schema.json` | `content/collections/calendar-rsvps/` |
|
|
1517
|
+
| Role `calendar-organiser` | `plugins/calendar/roles/calendar-organiser.json` | `content/collections/roles/data.json` |
|
|
1518
|
+
| Role `calendar-admin` | `plugins/calendar/roles/calendar-admin.json` | `content/collections/roles/data.json` |
|
|
1519
|
+
| Form `calendar-rsvp` | `plugins/calendar/forms/calendar-rsvp.json` | `content/forms/calendar-rsvp.json` |
|
|
1520
|
+
| Page `/calendar` | `plugins/calendar/pages/calendar.md` | `content/pages/calendar.md` |
|
|
1521
|
+
| Block `upcoming-events` | `plugins/calendar/blocks/upcoming-events.html` | `content/blocks/upcoming-events.html` |
|
|
1522
|
+
| View `calendar-upcoming` | generated in `onEnable` | `content/views/calendar-upcoming.json` |
|
|
1523
|
+
| View `calendar-past` | generated in `onEnable` | `content/views/calendar-past.json` |
|
|
1524
|
+
| View `calendar-my-events` | generated in `onEnable` | `content/views/calendar-my-events.json` |
|
|
1525
|
+
|
|
1526
|
+
`teardownPlugin` removes: page, form, roles. `onDisable` removes: block, views. Collections are never destroyed (data preserved).
|