domma-cms 0.6.16 → 0.6.21

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.
Files changed (44) hide show
  1. package/admin/js/api.js +1 -1
  2. package/admin/js/app.js +4 -4
  3. package/admin/js/lib/markdown-toolbar.js +14 -14
  4. package/admin/js/views/collection-editor.js +5 -3
  5. package/admin/js/views/collections.js +1 -1
  6. package/admin/js/views/page-editor.js +27 -27
  7. package/config/plugins.json +16 -0
  8. package/config/site.json +1 -1
  9. package/package.json +2 -2
  10. package/plugins/analytics/stats.json +1 -1
  11. package/plugins/contacts/admin/templates/contacts.html +126 -0
  12. package/plugins/contacts/admin/views/contacts.js +710 -0
  13. package/plugins/contacts/config.js +6 -0
  14. package/plugins/contacts/data/contacts.json +20 -0
  15. package/plugins/contacts/plugin.js +351 -0
  16. package/plugins/contacts/plugin.json +23 -0
  17. package/plugins/docs/admin/templates/docs.html +69 -0
  18. package/plugins/docs/admin/views/docs.js +276 -0
  19. package/plugins/docs/config.js +8 -0
  20. package/plugins/docs/data/documents/452f49b7-9c93-4a67-874d-27f882891ad2.json +11 -0
  21. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +11 -0
  22. package/plugins/docs/data/folders.json +9 -0
  23. package/plugins/docs/data/templates.json +1 -0
  24. package/plugins/docs/plugin.js +375 -0
  25. package/plugins/docs/plugin.json +23 -0
  26. package/plugins/notes/admin/templates/notes.html +92 -0
  27. package/plugins/notes/admin/views/notes.js +304 -0
  28. package/plugins/notes/config.js +6 -0
  29. package/plugins/notes/data/notes.json +1 -0
  30. package/plugins/notes/plugin.js +177 -0
  31. package/plugins/notes/plugin.json +23 -0
  32. package/plugins/todo/admin/templates/todo.html +164 -0
  33. package/plugins/todo/admin/views/todo.js +328 -0
  34. package/plugins/todo/config.js +7 -0
  35. package/plugins/todo/data/todos.json +1 -0
  36. package/plugins/todo/plugin.js +155 -0
  37. package/plugins/todo/plugin.json +23 -0
  38. package/server/routes/api/auth.js +2 -0
  39. package/server/routes/api/collections.js +55 -0
  40. package/server/routes/api/forms.js +3 -0
  41. package/server/routes/api/settings.js +16 -1
  42. package/server/routes/public.js +2 -0
  43. package/server/services/markdown.js +169 -8
  44. package/server/services/plugins.js +3 -2
