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,304 @@
1
+ // ============================================================
2
+ // Notes 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
+ // Security: all user-supplied content passes through escapeHtml()
6
+ // before being assigned to innerHTML — same pattern as todo plugin.
7
+ // ============================================================
8
+
9
+ /** Escape user-supplied text before inserting into HTML markup */
10
+ function escapeHtml(str) {
11
+ return String(str ?? '')
12
+ .replace(/&/g, '&')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;')
15
+ .replace(/"/g, '&quot;')
16
+ .replace(/'/g, '&#39;');
17
+ }
18
+
19
+ /** Debounce a function call */
20
+ function debounce(fn, delay) {
21
+ let timer;
22
+ return (...args) => {
23
+ clearTimeout(timer);
24
+ timer = setTimeout(() => fn(...args), delay);
25
+ };
26
+ }
27
+
28
+ async function api(url, method = 'GET', body) {
29
+ const opts = {method, headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}};
30
+ if (body !== undefined) {
31
+ opts.headers['Content-Type'] = 'application/json';
32
+ opts.body = JSON.stringify(body);
33
+ }
34
+ const res = await fetch(url, opts);
35
+ if (!res.ok) {
36
+ const err = await res.json().catch(() => ({error: res.statusText}));
37
+ throw new Error(err.error || res.statusText);
38
+ }
39
+ const text = await res.text();
40
+ return text ? JSON.parse(text) : {};
41
+ }
42
+
43
+ export const notesView = {
44
+ templateUrl: '/plugins/notes/admin/templates/notes.html',
45
+
46
+ async onMount($container) {
47
+ // ---- State ----
48
+ let notes = [];
49
+ let categories = [];
50
+ let activeCategory = '';
51
+ let searchQuery = '';
52
+ let editingNoteId = null; // null = new note
53
+
54
+ // ---- DOM refs (resolved once after template renders) ----
55
+ const gridEl = $container.find('#notes-grid').get(0);
56
+ const emptyEl = $container.find('#notes-empty').get(0);
57
+ const editorEl = $container.find('#note-editor').get(0);
58
+ const titleInput = $container.find('#note-title').get(0);
59
+ const contentInput = $container.find('#note-content').get(0);
60
+ const catsInput = $container.find('#note-categories').get(0);
61
+ const deleteBtnEl = $container.find('#delete-note-btn').get(0);
62
+ const catFilterEl = $container.find('#category-filter').get(0);
63
+
64
+ // ---- Fetch ----
65
+ async function loadNotes() {
66
+ try {
67
+ const params = new URLSearchParams();
68
+ if (searchQuery) params.set('q', searchQuery);
69
+ if (activeCategory) params.set('category', activeCategory);
70
+ const qs = params.toString();
71
+ const url = '/api/plugins/notes/items' + (qs ? '?' + qs : '');
72
+ const res = await api(url);
73
+ notes = Array.isArray(res) ? res : (res.data ?? []);
74
+ } catch {
75
+ E.toast('Failed to load notes', { type: 'error' });
76
+ notes = [];
77
+ }
78
+ }
79
+
80
+ async function loadCategories() {
81
+ try {
82
+ const res = await api('/api/plugins/notes/categories');
83
+ categories = Array.isArray(res) ? res : [];
84
+ } catch {
85
+ categories = [];
86
+ }
87
+ }
88
+
89
+ // ---- Render notes grid ----
90
+ // Safe: all user values are run through escapeHtml() before string interpolation.
91
+ function renderGrid() {
92
+ if (notes.length === 0) {
93
+ gridEl.textContent = '';
94
+ emptyEl.style.display = 'block';
95
+ return;
96
+ }
97
+
98
+ emptyEl.style.display = 'none';
99
+
100
+ const cards = notes.map((note) => {
101
+ const safeId = escapeHtml(note.id);
102
+ const safeTitle = escapeHtml(note.title || 'Untitled');
103
+ const preview = escapeHtml((note.content || '').slice(0, 120));
104
+ const ellipsis = note.content && note.content.length > 120 ? '\u2026' : '';
105
+ const safeFromNow = escapeHtml(D(note.updatedAt).fromNow());
106
+ const safeFullDt = escapeHtml(D(note.updatedAt).format('DD MMM YYYY HH:mm'));
107
+
108
+ const catTags = Array.isArray(note.categories) && note.categories.length > 0
109
+ ? note.categories.map((c) =>
110
+ '<span class="badge badge-outline notes-cat-tag"'
111
+ + ' style="font-size:0.7rem;cursor:pointer;"'
112
+ + ' data-cat="' + escapeHtml(c) + '">'
113
+ + escapeHtml(c)
114
+ + '</span>'
115
+ ).join(' ')
116
+ : '';
117
+
118
+ const previewHtml = preview
119
+ ? '<p class="notes-card-preview" style="margin:0 0 8px;font-size:0.875rem;opacity:0.8;white-space:pre-wrap;">'
120
+ + preview + ellipsis + '</p>'
121
+ : '';
122
+
123
+ return '<div class="card notes-card" data-id="' + safeId + '" style="cursor:default;">'
124
+ + '<div class="card-body">'
125
+ + '<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:6px;">'
126
+ + '<h4 class="notes-card-title" style="margin:0;font-size:1rem;font-weight:600;flex:1;">' + safeTitle + '</h4>'
127
+ + '<button class="btn btn-sm btn-danger delete-note-card" data-id="' + safeId + '" title="Delete note" style="flex-shrink:0;">'
128
+ + '<span data-icon="trash" data-icon-size="13"></span>'
129
+ + '</button>'
130
+ + '</div>'
131
+ + previewHtml
132
+ + '<div class="notes-card-footer" style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:4px;">'
133
+ + '<div class="notes-card-cats">' + catTags + '</div>'
134
+ + '<small style="opacity:0.6;" title="' + safeFullDt + '">' + safeFromNow + '</small>'
135
+ + '</div>'
136
+ + '</div>'
137
+ + '</div>';
138
+ }).join('');
139
+
140
+ // All values in `cards` have been escaped — safe assignment.
141
+ gridEl.innerHTML = cards;
142
+ Domma.icons.scan(gridEl);
143
+ }
144
+
145
+ // ---- Render category filter bar ----
146
+ // Safe: all category strings are run through escapeHtml().
147
+ function renderCategoryFilter() {
148
+ const allActive = activeCategory === '';
149
+ let html = '<button class="btn btn-sm ' + (allActive ? 'btn-primary' : 'btn-secondary')
150
+ + ' cat-filter-btn" data-cat="" style="margin-right:4px;">All</button>';
151
+
152
+ for (const c of categories) {
153
+ const safeC = escapeHtml(c);
154
+ const isActive = activeCategory === c;
155
+ html += '<button class="btn btn-sm ' + (isActive ? 'btn-primary' : 'btn-secondary')
156
+ + ' cat-filter-btn" data-cat="' + safeC + '" style="margin-right:4px;">'
157
+ + safeC + '</button>';
158
+ }
159
+
160
+ // All values in `html` have been escaped — safe assignment.
161
+ catFilterEl.innerHTML = html;
162
+ }
163
+
164
+ // ---- Full reload ----
165
+ async function reload() {
166
+ await Promise.all([loadNotes(), loadCategories()]);
167
+ renderGrid();
168
+ renderCategoryFilter();
169
+ }
170
+
171
+ // ---- Editor helpers ----
172
+ function openEditor(note = null) {
173
+ editingNoteId = note ? note.id : null;
174
+ titleInput.value = note ? (note.title || '') : '';
175
+ contentInput.value = note ? (note.content || '') : '';
176
+ catsInput.value = note && Array.isArray(note.categories) ? note.categories.join(', ') : '';
177
+ deleteBtnEl.style.display = note ? 'inline-flex' : 'none';
178
+ editorEl.style.display = 'flex';
179
+ titleInput.focus();
180
+ }
181
+
182
+ function closeEditor() {
183
+ editorEl.style.display = 'none';
184
+ editingNoteId = null;
185
+ titleInput.value = '';
186
+ contentInput.value = '';
187
+ catsInput.value = '';
188
+ }
189
+
190
+ function parseCategoriesInput() {
191
+ return catsInput.value
192
+ .split(',')
193
+ .map((c) => c.trim())
194
+ .filter(Boolean);
195
+ }
196
+
197
+ // ---- Save (create or update) ----
198
+ async function saveNote() {
199
+ const title = titleInput.value.trim();
200
+ const content = contentInput.value.trim();
201
+ const cats = parseCategoriesInput();
202
+
203
+ try {
204
+ if (editingNoteId) {
205
+ await api('/api/plugins/notes/items/' + editingNoteId, 'PUT', {title, content, categories: cats});
206
+ E.toast('Note updated', { type: 'success' });
207
+ } else {
208
+ await api('/api/plugins/notes/items', 'POST', {title, content, categories: cats});
209
+ E.toast('Note created', { type: 'success' });
210
+ }
211
+ closeEditor();
212
+ await reload();
213
+ } catch {
214
+ E.toast('Failed to save note', { type: 'error' });
215
+ }
216
+ }
217
+
218
+ // ---- Delete ----
219
+ async function deleteNote(id) {
220
+ const confirmed = await E.confirm('Delete this note?');
221
+ if (!confirmed) return;
222
+
223
+ try {
224
+ await api('/api/plugins/notes/items/' + id, 'DELETE');
225
+ E.toast('Note deleted', { type: 'success' });
226
+ if (editingNoteId === id) closeEditor();
227
+ await reload();
228
+ } catch {
229
+ E.toast('Failed to delete note', { type: 'error' });
230
+ }
231
+ }
232
+
233
+ // ---- Event bindings ----
234
+
235
+ // New note button
236
+ $container.find('#new-note-btn').get(0).addEventListener('click', () => openEditor());
237
+
238
+ // Save button
239
+ $container.find('#save-note-btn').get(0).addEventListener('click', saveNote);
240
+
241
+ // Cancel buttons (header X and footer Cancel both call closeEditor)
242
+ $container.find('#cancel-note-btn').get(0).addEventListener('click', closeEditor);
243
+ $container.find('#cancel-note-btn-footer').get(0).addEventListener('click', closeEditor);
244
+
245
+ // Delete button (inside editor)
246
+ deleteBtnEl.addEventListener('click', () => {
247
+ if (editingNoteId) deleteNote(editingNoteId);
248
+ });
249
+
250
+ // Search input (debounced)
251
+ $container.find('#notes-search').get(0).addEventListener('input', debounce(async (e) => {
252
+ searchQuery = e.target.value.trim();
253
+ await loadNotes();
254
+ renderGrid();
255
+ }, 300));
256
+
257
+ // Category filter — native delegation on catFilterEl
258
+ catFilterEl.addEventListener('click', async (e) => {
259
+ const btn = e.target.closest('.cat-filter-btn');
260
+ if (!btn) return;
261
+ activeCategory = btn.dataset.cat;
262
+ renderCategoryFilter();
263
+ await loadNotes();
264
+ renderGrid();
265
+ });
266
+
267
+ // Notes grid — native delegation
268
+ gridEl.addEventListener('click', (e) => {
269
+ // Delete button on card
270
+ const deleteBtn = e.target.closest('.delete-note-card');
271
+ if (deleteBtn) {
272
+ deleteNote(deleteBtn.dataset.id);
273
+ return;
274
+ }
275
+
276
+ // Category tag — filter by that category
277
+ const catTag = e.target.closest('.notes-cat-tag');
278
+ if (catTag) {
279
+ activeCategory = catTag.dataset.cat;
280
+ renderCategoryFilter();
281
+ loadNotes().then(renderGrid);
282
+ return;
283
+ }
284
+
285
+ // Click anywhere else on card — open editor
286
+ const card = e.target.closest('.notes-card');
287
+ if (card) {
288
+ const note = notes.find((n) => n.id === card.dataset.id);
289
+ if (note) openEditor(note);
290
+ }
291
+ });
292
+
293
+ // Ctrl+Enter / Cmd+Enter to save from editor
294
+ editorEl.addEventListener('keydown', (e) => {
295
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
296
+ saveNote();
297
+ }
298
+ });
299
+
300
+ // ---- Initial load ----
301
+ await reload();
302
+ Domma.icons.scan($container.get(0));
303
+ }
304
+ };
@@ -0,0 +1,6 @@
1
+ export default {
2
+ scope: 'user',
3
+ maxPerUser: 1000,
4
+ // storage.adapter: 'mongodb' uses config/connections.json; 'file' uses local JSON
5
+ storage: {adapter: 'mongodb', connection: 'default'}
6
+ };
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,177 @@
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 = 'user-notes';
13
+ const STORAGE = defaultConfig.storage ?? {adapter: 'file'};
14
+
15
+ const FIELDS = [
16
+ {name: 'title', label: 'Title', type: 'text', required: false},
17
+ {name: 'content', label: 'Content', type: 'textarea', required: false},
18
+ {name: 'categories', label: 'Categories', type: 'text', required: false},
19
+ {name: 'userId', label: 'User ID', type: 'text', required: false}
20
+ ];
21
+
22
+ /**
23
+ * Lifecycle: create the notes collection (MongoDB-backed) on plugin enable.
24
+ */
25
+ export async function onEnable({services: {collections}}) {
26
+ const existing = await collections.getCollection(SLUG).catch(() => null);
27
+ if (existing) return;
28
+ await collections.createCollection({
29
+ title: 'Notes',
30
+ slug: SLUG,
31
+ description: 'Notes managed by the Notes plugin.',
32
+ fields: FIELDS,
33
+ storage: STORAGE
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Lifecycle: remove the notes collection on plugin disable.
39
+ */
40
+ export async function onDisable({services: {collections}}) {
41
+ await collections.deleteCollection(SLUG).catch(() => {
42
+ });
43
+ }
44
+
45
+ /** Flatten a collection entry into the shape the admin view expects. */
46
+ function toNote(entry) {
47
+ return {
48
+ id: entry.id,
49
+ ...entry.data,
50
+ categories: Array.isArray(entry.data.categories) ? entry.data.categories : [],
51
+ createdAt: entry.meta.createdAt,
52
+ updatedAt: entry.meta.updatedAt
53
+ };
54
+ }
55
+
56
+ export default async function notesPlugin(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: 'Notes',
67
+ slug: SLUG,
68
+ description: 'Notes managed by the Notes plugin.',
69
+ fields: FIELDS,
70
+ storage
71
+ }).catch(err => fastify.log.warn(`[notes] 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: 'updatedAt', order: 'desc'});
80
+ let items = entries.map(toNote);
81
+ if (uid) items = items.filter(n => n.userId === uid);
82
+ return items;
83
+ }
84
+
85
+ /** GET /api/plugins/notes/items — supports ?q= and ?category= */
86
+ fastify.get('/items', { preHandler: [authenticate] }, async (request, reply) => {
87
+ const uid = userId(request);
88
+ const { q, category } = request.query;
89
+
90
+ let items = await loadAll(uid);
91
+
92
+ if (q && typeof q === 'string' && q.trim()) {
93
+ const term = q.trim().toLowerCase();
94
+ items = items.filter(n =>
95
+ (n.title ?? '').toLowerCase().includes(term) ||
96
+ (n.content ?? '').toLowerCase().includes(term)
97
+ );
98
+ }
99
+
100
+ if (category && typeof category === 'string' && category.trim()) {
101
+ const cat = category.trim().toLowerCase();
102
+ items = items.filter(n =>
103
+ Array.isArray(n.categories) && n.categories.some(c => c.toLowerCase() === cat)
104
+ );
105
+ }
106
+
107
+ return reply.send(items);
108
+ });
109
+
110
+ /** POST /api/plugins/notes/items */
111
+ fastify.post('/items', { preHandler: [authenticate] }, async (request, reply) => {
112
+ const uid = userId(request);
113
+ const { title, content, categories } = request.body ?? {};
114
+
115
+ const entry = await createEntry(SLUG, {
116
+ title: typeof title === 'string' ? title.trim() : '',
117
+ content: typeof content === 'string' ? content.trim() : '',
118
+ categories: Array.isArray(categories)
119
+ ? categories.map(c => String(c).trim()).filter(Boolean)
120
+ : [],
121
+ userId: uid ?? null
122
+ }, {createdBy: uid, source: 'admin'});
123
+
124
+ return reply.code(201).send(toNote(entry));
125
+ });
126
+
127
+ /** PUT /api/plugins/notes/items/:id */
128
+ fastify.put('/items/:id', { preHandler: [authenticate] }, async (request, reply) => {
129
+ const uid = userId(request);
130
+ const { id } = request.params;
131
+ const { title, content, categories } = request.body ?? {};
132
+
133
+ const entry = await getEntry(SLUG, id);
134
+ if (!entry) return reply.code(404).send({error: 'Note not found'});
135
+ if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Note not found'});
136
+
137
+ const merged = {...entry.data};
138
+ if (title !== undefined) merged.title = String(title).trim();
139
+ if (content !== undefined) merged.content = String(content).trim();
140
+ if (categories !== undefined) {
141
+ merged.categories = Array.isArray(categories)
142
+ ? categories.map(c => String(c).trim()).filter(Boolean)
143
+ : [];
144
+ }
145
+
146
+ const updated = await updateEntry(SLUG, id, merged);
147
+ return reply.send(toNote(updated));
148
+ });
149
+
150
+ /** DELETE /api/plugins/notes/items/:id */
151
+ fastify.delete('/items/:id', { preHandler: [authenticate] }, async (request, reply) => {
152
+ const uid = userId(request);
153
+ const { id } = request.params;
154
+
155
+ const entry = await getEntry(SLUG, id);
156
+ if (!entry) return reply.code(404).send({error: 'Note not found'});
157
+ if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Note not found'});
158
+
159
+ await deleteEntry(SLUG, id);
160
+ return reply.send({ success: true });
161
+ });
162
+
163
+ /** GET /api/plugins/notes/categories */
164
+ fastify.get('/categories', { preHandler: [authenticate] }, async (request, reply) => {
165
+ const uid = userId(request);
166
+ const items = await loadAll(uid);
167
+ const cats = new Set();
168
+ for (const note of items) {
169
+ if (Array.isArray(note.categories)) {
170
+ note.categories.forEach(c => {
171
+ if (c) cats.add(c.trim());
172
+ });
173
+ }
174
+ }
175
+ return reply.send([...cats].sort());
176
+ });
177
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "notes",
3
+ "displayName": "Notes",
4
+ "version": "1.0.0",
5
+ "description": "Rich note-taking with categories, search, and markdown content.",
6
+ "author": "Darryl Waterhouse",
7
+ "date": "2026-03-24",
8
+ "icon": "file-text",
9
+ "admin": {
10
+ "sidebar": [
11
+ { "id": "notes", "text": "Notes", "icon": "file-text", "url": "#/plugins/notes", "section": "#/plugins/notes" }
12
+ ],
13
+ "routes": [
14
+ { "path": "/plugins/notes", "view": "plugin-notes", "title": "Notes - Domma CMS" }
15
+ ],
16
+ "views": {
17
+ "plugin-notes": {
18
+ "entry": "notes/admin/views/notes.js",
19
+ "exportName": "notesView"
20
+ }
21
+ }
22
+ }
23
+ }
@@ -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>