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.
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +14 -12
- 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 +20 -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/job-board/admin/templates/application-detail.html +40 -0
- package/plugins/job-board/admin/templates/applications.html +10 -0
- package/plugins/job-board/admin/templates/companies.html +24 -0
- package/plugins/job-board/admin/templates/dashboard.html +36 -0
- package/plugins/job-board/admin/templates/job-editor.html +17 -0
- package/plugins/job-board/admin/templates/jobs.html +15 -0
- package/plugins/job-board/admin/templates/profile.html +17 -0
- package/plugins/job-board/admin/views/application-detail.js +62 -0
- package/plugins/job-board/admin/views/applications.js +47 -0
- package/plugins/job-board/admin/views/companies.js +104 -0
- package/plugins/job-board/admin/views/dashboard.js +88 -0
- package/plugins/job-board/admin/views/job-editor.js +86 -0
- package/plugins/job-board/admin/views/jobs.js +53 -0
- package/plugins/job-board/admin/views/profile.js +47 -0
- package/plugins/job-board/config.js +6 -0
- package/plugins/job-board/plugin.js +466 -0
- package/plugins/job-board/plugin.json +40 -0
- package/plugins/job-board/schemas/jb-agent-companies.json +17 -0
- package/plugins/job-board/schemas/jb-applications.json +20 -0
- package/plugins/job-board/schemas/jb-candidate-profiles.json +20 -0
- package/plugins/job-board/schemas/jb-companies.json +21 -0
- package/plugins/job-board/schemas/jb-jobs.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 +59 -0
- package/server/routes/api/forms.js +3 -0
- package/server/routes/api/plugins.js +9 -1
- package/server/routes/api/settings.js +16 -1
- package/server/routes/public.js +2 -0
- package/server/services/markdown.js +155 -8
- package/server/services/plugins.js +33 -2
- package/plugins/example-analytics/admin/templates/analytics.html +0 -10
- package/plugins/example-analytics/admin/views/analytics.js +0 -51
- package/plugins/example-analytics/config.js +0 -6
- package/plugins/example-analytics/plugin.js +0 -58
- package/plugins/example-analytics/plugin.json +0 -45
- package/plugins/example-analytics/public/inject-body.html +0 -14
- package/plugins/example-analytics/public/inject-head.html +0 -1
- package/plugins/example-analytics/stats.json +0 -24
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<div class="notes-plugin-wrap" style="padding: 24px; max-width: 1200px;">
|
|
2
|
+
|
|
3
|
+
<!-- Toolbar -->
|
|
4
|
+
<div class="notes-toolbar" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:20px;">
|
|
5
|
+
<h2 style="margin:0;flex:0 0 auto;font-size:1.4rem;">
|
|
6
|
+
<span data-icon="file-text" data-icon-size="20" style="margin-right:8px;"></span>Notes
|
|
7
|
+
</h2>
|
|
8
|
+
<input
|
|
9
|
+
id="notes-search"
|
|
10
|
+
type="search"
|
|
11
|
+
class="form-input"
|
|
12
|
+
placeholder="Search notes…"
|
|
13
|
+
style="flex:1;min-width:180px;max-width:320px;"
|
|
14
|
+
/>
|
|
15
|
+
<button id="new-note-btn" class="btn btn-primary" style="flex-shrink:0;">
|
|
16
|
+
<span data-icon="plus" data-icon-size="15" style="margin-right:6px;"></span>New Note
|
|
17
|
+
</button>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<!-- Category filter -->
|
|
21
|
+
<div id="category-filter" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:18px;"></div>
|
|
22
|
+
|
|
23
|
+
<!-- Notes grid -->
|
|
24
|
+
<div
|
|
25
|
+
id="notes-grid"
|
|
26
|
+
style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;"
|
|
27
|
+
></div>
|
|
28
|
+
|
|
29
|
+
<!-- Empty state -->
|
|
30
|
+
<div id="notes-empty" style="display:none;text-align:center;padding:60px 20px;opacity:0.6;">
|
|
31
|
+
<span data-icon="file-text" data-icon-size="48" style="display:block;margin-bottom:12px;"></span>
|
|
32
|
+
<p style="font-size:1rem;margin:0;">No notes yet. Click <strong>New Note</strong> to get started.</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<!-- Editor overlay -->
|
|
36
|
+
<div
|
|
37
|
+
id="note-editor"
|
|
38
|
+
style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,0.45);
|
|
39
|
+
align-items:center;justify-content:center;padding:20px;"
|
|
40
|
+
>
|
|
41
|
+
<div class="card" style="width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;">
|
|
42
|
+
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;">
|
|
43
|
+
<strong style="font-size:1rem;">Edit Note</strong>
|
|
44
|
+
<button id="cancel-note-btn" class="btn btn-sm btn-secondary" title="Close">
|
|
45
|
+
<span data-icon="x" data-icon-size="15"></span>
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="card-body" style="flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:14px;">
|
|
49
|
+
<div>
|
|
50
|
+
<label for="note-title" class="form-label" style="font-weight:500;margin-bottom:4px;display:block;">Title</label>
|
|
51
|
+
<input
|
|
52
|
+
id="note-title"
|
|
53
|
+
type="text"
|
|
54
|
+
class="form-input"
|
|
55
|
+
placeholder="Note title…"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
<div style="flex:1;display:flex;flex-direction:column;">
|
|
59
|
+
<label for="note-content" class="form-label" style="font-weight:500;margin-bottom:4px;display:block;">Content</label>
|
|
60
|
+
<textarea
|
|
61
|
+
id="note-content"
|
|
62
|
+
class="form-input"
|
|
63
|
+
placeholder="Write your note here… (Ctrl+Enter to save)"
|
|
64
|
+
style="flex:1;min-height:220px;resize:vertical;font-family:inherit;"
|
|
65
|
+
></textarea>
|
|
66
|
+
</div>
|
|
67
|
+
<div>
|
|
68
|
+
<label for="note-categories" class="form-label" style="font-weight:500;margin-bottom:4px;display:block;">Categories</label>
|
|
69
|
+
<input
|
|
70
|
+
id="note-categories"
|
|
71
|
+
type="text"
|
|
72
|
+
class="form-input"
|
|
73
|
+
placeholder="Comma-separated, e.g. work, ideas, personal"
|
|
74
|
+
/>
|
|
75
|
+
<small style="opacity:0.6;margin-top:4px;display:block;">Separate multiple categories with commas.</small>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="card-footer" style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 20px;">
|
|
79
|
+
<button id="delete-note-btn" class="btn btn-danger btn-sm" style="display:none;">
|
|
80
|
+
<span data-icon="trash" data-icon-size="13" style="margin-right:5px;"></span>Delete
|
|
81
|
+
</button>
|
|
82
|
+
<div style="display:flex;gap:8px;margin-left:auto;">
|
|
83
|
+
<button id="cancel-note-btn-footer" class="btn btn-secondary btn-sm">Cancel</button>
|
|
84
|
+
<button id="save-note-btn" class="btn btn-primary btn-sm">
|
|
85
|
+
<span data-icon="save" data-icon-size="13" style="margin-right:5px;"></span>Save
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
</div>
|
|
@@ -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, '<')
|
|
14
|
+
.replace(/>/g, '>')
|
|
15
|
+
.replace(/"/g, '"')
|
|
16
|
+
.replace(/'/g, ''');
|
|
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 @@
|
|
|
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
|
+
}
|