@@ -0,0 +1,6 @@
1
+ export default {
2
+ scope: 'user',
3
+ maxPerUser: 5000,
4
+ // storage.adapter: 'mongodb' uses config/connections.json; 'file' uses local JSON
5
+ storage: {adapter: 'file'}
6
+ };
@@ -0,0 +1,20 @@
1
+ {
2
+ "contacts": [
3
+ {
4
+ "id": "97bbf711-84ce-41cc-898f-811936e051bc",
5
+ "name": "Darryl Waterhouse",
6
+ "email": "",
7
+ "phone": "+447835449292",
8
+ "groups": [
9
+ "Coders"
10
+ ],
11
+ "notes": "Ledge",
12
+ "favourite": true,
13
+ "userId": "2421ad8e-060d-4548-8878-af7011d5e08b",
14
+ "createdAt": "2026-03-24T16:43:39.907Z"
15
+ }
16
+ ],
17
+ "groups": [
18
+ "Coders"
19
+ ]
20
+ }
@@ -0,0 +1,351 @@
1
+ import defaultConfig from './config.js';
2
+ import {
3
+ listEntries,
4
+ createEntry,
5
+ updateEntry,
6
+ deleteEntry,
7
+ getEntry,
8
+ getCollection,
9
+ createCollection
10
+ } from '../../server/services/collections.js';
11
+
12
+ const CONTACTS_SLUG = 'user-contacts';
13
+ const GROUPS_SLUG = 'user-contact-groups';
14
+
15
+ const CONTACT_FIELDS = [
16
+ {name: 'name', label: 'Name', type: 'text', required: true},
17
+ {name: 'email', label: 'Email', type: 'text', required: false},
18
+ {name: 'phone', label: 'Phone', type: 'text', required: false},
19
+ {name: 'groups', label: 'Groups', type: 'text', required: false},
20
+ {name: 'notes', label: 'Notes', type: 'textarea', required: false},
21
+ {name: 'favourite', label: 'Favourite', type: 'text', required: false},
22
+ {name: 'userId', label: 'User ID', type: 'text', required: false}
23
+ ];
24
+
25
+ const GROUP_FIELDS = [
26
+ {name: 'name', label: 'Group Name', type: 'text', required: true}
27
+ ];
28
+
29
+ /** Map a collection entry to the shape the admin view expects. */
30
+ function toContact(entry) {
31
+ let groups = entry.data.groups;
32
+ if (typeof groups === 'string') {
33
+ try {
34
+ groups = JSON.parse(groups);
35
+ } catch {
36
+ groups = [];
37
+ }
38
+ }
39
+ return {
40
+ id: entry.id,
41
+ name: entry.data.name ?? '',
42
+ email: entry.data.email ?? '',
43
+ phone: entry.data.phone ?? '',
44
+ groups: Array.isArray(groups) ? groups : [],
45
+ notes: entry.data.notes ?? '',
46
+ favourite: entry.data.favourite === true || entry.data.favourite === 'true',
47
+ userId: entry.data.userId ?? null,
48
+ createdAt: entry.meta.createdAt,
49
+ updatedAt: entry.meta.updatedAt
50
+ };
51
+ }
52
+
53
+ function toGroupName(entry) {
54
+ return entry.data.name ?? '';
55
+ }
56
+
57
+ /**
58
+ * Lifecycle: create both collections on plugin enable.
59
+ */
60
+ export async function onEnable({services: {collections}}) {
61
+ for (const [slug, title, fields, desc] of [
62
+ [CONTACTS_SLUG, 'Contacts', CONTACT_FIELDS, 'Contacts managed by the Contacts plugin.'],
63
+ [GROUPS_SLUG, 'Contact Groups', GROUP_FIELDS, 'Groups for the Contacts plugin.']
64
+ ]) {
65
+ const existing = await collections.getCollection(slug).catch(() => null);
66
+ if (!existing) {
67
+ await collections.createCollection({title, slug, description: desc, fields});
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Lifecycle: remove both collections on plugin disable.
74
+ */
75
+ export async function onDisable({services: {collections}}) {
76
+ await collections.deleteCollection(CONTACTS_SLUG).catch(() => {
77
+ });
78
+ await collections.deleteCollection(GROUPS_SLUG).catch(() => {
79
+ });
80
+ }
81
+
82
+ export default async function contactsPlugin(fastify, options) {
83
+ const {authenticate} = options.auth;
84
+ const config = {...defaultConfig, ...(options.settings || {})};
85
+ const scope = config.scope ?? 'user';
86
+ const storage = config.storage ?? {adapter: 'file'};
87
+
88
+ // Auto-create collections if missing.
89
+ for (const [slug, title, fields, desc] of [
90
+ [CONTACTS_SLUG, 'Contacts', CONTACT_FIELDS, 'Contacts managed by the Contacts plugin.'],
91
+ [GROUPS_SLUG, 'Contact Groups', GROUP_FIELDS, 'Groups for the Contacts plugin.']
92
+ ]) {
93
+ const existing = await getCollection(slug).catch(() => null);
94
+ if (!existing) {
95
+ await createCollection({title, slug, description: desc, fields, storage})
96
+ .catch(err => fastify.log.warn(`[contacts] Collection setup: ${err.message}`));
97
+ }
98
+ }
99
+
100
+ function userId(request) {
101
+ return scope === 'user' ? (request.user?.id ?? request.user?.sub ?? null) : null;
102
+ }
103
+
104
+ async function loadContacts(uid) {
105
+ const {entries} = await listEntries(CONTACTS_SLUG, {limit: 100000, sort: 'createdAt', order: 'asc'});
106
+ let items = entries.map(toContact);
107
+ if (uid) items = items.filter(c => c.userId === uid);
108
+ return items;
109
+ }
110
+
111
+ async function loadGroups() {
112
+ const {entries} = await listEntries(GROUPS_SLUG, {limit: 10000, sort: 'createdAt', order: 'asc'});
113
+ return entries.map(e => ({id: e.id, name: toGroupName(e)}));
114
+ }
115
+
116
+ // -------------------------------------------------------------------------
117
+ // Contacts CRUD
118
+ // -------------------------------------------------------------------------
119
+
120
+ /** GET /api/plugins/contacts/contacts — supports ?q=, ?group=, ?favourites=true */
121
+ fastify.get('/contacts', {preHandler: [authenticate]}, async (request, reply) => {
122
+ const uid = userId(request);
123
+ const { q, group, favourites } = request.query;
124
+
125
+ let result = await loadContacts(uid);
126
+
127
+ if (q) {
128
+ const query = q.toLowerCase();
129
+ result = result.filter(c =>
130
+ c.name.toLowerCase().includes(query) ||
131
+ c.email.toLowerCase().includes(query) ||
132
+ c.phone.includes(query)
133
+ );
134
+ }
135
+ if (group) {
136
+ result = result.filter(c => Array.isArray(c.groups) && c.groups.includes(group));
137
+ }
138
+ if (favourites === 'true') {
139
+ result = result.filter(c => c.favourite === true);
140
+ }
141
+
142
+ result.sort((a, b) => a.name.localeCompare(b.name));
143
+ return reply.send(result);
144
+ });
145
+
146
+ /** POST /api/plugins/contacts/contacts */
147
+ fastify.post('/contacts', {preHandler: [authenticate]}, async (request, reply) => {
148
+ const uid = userId(request);
149
+ const { name, email, phone, groups, notes, favourite } = request.body ?? {};
150
+
151
+ if (!name || typeof name !== 'string' || !name.trim()) {
152
+ return reply.code(400).send({ error: 'name is required' });
153
+ }
154
+
155
+ const entry = await createEntry(CONTACTS_SLUG, {
156
+ name: name.trim(),
157
+ email: email?.trim() ?? '',
158
+ phone: phone?.trim() ?? '',
159
+ groups: Array.isArray(groups) ? groups : [],
160
+ notes: notes?.trim() ?? '',
161
+ favourite: favourite === true,
162
+ userId: uid ?? null
163
+ }, {createdBy: uid, source: 'admin'});
164
+
165
+ return reply.code(201).send(toContact(entry));
166
+ });
167
+
168
+ /** PUT /api/plugins/contacts/contacts/:id */
169
+ fastify.put('/contacts/:id', {preHandler: [authenticate]}, async (request, reply) => {
170
+ const uid = userId(request);
171
+ const { id } = request.params;
172
+ const updates = request.body ?? {};
173
+
174
+ const entry = await getEntry(CONTACTS_SLUG, id);
175
+ if (!entry) return reply.code(404).send({error: 'Contact not found'});
176
+ if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Contact not found'});
177
+
178
+ const merged = {...entry.data};
179
+ for (const key of ['name', 'email', 'phone', 'groups', 'notes', 'favourite']) {
180
+ if (key in updates) merged[key] = updates[key];
181
+ }
182
+
183
+ const updated = await updateEntry(CONTACTS_SLUG, id, merged);
184
+ return reply.send(toContact(updated));
185
+ });
186
+
187
+ /** DELETE /api/plugins/contacts/contacts/bulk — must be before /:id */
188
+ fastify.delete('/contacts/bulk', {preHandler: [authenticate]}, async (request, reply) => {
189
+ const uid = userId(request);
190
+ const { ids } = request.body ?? {};
191
+
192
+ if (!Array.isArray(ids) || ids.length === 0) {
193
+ return reply.code(400).send({ error: 'ids array is required' });
194
+ }
195
+
196
+ let deleted = 0;
197
+ for (const id of ids) {
198
+ const entry = await getEntry(CONTACTS_SLUG, id).catch(() => null);
199
+ if (!entry) continue;
200
+ if (uid && entry.data.userId !== uid) continue;
201
+ await deleteEntry(CONTACTS_SLUG, id).catch(() => {
202
+ });
203
+ deleted++;
204
+ }
205
+
206
+ return reply.send({ deleted });
207
+ });
208
+
209
+ /** DELETE /api/plugins/contacts/contacts/:id */
210
+ fastify.delete('/contacts/:id', {preHandler: [authenticate]}, async (request, reply) => {
211
+ const uid = userId(request);
212
+ const { id } = request.params;
213
+
214
+ const entry = await getEntry(CONTACTS_SLUG, id);
215
+ if (!entry) return reply.code(404).send({error: 'Contact not found'});
216
+ if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Contact not found'});
217
+
218
+ await deleteEntry(CONTACTS_SLUG, id);
219
+ return reply.send({ ok: true });
220
+ });
221
+
222
+ // -------------------------------------------------------------------------
223
+ // Groups CRUD
224
+ // -------------------------------------------------------------------------
225
+
226
+ /** GET /api/plugins/contacts/groups */
227
+ fastify.get('/groups', {preHandler: [authenticate]}, async (request, reply) => {
228
+ const groups = await loadGroups();
229
+ return reply.send(groups.map(g => g.name));
230
+ });
231
+
232
+ /** POST /api/plugins/contacts/groups */
233
+ fastify.post('/groups', {preHandler: [authenticate]}, async (request, reply) => {
234
+ const { name } = request.body ?? {};
235
+
236
+ if (!name || typeof name !== 'string' || !name.trim()) {
237
+ return reply.code(400).send({ error: 'name is required' });
238
+ }
239
+
240
+ const trimmed = name.trim();
241
+ const existing = await loadGroups();
242
+ if (existing.some(g => g.name === trimmed)) {
243
+ return reply.code(400).send({ error: 'Group already exists' });
244
+ }
245
+
246
+ await createEntry(GROUPS_SLUG, {name: trimmed}, {source: 'admin'});
247
+ return reply.code(201).send({ name: trimmed });
248
+ });
249
+
250
+ /** PUT /api/plugins/contacts/groups/:name — rename + cascade */
251
+ fastify.put('/groups/:name', {preHandler: [authenticate]}, async (request, reply) => {
252
+ const oldName = decodeURIComponent(request.params.name);
253
+ const { newName } = request.body ?? {};
254
+
255
+ if (!newName || typeof newName !== 'string' || !newName.trim()) {
256
+ return reply.code(400).send({ error: 'newName is required' });
257
+ }
258
+
259
+ const groups = await loadGroups();
260
+ const grpEntry = groups.find(g => g.name === oldName);
261
+ if (!grpEntry) return reply.code(404).send({error: 'Group not found'});
262
+
263
+ const trimmed = newName.trim();
264
+ if (groups.some(g => g.name === trimmed)) {
265
+ return reply.code(400).send({ error: 'A group with that name already exists' });
266
+ }
267
+
268
+ // Rename the group entry
269
+ await updateEntry(GROUPS_SLUG, grpEntry.id, {name: trimmed});
270
+
271
+ // Cascade rename across all contacts
272
+ const {entries} = await listEntries(CONTACTS_SLUG, {limit: 100000});
273
+ for (const entry of entries) {
274
+ const grps = Array.isArray(entry.data.groups) ? entry.data.groups : [];
275
+ if (grps.includes(oldName)) {
276
+ await updateEntry(CONTACTS_SLUG, entry.id, {
277
+ ...entry.data,
278
+ groups: grps.map(g => (g === oldName ? trimmed : g))
279
+ });
280
+ }
281
+ }
282
+
283
+ return reply.send({ name: trimmed });
284
+ });
285
+
286
+ /** DELETE /api/plugins/contacts/groups/:name — delete + cascade */
287
+ fastify.delete('/groups/:name', {preHandler: [authenticate]}, async (request, reply) => {
288
+ const name = decodeURIComponent(request.params.name);
289
+
290
+ const groups = await loadGroups();
291
+ const grpEntry = groups.find(g => g.name === name);
292
+ if (!grpEntry) return reply.code(404).send({error: 'Group not found'});
293
+
294
+ // Remove the group entry
295
+ await deleteEntry(GROUPS_SLUG, grpEntry.id);
296
+
297
+ // Cascade: strip group from all contacts
298
+ const {entries} = await listEntries(CONTACTS_SLUG, {limit: 100000});
299
+ for (const entry of entries) {
300
+ const grps = Array.isArray(entry.data.groups) ? entry.data.groups : [];
301
+ if (grps.includes(name)) {
302
+ await updateEntry(CONTACTS_SLUG, entry.id, {
303
+ ...entry.data,
304
+ groups: grps.filter(g => g !== name)
305
+ });
306
+ }
307
+ }
308
+
309
+ return reply.send({ ok: true });
310
+ });
311
+
312
+ // -------------------------------------------------------------------------
313
+ // Import / Export
314
+ // -------------------------------------------------------------------------
315
+
316
+ /** GET /api/plugins/contacts/export */
317
+ fastify.get('/export', {preHandler: [authenticate]}, async (request, reply) => {
318
+ const uid = userId(request);
319
+ const contacts = await loadContacts(uid);
320
+ return reply
321
+ .header('Content-Disposition', 'attachment; filename="contacts.json"')
322
+ .send(contacts);
323
+ });
324
+
325
+ /** POST /api/plugins/contacts/import */
326
+ fastify.post('/import', {preHandler: [authenticate]}, async (request, reply) => {
327
+ const uid = userId(request);
328
+ const { contacts: incoming } = request.body ?? {};
329
+
330
+ if (!Array.isArray(incoming)) {
331
+ return reply.code(400).send({ error: 'contacts array is required' });
332
+ }
333
+
334
+ let imported = 0;
335
+ for (const raw of incoming) {
336
+ if (!raw || typeof raw.name !== 'string') continue;
337
+ await createEntry(CONTACTS_SLUG, {
338
+ name: raw.name?.trim() ?? '',
339
+ email: raw.email?.trim() ?? '',
340
+ phone: raw.phone?.trim() ?? '',
341
+ groups: Array.isArray(raw.groups) ? raw.groups : [],
342
+ notes: raw.notes?.trim() ?? '',
343
+ favourite: raw.favourite === true,
344
+ userId: uid ?? null
345
+ }, {source: 'import'});
346
+ imported++;
347
+ }
348
+
349
+ return reply.send({ imported });
350
+ });
351
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "contacts",
3
+ "displayName": "Contacts",
4
+ "version": "1.0.0",
5
+ "description": "Contact manager with groups, favourites, search, and import/export.",
6
+ "author": "Darryl Waterhouse",
7
+ "date": "2026-03-24",
8
+ "icon": "users",
9
+ "admin": {
10
+ "sidebar": [
11
+ { "id": "contacts", "text": "Contacts", "icon": "users", "url": "#/plugins/contacts", "section": "#/plugins/contacts" }
12
+ ],
13
+ "routes": [
14
+ { "path": "/plugins/contacts", "view": "plugin-contacts", "title": "Contacts - Domma CMS" }
15
+ ],
16
+ "views": {
17
+ "plugin-contacts": {
18
+ "entry": "contacts/admin/views/contacts.js",
19
+ "exportName": "contactsView"
20
+ }
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,69 @@
1
+ <div class="docs-layout" style="display:grid;grid-template-columns:200px 260px 1fr;height:calc(100vh - 120px);gap:0;border:1px solid var(--dm-border);border-radius:var(--dm-radius);overflow:hidden;">
2
+
3
+ <!-- Folder sidebar -->
4
+ <div class="docs-folders" style="border-right:1px solid var(--dm-border);display:flex;flex-direction:column;">
5
+ <div style="padding:0.75rem;border-bottom:1px solid var(--dm-border);display:flex;align-items:center;justify-content:space-between;">
6
+ <span style="font-weight:600;font-size:0.85rem;">Folders</span>
7
+ <button id="new-folder-btn" class="btn btn-sm btn-ghost" title="New Folder">
8
+ <span data-icon="plus" data-icon-size="14"></span>
9
+ </button>
10
+ </div>
11
+ <div id="folder-sidebar" style="flex:1;overflow-y:auto;padding:0.5rem;"></div>
12
+ </div>
13
+
14
+ <!-- Document list -->
15
+ <div class="docs-list-pane" style="border-right:1px solid var(--dm-border);display:flex;flex-direction:column;">
16
+ <div style="padding:0.75rem;border-bottom:1px solid var(--dm-border);">
17
+ <input id="doc-search" class="form-input form-input-sm" placeholder="Search documents..." style="width:100%;">
18
+ </div>
19
+ <div style="padding:0.5rem;border-bottom:1px solid var(--dm-border);display:flex;gap:0.5rem;">
20
+ <button id="new-doc-btn" class="btn btn-sm btn-primary" style="flex:1;">
21
+ <span data-icon="plus" data-icon-size="14"></span> New
22
+ </button>
23
+ <button id="new-from-template-btn" class="btn btn-sm btn-outline" title="New from Template">
24
+ <span data-icon="layout" data-icon-size="14"></span>
25
+ </button>
26
+ </div>
27
+ <div id="doc-list" style="flex:1;overflow-y:auto;"></div>
28
+ </div>
29
+
30
+ <!-- Editor pane -->
31
+ <div style="display:flex;flex-direction:column;overflow:hidden;">
32
+
33
+ <!-- Placeholder when no doc is selected -->
34
+ <div id="editor-placeholder" style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--dm-text-muted);">
35
+ <div style="text-align:center;">
36
+ <span data-icon="book-open" data-icon-size="48" style="display:block;margin-bottom:1rem;opacity:0.4;"></span>
37
+ <p>Select a document to start editing</p>
38
+ </div>
39
+ </div>
40
+
41
+ <!-- Editor when a doc is open -->
42
+ <div id="editor-pane" style="display:none;flex-direction:column;flex:1;overflow:hidden;">
43
+ <!-- Toolbar -->
44
+ <div style="padding:0.5rem 0.75rem;border-bottom:1px solid var(--dm-border);display:flex;align-items:center;gap:0.5rem;flex-shrink:0;">
45
+ <input id="doc-title-input" class="form-input" placeholder="Document title"
46
+ style="flex:1;border:none;font-weight:600;font-size:1rem;background:transparent;outline:none;padding:0.25rem;">
47
+ <button id="save-doc-btn" class="btn btn-sm btn-primary">
48
+ <span data-icon="save" data-icon-size="14"></span> Save
49
+ </button>
50
+ <button id="find-replace-btn" class="btn btn-sm btn-ghost" title="Find &amp; Replace">
51
+ <span data-icon="search" data-icon-size="14"></span>
52
+ </button>
53
+ <button id="version-history-btn" class="btn btn-sm btn-ghost" title="Version History">
54
+ <span data-icon="clock" data-icon-size="14"></span>
55
+ </button>
56
+ <button id="duplicate-doc-btn" class="btn btn-sm btn-ghost" title="Duplicate Document">
57
+ <span data-icon="copy" data-icon-size="14"></span>
58
+ </button>
59
+ <button id="delete-doc-btn" class="btn btn-sm btn-ghost btn-danger" title="Delete Document">
60
+ <span data-icon="trash" data-icon-size="14"></span>
61
+ </button>
62
+ </div>
63
+ <!-- Content area -->
64
+ <div id="doc-editor-content" style="flex:1;overflow-y:auto;padding:1rem;"></div>
65
+ </div>
66
+
67
+ </div>
68
+
69
+ </div>