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.
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/markdown-toolbar.js +14 -14
- package/admin/js/views/collection-editor.js +5 -3
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/page-editor.js +27 -27
- package/config/plugins.json +16 -0
- package/config/site.json +1 -1
- package/package.json +2 -2
- package/plugins/analytics/stats.json +1 -1
- package/plugins/contacts/admin/templates/contacts.html +126 -0
- package/plugins/contacts/admin/views/contacts.js +710 -0
- package/plugins/contacts/config.js +6 -0
- package/plugins/contacts/data/contacts.json +20 -0
- package/plugins/contacts/plugin.js +351 -0
- package/plugins/contacts/plugin.json +23 -0
- package/plugins/docs/admin/templates/docs.html +69 -0
- package/plugins/docs/admin/views/docs.js +276 -0
- package/plugins/docs/config.js +8 -0
- package/plugins/docs/data/documents/452f49b7-9c93-4a67-874d-27f882891ad2.json +11 -0
- package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +11 -0
- package/plugins/docs/data/folders.json +9 -0
- package/plugins/docs/data/templates.json +1 -0
- package/plugins/docs/plugin.js +375 -0
- package/plugins/docs/plugin.json +23 -0
- package/plugins/notes/admin/templates/notes.html +92 -0
- package/plugins/notes/admin/views/notes.js +304 -0
- package/plugins/notes/config.js +6 -0
- package/plugins/notes/data/notes.json +1 -0
- package/plugins/notes/plugin.js +177 -0
- package/plugins/notes/plugin.json +23 -0
- package/plugins/todo/admin/templates/todo.html +164 -0
- package/plugins/todo/admin/views/todo.js +328 -0
- package/plugins/todo/config.js +7 -0
- package/plugins/todo/data/todos.json +1 -0
- package/plugins/todo/plugin.js +155 -0
- package/plugins/todo/plugin.json +23 -0
- package/server/routes/api/auth.js +2 -0
- package/server/routes/api/collections.js +55 -0
- package/server/routes/api/forms.js +3 -0
- package/server/routes/api/settings.js +16 -1
- package/server/routes/public.js +2 -0
- package/server/services/markdown.js +169 -8
- package/server/services/plugins.js +3 -2
|
@@ -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, '&')
|
|
34
|
+
.replace(/</g, '<')
|
|
35
|
+
.replace(/>/g, '>')
|
|
36
|
+
.replace(/"/g, '"')
|
|
37
|
+
.replace(/'/g, ''');
|
|
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 @@
|
|
|
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
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
11
|
import {config, getConfig} from '../../config.js';
|
|
12
|
+
import {hooks} from '../../services/hooks.js';
|
|
12
13
|
import {authenticate, getPermissionsForRole} from '../../middleware/auth.js';
|
|
13
14
|
import {GROUP_ORDER, REGISTRY} from '../../services/permissionRegistry.js';
|
|
14
15
|
import {
|
|
@@ -93,6 +94,7 @@ export async function authRoutes(fastify) {
|
|
|
93
94
|
await touchLastLogin(user.id);
|
|
94
95
|
|
|
95
96
|
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
|
|
97
|
+
hooks.emit('user:loggedIn', {userId: user.id, email: user.email, role: user.role});
|
|
96
98
|
const { token, refreshToken } = signTokens(fastify, safeUser);
|
|
97
99
|
return { token, refreshToken, user: safeUser };
|
|
98
100
|
});
|
|
@@ -253,6 +253,61 @@ export async function collectionsRoutes(fastify) {
|
|
|
253
253
|
}
|
|
254
254
|
});
|
|
255
255
|
|
|
256
|
+
// -------------------------------------------------------------------------
|
|
257
|
+
// Storage migration
|
|
258
|
+
// -------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
fastify.post('/collections/:slug/migrate-storage', canUpdate, async (request, reply) => {
|
|
261
|
+
const {slug} = request.params;
|
|
262
|
+
const {storage} = request.body || {};
|
|
263
|
+
|
|
264
|
+
if (!storage?.adapter) {
|
|
265
|
+
return reply.status(400).send({error: 'storage.adapter is required'});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const schema = await getCollection(slug);
|
|
269
|
+
if (!schema) return reply.status(404).send({error: 'Collection not found'});
|
|
270
|
+
|
|
271
|
+
const sourceAdapter = schema.storage?.adapter || 'file';
|
|
272
|
+
if (sourceAdapter === storage.adapter) {
|
|
273
|
+
return reply.status(400).send({error: 'Source and target adapters are the same'});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Step 1: read all existing entries from current adapter BEFORE schema change
|
|
277
|
+
const {entries} = await listEntries(slug, {limit: 1000000, sort: 'createdAt', order: 'asc'});
|
|
278
|
+
|
|
279
|
+
// Step 2: update schema to new adapter (also invalidates the adapter cache)
|
|
280
|
+
await updateCollection(slug, {...schema, storage});
|
|
281
|
+
|
|
282
|
+
// Step 3: insert all entries into the new adapter
|
|
283
|
+
let migrated = 0;
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
try {
|
|
286
|
+
await createEntry(slug, entry.data, {
|
|
287
|
+
createdBy: entry.meta?.createdBy || null,
|
|
288
|
+
source: 'migration'
|
|
289
|
+
});
|
|
290
|
+
migrated++;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
fastify.log.warn(`[migrate-storage] Entry ${entry.id} skipped: ${err.message}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Step 4: if migrating away from file storage, archive the old data.json
|
|
297
|
+
if (sourceAdapter === 'file') {
|
|
298
|
+
try {
|
|
299
|
+
const {rename} = await import('fs/promises');
|
|
300
|
+
const {join} = await import('path');
|
|
301
|
+
const dataPath = join(process.cwd(), 'content', 'collections', slug, 'data.json');
|
|
302
|
+
await rename(dataPath, dataPath + '.bak');
|
|
303
|
+
} catch {
|
|
304
|
+
// data.json may not exist or rename already done
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {migrated, total: entries.length};
|
|
309
|
+
});
|
|
310
|
+
|
|
256
311
|
// -------------------------------------------------------------------------
|
|
257
312
|
// Export / Import
|
|
258
313
|
// -------------------------------------------------------------------------
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from '../../services/collections.js';
|
|
34
34
|
import {getConfig} from '../../config.js';
|
|
35
35
|
import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
|
|
36
|
+
import {hooks} from '../../services/hooks.js';
|
|
36
37
|
|
|
37
38
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
38
39
|
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
@@ -453,6 +454,8 @@ export async function formsRoutes(fastify) {
|
|
|
453
454
|
}
|
|
454
455
|
}
|
|
455
456
|
|
|
457
|
+
hooks.emit('form:submitted', {slug, entryId: entry?.id || null, data, formTitle: form.title});
|
|
458
|
+
|
|
456
459
|
return {
|
|
457
460
|
ok: true,
|
|
458
461
|
message: settings.successMessage || 'Thank you for your submission.',
|
|
@@ -13,7 +13,8 @@ import {fileURLToPath} from 'url';
|
|
|
13
13
|
|
|
14
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
15
|
const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
|
|
16
|
-
const
|
|
16
|
+
const CONNECTIONS_FILE = path.resolve(__dirname, '../../../config/connections.json');
|
|
17
|
+
const CUSTOM_CSS_MAX = 100 * 1024; // 100 KB
|
|
17
18
|
|
|
18
19
|
export async function settingsRoutes(fastify) {
|
|
19
20
|
const canRead = {preHandler: [authenticate, requirePermission('settings', 'read')]};
|
|
@@ -79,6 +80,20 @@ export async function settingsRoutes(fastify) {
|
|
|
79
80
|
}
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
// GET /api/settings/db-status — returns whether MongoDB connections are configured
|
|
84
|
+
fastify.get('/settings/db-status', canRead, async () => {
|
|
85
|
+
try {
|
|
86
|
+
const raw = await fs.readFile(CONNECTIONS_FILE, 'utf8');
|
|
87
|
+
const connections = JSON.parse(raw);
|
|
88
|
+
const names = Object.keys(connections).filter(k =>
|
|
89
|
+
connections[k]?.type === 'mongodb' && connections[k]?.uri
|
|
90
|
+
);
|
|
91
|
+
return {configured: names.length > 0, connections: names};
|
|
92
|
+
} catch {
|
|
93
|
+
return {configured: false, connections: []};
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
82
97
|
// GET /api/settings/custom-css — return current CSS as JSON
|
|
83
98
|
fastify.get('/settings/custom-css', canUpdate, async () => {
|
|
84
99
|
try {
|
package/server/routes/public.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import {getPage} from '../services/content.js';
|
|
8
8
|
import {renderPage} from '../services/renderer.js';
|
|
9
9
|
import {getRoleLevel} from '../services/roles.js';
|
|
10
|
+
import {hooks} from '../services/hooks.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Escape user-controlled strings before interpolating into HTML.
|
|
@@ -83,6 +84,7 @@ export async function publicRoutes(fastify) {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
const html = await renderPage(page);
|
|
87
|
+
hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
|
|
86
88
|
return reply.type('text/html').send(html);
|
|
87
89
|
});
|
|
88
90
|
}
|