domma-cms 0.6.15 → 0.6.20

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 (76) hide show
  1. package/admin/js/api.js +1 -1
  2. package/admin/js/app.js +4 -4
  3. package/admin/js/config/sidebar-config.js +1 -1
  4. package/admin/js/lib/markdown-toolbar.js +14 -12
  5. package/admin/js/views/collection-editor.js +5 -3
  6. package/admin/js/views/collections.js +1 -1
  7. package/admin/js/views/page-editor.js +27 -27
  8. package/config/plugins.json +20 -0
  9. package/config/site.json +1 -1
  10. package/package.json +2 -2
  11. package/plugins/analytics/stats.json +1 -1
  12. package/plugins/contacts/admin/templates/contacts.html +126 -0
  13. package/plugins/contacts/admin/views/contacts.js +710 -0
  14. package/plugins/contacts/config.js +6 -0
  15. package/plugins/contacts/data/contacts.json +20 -0
  16. package/plugins/contacts/plugin.js +351 -0
  17. package/plugins/contacts/plugin.json +23 -0
  18. package/plugins/docs/admin/templates/docs.html +69 -0
  19. package/plugins/docs/admin/views/docs.js +276 -0
  20. package/plugins/docs/config.js +8 -0
  21. package/plugins/docs/data/documents/452f49b7-9c93-4a67-874d-27f882891ad2.json +11 -0
  22. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +11 -0
  23. package/plugins/docs/data/folders.json +9 -0
  24. package/plugins/docs/data/templates.json +1 -0
  25. package/plugins/docs/plugin.js +375 -0
  26. package/plugins/docs/plugin.json +23 -0
  27. package/plugins/job-board/admin/templates/application-detail.html +40 -0
  28. package/plugins/job-board/admin/templates/applications.html +10 -0
  29. package/plugins/job-board/admin/templates/companies.html +24 -0
  30. package/plugins/job-board/admin/templates/dashboard.html +36 -0
  31. package/plugins/job-board/admin/templates/job-editor.html +17 -0
  32. package/plugins/job-board/admin/templates/jobs.html +15 -0
  33. package/plugins/job-board/admin/templates/profile.html +17 -0
  34. package/plugins/job-board/admin/views/application-detail.js +62 -0
  35. package/plugins/job-board/admin/views/applications.js +47 -0
  36. package/plugins/job-board/admin/views/companies.js +104 -0
  37. package/plugins/job-board/admin/views/dashboard.js +88 -0
  38. package/plugins/job-board/admin/views/job-editor.js +86 -0
  39. package/plugins/job-board/admin/views/jobs.js +53 -0
  40. package/plugins/job-board/admin/views/profile.js +47 -0
  41. package/plugins/job-board/config.js +6 -0
  42. package/plugins/job-board/plugin.js +466 -0
  43. package/plugins/job-board/plugin.json +40 -0
  44. package/plugins/job-board/schemas/jb-agent-companies.json +17 -0
  45. package/plugins/job-board/schemas/jb-applications.json +20 -0
  46. package/plugins/job-board/schemas/jb-candidate-profiles.json +20 -0
  47. package/plugins/job-board/schemas/jb-companies.json +21 -0
  48. package/plugins/job-board/schemas/jb-jobs.json +23 -0
  49. package/plugins/notes/admin/templates/notes.html +92 -0
  50. package/plugins/notes/admin/views/notes.js +304 -0
  51. package/plugins/notes/config.js +6 -0
  52. package/plugins/notes/data/notes.json +1 -0
  53. package/plugins/notes/plugin.js +177 -0
  54. package/plugins/notes/plugin.json +23 -0
  55. package/plugins/todo/admin/templates/todo.html +164 -0
  56. package/plugins/todo/admin/views/todo.js +328 -0
  57. package/plugins/todo/config.js +7 -0
  58. package/plugins/todo/data/todos.json +1 -0
  59. package/plugins/todo/plugin.js +155 -0
  60. package/plugins/todo/plugin.json +23 -0
  61. package/server/routes/api/auth.js +2 -0
  62. package/server/routes/api/collections.js +59 -0
  63. package/server/routes/api/forms.js +3 -0
  64. package/server/routes/api/plugins.js +9 -1
  65. package/server/routes/api/settings.js +16 -1
  66. package/server/routes/public.js +2 -0
  67. package/server/services/markdown.js +155 -8
  68. package/server/services/plugins.js +33 -2
  69. package/plugins/example-analytics/admin/templates/analytics.html +0 -10
  70. package/plugins/example-analytics/admin/views/analytics.js +0 -51
  71. package/plugins/example-analytics/config.js +0 -6
  72. package/plugins/example-analytics/plugin.js +0 -58
  73. package/plugins/example-analytics/plugin.json +0 -45
  74. package/plugins/example-analytics/public/inject-body.html +0 -14
  75. package/plugins/example-analytics/public/inject-head.html +0 -1
  76. package/plugins/example-analytics/stats.json +0 -24
@@ -0,0 +1,164 @@
1
+ <div class="p-4">
2
+ <div class="card">
3
+ <div class="card-body">
4
+
5
+ <!-- Header -->
6
+ <div class="flex items-center justify-between mb-4">
7
+ <h2 class="text-lg font-semibold" style="margin:0;">
8
+ <span data-icon="check-square" data-icon-size="20" style="vertical-align:middle;margin-right:8px;"></span>
9
+ Todo
10
+ </h2>
11
+ <button id="clear-completed-btn" class="btn btn-sm btn-danger">
12
+ <span data-icon="trash" data-icon-size="14"></span>
13
+ Clear Completed
14
+ </button>
15
+ </div>
16
+
17
+ <!-- Add new todo row -->
18
+ <div class="flex gap-2 mb-4" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
19
+ <input
20
+ type="text"
21
+ id="todo-input"
22
+ class="form-input"
23
+ placeholder="What needs to be done?"
24
+ autocomplete="off"
25
+ style="flex:1;min-width:200px;"
26
+ />
27
+ <select id="priority-input" class="form-input" style="width:130px;">
28
+ <option value="highest">Highest</option>
29
+ <option value="high">High</option>
30
+ <option value="medium" selected>Medium</option>
31
+ <option value="low">Low</option>
32
+ <option value="lowest">Lowest</option>
33
+ </select>
34
+ <input type="date" id="due-date-input" class="form-input" title="Due date (optional)"
35
+ style="width:145px;"/>
36
+ <button id="add-todo-btn" class="btn btn-primary">
37
+ <span data-icon="plus" data-icon-size="14"></span>
38
+ Add
39
+ </button>
40
+ </div>
41
+
42
+ <!-- Filter bar -->
43
+ <div id="filter-bar" class="flex gap-2 mb-4" style="display:flex;gap:8px;flex-wrap:wrap;">
44
+ <button class="btn btn-sm filter-btn active" data-filter="all">All</button>
45
+ <button class="btn btn-sm filter-btn" data-filter="not-started">Not Started</button>
46
+ <button class="btn btn-sm filter-btn" data-filter="in-progress">In Progress</button>
47
+ <button class="btn btn-sm filter-btn" data-filter="completed">Completed</button>
48
+ </div>
49
+
50
+ <!-- Todo list -->
51
+ <ul id="todo-list" style="list-style:none;padding:0;margin:0;"></ul>
52
+
53
+ <!-- Empty state -->
54
+ <div id="empty-state" style="display:none;text-align:center;padding:40px 0;color:var(--dm-text-muted);">
55
+ <span data-icon="check-circle" data-icon-size="48" style="display:block;margin-bottom:12px;opacity:0.4;"></span>
56
+ <p style="margin:0;font-size:0.95rem;">No tasks yet. Add one above to get started.</p>
57
+ </div>
58
+
59
+ </div>
60
+ </div>
61
+ </div>
62
+
63
+ <style>
64
+ .todo-item {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 10px;
68
+ padding: 9px 0;
69
+ border-bottom: 1px solid var(--dm-border);
70
+ }
71
+
72
+ .todo-item:last-child {
73
+ border-bottom: none;
74
+ }
75
+ .todo-item.completed .todo-text {
76
+ text-decoration: line-through;
77
+ opacity: 0.45;
78
+ }
79
+
80
+ .todo-checkbox {
81
+ flex-shrink: 0;
82
+ }
83
+
84
+ .todo-text {
85
+ flex: 1;
86
+ min-width: 0;
87
+ word-break: break-word;
88
+ font-size: 0.9rem;
89
+ }
90
+
91
+ .todo-selects {
92
+ display: flex;
93
+ gap: 5px;
94
+ flex-shrink: 0;
95
+ }
96
+
97
+ .todo-select-pill {
98
+ appearance: none;
99
+ -webkit-appearance: none;
100
+ border: none;
101
+ border-radius: 999px;
102
+ padding: 2px 10px 2px 8px;
103
+ font-size: 0.72rem;
104
+ font-weight: 500;
105
+ cursor: pointer;
106
+ outline: none;
107
+ }
108
+
109
+ .todo-select-pill:focus {
110
+ box-shadow: 0 0 0 2px var(--dm-primary-soft, rgba(99, 102, 241, .3));
111
+ }
112
+ .todo-actions {
113
+ display: flex;
114
+ gap: 3px;
115
+ flex-shrink: 0;
116
+ }
117
+ .filter-btn.active {
118
+ background: var(--dm-primary);
119
+ color: #fff;
120
+ border-color: var(--dm-primary);
121
+ }
122
+
123
+ /* Status pill colours */
124
+ .status-pill-not-started {
125
+ background: var(--dm-gray-200, #e5e7eb);
126
+ color: var(--dm-gray-700, #374151);
127
+ }
128
+
129
+ .status-pill-in-progress {
130
+ background: #dbeafe;
131
+ color: #2563eb;
132
+ }
133
+
134
+ .status-pill-completed {
135
+ background: #dcfce7;
136
+ color: #16a34a;
137
+ }
138
+
139
+ /* Priority pill colours */
140
+ .priority-pill-highest {
141
+ background: #fee2e2;
142
+ color: #b91c1c;
143
+ }
144
+
145
+ .priority-pill-high {
146
+ background: #ffedd5;
147
+ color: #c2410c;
148
+ }
149
+
150
+ .priority-pill-medium {
151
+ background: #fef9c3;
152
+ color: #a16207;
153
+ }
154
+
155
+ .priority-pill-low {
156
+ background: #dbeafe;
157
+ color: #1d4ed8;
158
+ }
159
+
160
+ .priority-pill-lowest {
161
+ background: #f3f4f6;
162
+ color: #6b7280;
163
+ }
164
+ </style>
@@ -0,0 +1,328 @@
1
+ // ============================================================
2
+ // Todo plugin admin view
3
+ // Uses auth-aware api() helper for all API calls (Bearer token from S.get('auth_token'))
4
+ // Uses $container.find() for all DOM queries
5
+ // Note: innerHTML usage below escapes all user-supplied text via escapeHtml()
6
+ // ============================================================
7
+
8
+ const STATUS = {
9
+ NOT_STARTED: 'not-started',
10
+ IN_PROGRESS: 'in-progress',
11
+ COMPLETED: 'completed'
12
+ };
13
+
14
+ const PRIORITY = {
15
+ HIGHEST: 'highest',
16
+ HIGH: 'high',
17
+ MEDIUM: 'medium',
18
+ LOW: 'low',
19
+ LOWEST: 'lowest'
20
+ };
21
+
22
+ function formatStatus(status) {
23
+ return status.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
24
+ }
25
+
26
+ function formatPriority(priority) {
27
+ return priority.charAt(0).toUpperCase() + priority.slice(1);
28
+ }
29
+
30
+ /** Escape user-supplied text before inserting into HTML markup */
31
+ function escapeHtml(str) {
32
+ return String(str ?? '')
33
+ .replace(/&/g, '&amp;')
34
+ .replace(/</g, '&lt;')
35
+ .replace(/>/g, '&gt;')
36
+ .replace(/"/g, '&quot;')
37
+ .replace(/'/g, '&#39;');
38
+ }
39
+
40
+ async function api(url, method = 'GET', body) {
41
+ const opts = {method, headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}};
42
+ if (body !== undefined) {
43
+ opts.headers['Content-Type'] = 'application/json';
44
+ opts.body = JSON.stringify(body);
45
+ }
46
+ const res = await fetch(url, opts);
47
+ if (!res.ok) {
48
+ const err = await res.json().catch(() => ({error: res.statusText}));
49
+ throw new Error(err.error || res.statusText);
50
+ }
51
+ const text = await res.text();
52
+ return text ? JSON.parse(text) : {};
53
+ }
54
+
55
+ export const todoView = {
56
+ templateUrl: '/plugins/todo/admin/templates/todo.html',
57
+
58
+ async onMount($container) {
59
+ // ---- State ----
60
+ let todos = [];
61
+ let activeFilter = 'all';
62
+
63
+ // ---- Fetch ----
64
+ async function loadTodos() {
65
+ try {
66
+ const res = await api('/api/plugins/todo/items');
67
+ todos = Array.isArray(res) ? res : (res.data ?? []);
68
+ } catch (err) {
69
+ E.toast('Failed to load todos', { type: 'error' });
70
+ todos = [];
71
+ }
72
+ }
73
+
74
+ // ---- Render ----
75
+ function render() {
76
+ const listEl = $container.find('#todo-list').get(0);
77
+ const emptyEl = $container.find('#empty-state').get(0);
78
+
79
+ const filtered = activeFilter === 'all'
80
+ ? todos
81
+ : todos.filter((t) => t.status === activeFilter);
82
+
83
+ if (filtered.length === 0) {
84
+ // Safe: no user content, only static markup
85
+ listEl.textContent = '';
86
+ emptyEl.style.display = 'block';
87
+ return;
88
+ }
89
+
90
+ emptyEl.style.display = 'none';
91
+
92
+ // Build markup — all user values run through escapeHtml()
93
+ const rows = filtered.map((todo) => {
94
+ const isCompleted = todo.status === STATUS.COMPLETED;
95
+ const safeText = escapeHtml(todo.text);
96
+ const safeId = escapeHtml(todo.id);
97
+
98
+ const statusClass = `status-pill-${escapeHtml(todo.status)}`;
99
+ const priorityClass = `priority-pill-${escapeHtml(todo.priority)}`;
100
+
101
+ let dueBadge = '';
102
+ if (todo.dueAt) {
103
+ const due = new Date(todo.dueAt);
104
+ const today = new Date();
105
+ today.setHours(0, 0, 0, 0);
106
+ const overdue = !isCompleted && due < today;
107
+ const dueLabel = D(todo.dueAt).format('D MMM');
108
+ dueBadge = `<span class="badge ${overdue ? 'badge-danger' : 'badge-outline'}" style="font-size:0.68rem;padding:1px 6px;flex-shrink:0;" title="Due ${escapeHtml(dueLabel)}">${overdue ? '⚠ ' : ''}${escapeHtml(dueLabel)}</span>`;
109
+ }
110
+
111
+ return `<li class="todo-item${isCompleted ? ' completed' : ''}" data-id="${safeId}">
112
+ <input type="checkbox" class="form-check-input todo-checkbox" ${isCompleted ? 'checked' : ''} />
113
+ <span class="todo-text">${safeText}${dueBadge ? ' ' + dueBadge : ''}</span>
114
+ <div class="todo-selects">
115
+ <select class="todo-select-pill status-select ${statusClass}" data-id="${safeId}" title="Status">
116
+ <option value="not-started"${todo.status === 'not-started' ? ' selected' : ''}>Not Started</option>
117
+ <option value="in-progress"${todo.status === 'in-progress' ? ' selected' : ''}>In Progress</option>
118
+ <option value="completed"${todo.status === 'completed' ? ' selected' : ''}>Completed</option>
119
+ </select>
120
+ <select class="todo-select-pill priority-select ${priorityClass}" data-id="${safeId}" title="Priority">
121
+ <option value="highest"${todo.priority === 'highest' ? ' selected' : ''}>Highest</option>
122
+ <option value="high"${todo.priority === 'high' ? ' selected' : ''}>High</option>
123
+ <option value="medium"${todo.priority === 'medium' ? ' selected' : ''}>Medium</option>
124
+ <option value="low"${todo.priority === 'low' ? ' selected' : ''}>Low</option>
125
+ <option value="lowest"${todo.priority === 'lowest' ? ' selected' : ''}>Lowest</option>
126
+ </select>
127
+ </div>
128
+ <div class="todo-actions">
129
+ <button class="btn btn-sm btn-ghost edit-todo" data-id="${safeId}" title="Edit">
130
+ <span data-icon="edit" data-icon-size="13"></span>
131
+ </button>
132
+ <button class="btn btn-sm btn-danger delete-todo" data-id="${safeId}" title="Delete">
133
+ <span data-icon="trash" data-icon-size="13"></span>
134
+ </button>
135
+ </div>
136
+ </li>`;
137
+ }).join('');
138
+
139
+ listEl.innerHTML = rows;
140
+ Domma.icons.scan(listEl);
141
+ }
142
+
143
+ // ---- Add todo ----
144
+ async function addTodo() {
145
+ const $input = $container.find('#todo-input');
146
+ const $priority = $container.find('#priority-input');
147
+ const $due = $container.find('#due-date-input');
148
+ const text = $input.get(0).value.trim();
149
+ const priority = $priority.get(0).value;
150
+ const dueAt = $due.get(0)?.value || null;
151
+
152
+ if (!text) {
153
+ E.toast('Please enter a task', { type: 'warning' });
154
+ return;
155
+ }
156
+
157
+ try {
158
+ const item = await api('/api/plugins/todo/items', 'POST', {
159
+ text,
160
+ priority,
161
+ status: STATUS.NOT_STARTED,
162
+ dueAt
163
+ });
164
+ todos.push(item);
165
+ $input.get(0).value = '';
166
+ $priority.get(0).value = PRIORITY.MEDIUM;
167
+ if ($due.get(0)) $due.get(0).value = '';
168
+ render();
169
+ E.toast('Task added', { type: 'success' });
170
+ } catch (err) {
171
+ E.toast('Failed to add task', { type: 'error' });
172
+ }
173
+ }
174
+
175
+ // ---- Toggle (checkbox) ----
176
+ async function toggleTodo(id) {
177
+ const todo = todos.find((t) => t.id === id);
178
+ if (!todo) return;
179
+
180
+ const newStatus = todo.status === STATUS.COMPLETED ? STATUS.NOT_STARTED : STATUS.COMPLETED;
181
+ try {
182
+ const updated = await api(`/api/plugins/todo/items/${id}`, 'PUT', {status: newStatus});
183
+ Object.assign(todo, updated);
184
+ render();
185
+ E.toast(newStatus === STATUS.COMPLETED ? 'Task completed' : 'Task reopened', { type: 'success' });
186
+ } catch (err) {
187
+ E.toast('Failed to update task', { type: 'error' });
188
+ }
189
+ }
190
+
191
+ // ---- Edit todo text ----
192
+ async function editTodo(id) {
193
+ const todo = todos.find((t) => t.id === id);
194
+ if (!todo) return;
195
+
196
+ const newText = await E.prompt('Edit your task:', { inputValue: todo.text });
197
+ if (!newText || !newText.trim()) return;
198
+
199
+ try {
200
+ const updated = await api(`/api/plugins/todo/items/${id}`, 'PUT', {text: newText.trim()});
201
+ Object.assign(todo, updated);
202
+ render();
203
+ E.toast('Task updated', { type: 'success' });
204
+ } catch (err) {
205
+ E.toast('Failed to update task', { type: 'error' });
206
+ }
207
+ }
208
+
209
+ // ---- Delete single ----
210
+ async function deleteTodo(id) {
211
+ const confirmed = await E.confirm('Delete this task?');
212
+ if (!confirmed) return;
213
+
214
+ try {
215
+ await api(`/api/plugins/todo/items/${id}`, 'DELETE');
216
+ todos = todos.filter((t) => t.id !== id);
217
+ render();
218
+ E.toast('Task deleted', { type: 'success' });
219
+ } catch (err) {
220
+ E.toast('Failed to delete task', { type: 'error' });
221
+ }
222
+ }
223
+
224
+ // ---- Update status ----
225
+ async function updateStatus(id, status) {
226
+ const todo = todos.find((t) => t.id === id);
227
+ if (!todo) return;
228
+
229
+ try {
230
+ const updated = await api(`/api/plugins/todo/items/${id}`, 'PUT', {status});
231
+ Object.assign(todo, updated);
232
+ render();
233
+ E.toast(`Status: ${formatStatus(status)}`, { type: 'success' });
234
+ } catch (err) {
235
+ E.toast('Failed to update status', { type: 'error' });
236
+ }
237
+ }
238
+
239
+ // ---- Update priority ----
240
+ async function updatePriority(id, priority) {
241
+ const todo = todos.find((t) => t.id === id);
242
+ if (!todo) return;
243
+
244
+ try {
245
+ const updated = await api(`/api/plugins/todo/items/${id}`, 'PUT', {priority});
246
+ Object.assign(todo, updated);
247
+ render();
248
+ E.toast(`Priority: ${formatPriority(priority)}`, { type: 'success' });
249
+ } catch (err) {
250
+ E.toast('Failed to update priority', { type: 'error' });
251
+ }
252
+ }
253
+
254
+ // ---- Clear completed ----
255
+ async function clearCompleted() {
256
+ const hasCompleted = todos.some((t) => t.status === STATUS.COMPLETED);
257
+ if (!hasCompleted) {
258
+ E.toast('No completed tasks to clear', { type: 'warning' });
259
+ return;
260
+ }
261
+
262
+ const confirmed = await E.confirm('Clear all completed tasks?');
263
+ if (!confirmed) return;
264
+
265
+ try {
266
+ await api('/api/plugins/todo/completed', 'DELETE');
267
+ todos = todos.filter((t) => t.status !== STATUS.COMPLETED);
268
+ render();
269
+ E.toast('Completed tasks cleared', { type: 'success' });
270
+ } catch (err) {
271
+ E.toast('Failed to clear completed tasks', { type: 'error' });
272
+ }
273
+ }
274
+
275
+ // ---- Event bindings ----
276
+
277
+ $container.find('#add-todo-btn').get(0).addEventListener('click', addTodo);
278
+
279
+ $container.find('#todo-input').get(0).addEventListener('keydown', (e) => {
280
+ if (e.key === 'Enter') addTodo();
281
+ });
282
+
283
+ $container.find('#clear-completed-btn').get(0).addEventListener('click', clearCompleted);
284
+
285
+ // Filter buttons — direct addEventListener to avoid namespaced delegation issues
286
+ $container.find('#filter-bar').get(0).querySelectorAll('.filter-btn').forEach((btn) => {
287
+ btn.addEventListener('click', () => {
288
+ activeFilter = btn.dataset.filter;
289
+ $container.find('#filter-bar').get(0).querySelectorAll('.filter-btn').forEach((b) => b.classList.remove('active'));
290
+ btn.classList.add('active');
291
+ render();
292
+ });
293
+ });
294
+
295
+ // Todo list — native event delegation on the list element
296
+ const listEl = $container.find('#todo-list').get(0);
297
+
298
+ listEl.addEventListener('change', (e) => {
299
+ const target = e.target;
300
+ const id = target.dataset.id ?? target.closest('.todo-item')?.dataset.id;
301
+ if (!id) return;
302
+
303
+ if (target.classList.contains('todo-checkbox')) {
304
+ toggleTodo(id);
305
+ } else if (target.classList.contains('status-select')) {
306
+ // Update pill colour immediately
307
+ target.className = target.className.replace(/status-pill-\S+/, `status-pill-${target.value}`);
308
+ updateStatus(id, target.value);
309
+ } else if (target.classList.contains('priority-select')) {
310
+ target.className = target.className.replace(/priority-pill-\S+/, `priority-pill-${target.value}`);
311
+ updatePriority(id, target.value);
312
+ }
313
+ });
314
+
315
+ listEl.addEventListener('click', (e) => {
316
+ const editBtn = e.target.closest('.edit-todo');
317
+ const deleteBtn = e.target.closest('.delete-todo');
318
+
319
+ if (editBtn) editTodo(editBtn.dataset.id);
320
+ if (deleteBtn) deleteTodo(deleteBtn.dataset.id);
321
+ });
322
+
323
+ // ---- Initial load ----
324
+ await loadTodos();
325
+ render();
326
+ Domma.icons.scan($container.get(0));
327
+ }
328
+ };
@@ -0,0 +1,7 @@
1
+ export default {
2
+ scope: 'user',
3
+ defaultPriority: 'medium',
4
+ maxPerUser: 500,
5
+ // storage.adapter: 'mongodb' uses config/connections.json; 'file' uses local JSON
6
+ storage: {adapter: 'mongodb', connection: 'default'}
7
+ };
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,155 @@
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 SLUG = 'todos';
13
+ const STORAGE = defaultConfig.storage ?? {adapter: 'file'};
14
+
15
+ const FIELDS = [
16
+ {name: 'text', label: 'Task', type: 'text', required: true},
17
+ {name: 'status', label: 'Status', type: 'text', required: false},
18
+ {name: 'priority', label: 'Priority', type: 'text', required: false},
19
+ {name: 'dueAt', label: 'Due Date', type: 'text', required: false},
20
+ {name: 'userId', label: 'User ID', type: 'text', required: false}
21
+ ];
22
+
23
+ /**
24
+ * Lifecycle: create the todos collection (MongoDB-backed) on plugin enable.
25
+ */
26
+ export async function onEnable({services: {collections}}) {
27
+ const existing = await collections.getCollection(SLUG).catch(() => null);
28
+ if (existing) return;
29
+ await collections.createCollection({
30
+ title: 'Todos',
31
+ slug: SLUG,
32
+ description: 'Todo items managed by the Todo plugin.',
33
+ fields: FIELDS,
34
+ storage: STORAGE
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Lifecycle: remove the todos collection on plugin disable.
40
+ */
41
+ export async function onDisable({services: {collections}}) {
42
+ await collections.deleteCollection(SLUG).catch(() => {
43
+ });
44
+ }
45
+
46
+ /** Flatten a collection entry into the shape the admin view expects. */
47
+ function toTodo(entry) {
48
+ return {
49
+ id: entry.id,
50
+ ...entry.data,
51
+ createdAt: entry.meta.createdAt,
52
+ updatedAt: entry.meta.updatedAt
53
+ };
54
+ }
55
+
56
+ export default async function todoPlugin(fastify, options) {
57
+ const { authenticate } = options.auth;
58
+ const config = {...defaultConfig, ...(options.settings || {})};
59
+ const scope = config.scope ?? 'user';
60
+ const storage = config.storage ?? STORAGE;
61
+
62
+ // Auto-create the collection if it doesn't exist yet.
63
+ const existing = await getCollection(SLUG).catch(() => null);
64
+ if (!existing) {
65
+ await createCollection({
66
+ title: 'Todos',
67
+ slug: SLUG,
68
+ description: 'Todo items managed by the Todo plugin.',
69
+ fields: FIELDS,
70
+ storage
71
+ }).catch(err => fastify.log.warn(`[todo] Collection setup: ${err.message}`));
72
+ }
73
+
74
+ function userId(request) {
75
+ return scope === 'user' ? (request.user?.id ?? request.user?.sub ?? null) : null;
76
+ }
77
+
78
+ async function loadAll(uid) {
79
+ const {entries} = await listEntries(SLUG, {limit: 10000, sort: 'createdAt', order: 'asc'});
80
+ let items = entries.map(toTodo);
81
+ if (uid) items = items.filter(t => t.userId === uid);
82
+ return items;
83
+ }
84
+
85
+ /** GET /api/plugins/todo/items */
86
+ fastify.get('/items', { preHandler: [authenticate] }, async (request, reply) => {
87
+ const uid = userId(request);
88
+ const { status } = request.query;
89
+ let items = await loadAll(uid);
90
+ if (status) items = items.filter(t => t.status === status);
91
+ return reply.send(items);
92
+ });
93
+
94
+ /** POST /api/plugins/todo/items */
95
+ fastify.post('/items', { preHandler: [authenticate] }, async (request, reply) => {
96
+ const uid = userId(request);
97
+ const { text, priority, status } = request.body ?? {};
98
+
99
+ if (!text || typeof text !== 'string' || !text.trim()) {
100
+ return reply.code(400).send({ error: 'text is required' });
101
+ }
102
+
103
+ const {dueAt} = request.body ?? {};
104
+ const entry = await createEntry(SLUG, {
105
+ text: text.trim(),
106
+ status: status ?? 'not-started',
107
+ priority: priority ?? config.defaultPriority ?? 'medium',
108
+ dueAt: dueAt ?? null,
109
+ userId: uid ?? null
110
+ }, {createdBy: uid, source: 'admin'});
111
+
112
+ return reply.code(201).send(toTodo(entry));
113
+ });
114
+
115
+ /** PUT /api/plugins/todo/items/:id */
116
+ fastify.put('/items/:id', { preHandler: [authenticate] }, async (request, reply) => {
117
+ const uid = userId(request);
118
+ const { id } = request.params;
119
+ const patch = request.body ?? {};
120
+
121
+ const entry = await getEntry(SLUG, id);
122
+ if (!entry) return reply.code(404).send({error: 'Todo not found'});
123
+ if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Todo not found'});
124
+
125
+ const merged = {...entry.data};
126
+ for (const key of ['text', 'status', 'priority', 'dueAt']) {
127
+ if (key in patch) merged[key] = key === 'text' ? String(patch[key]).trim() : patch[key];
128
+ }
129
+
130
+ const updated = await updateEntry(SLUG, id, merged);
131
+ return reply.send(toTodo(updated));
132
+ });
133
+
134
+ /** DELETE /api/plugins/todo/completed — must be registered before /:id */
135
+ fastify.delete('/completed', {preHandler: [authenticate]}, async (request, reply) => {
136
+ const uid = userId(request);
137
+ const all = await loadAll(uid);
138
+ const completed = all.filter(t => t.status === 'completed');
139
+ await Promise.all(completed.map(t => deleteEntry(SLUG, t.id)));
140
+ return reply.send({success: true, removed: completed.length});
141
+ });
142
+
143
+ /** DELETE /api/plugins/todo/items/:id */
144
+ fastify.delete('/items/:id', { preHandler: [authenticate] }, async (request, reply) => {
145
+ const uid = userId(request);
146
+ const { id } = request.params;
147
+
148
+ const entry = await getEntry(SLUG, id);
149
+ if (!entry) return reply.code(404).send({error: 'Todo not found'});
150
+ if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Todo not found'});
151
+
152
+ await deleteEntry(SLUG, id);
153
+ return reply.send({ success: true });
154
+ });
155
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "todo",
3
+ "displayName": "Todo",
4
+ "version": "1.0.0",
5
+ "description": "Personal task manager with priorities and status tracking.",
6
+ "author": "Darryl Waterhouse",
7
+ "date": "2026-03-24",
8
+ "icon": "check-square",
9
+ "admin": {
10
+ "sidebar": [
11
+ { "id": "todo", "text": "Todo", "icon": "check-square", "url": "#/plugins/todo", "section": "#/plugins/todo" }
12
+ ],
13
+ "routes": [
14
+ { "path": "/plugins/todo", "view": "plugin-todo", "title": "Todo - Domma CMS" }
15
+ ],
16
+ "views": {
17
+ "plugin-todo": {
18
+ "entry": "todo/admin/views/todo.js",
19
+ "exportName": "todoView"
20
+ }
21
+ }
22
+ }
23
+ }