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,375 @@
|
|
|
1
|
+
import { createStore } from '../_lib/dataStore.js';
|
|
2
|
+
import defaultConfig from './config.js';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import fs from 'fs/promises';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
const dataDir = path.join(__dirname, 'data');
|
|
11
|
+
const documentsDir = path.join(dataDir, 'documents');
|
|
12
|
+
const versionsDir = path.join(dataDir, 'versions');
|
|
13
|
+
|
|
14
|
+
const foldersStore = createStore(path.join(dataDir, 'folders.json'));
|
|
15
|
+
const templatesStore = createStore(path.join(dataDir, 'templates.json'));
|
|
16
|
+
|
|
17
|
+
function countWords(html) {
|
|
18
|
+
if (!html) return 0;
|
|
19
|
+
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
20
|
+
return text ? text.split(' ').length : 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readDoc(id) {
|
|
24
|
+
try {
|
|
25
|
+
const raw = await fs.readFile(path.join(documentsDir, `${id}.json`), 'utf8');
|
|
26
|
+
return JSON.parse(raw);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function writeDoc(doc) {
|
|
33
|
+
await fs.mkdir(documentsDir, { recursive: true });
|
|
34
|
+
await fs.writeFile(path.join(documentsDir, `${doc.id}.json`), JSON.stringify(doc, null, 2));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function listDocs() {
|
|
38
|
+
await fs.mkdir(documentsDir, { recursive: true });
|
|
39
|
+
const files = await fs.readdir(documentsDir);
|
|
40
|
+
const docs = [];
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
if (!file.endsWith('.json')) continue;
|
|
43
|
+
try {
|
|
44
|
+
const raw = await fs.readFile(path.join(documentsDir, file), 'utf8');
|
|
45
|
+
docs.push(JSON.parse(raw));
|
|
46
|
+
} catch {
|
|
47
|
+
// skip corrupt files
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return docs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default async function docsPlugin(fastify, options) {
|
|
54
|
+
const prefix = '';
|
|
55
|
+
const {authenticate} = options.auth;
|
|
56
|
+
const config = {...defaultConfig, ...(options.settings || {})};
|
|
57
|
+
|
|
58
|
+
// -------------------------
|
|
59
|
+
// Documents
|
|
60
|
+
// -------------------------
|
|
61
|
+
|
|
62
|
+
// GET /documents
|
|
63
|
+
fastify.get(`${prefix}/documents`, {preHandler: [authenticate]}, async (req, reply) => {
|
|
64
|
+
const {folder, q} = req.query;
|
|
65
|
+
let docs = await listDocs();
|
|
66
|
+
|
|
67
|
+
if (config.scope === 'user') {
|
|
68
|
+
docs = docs.filter(d => d.userId === req.user.id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (folder !== undefined) {
|
|
72
|
+
docs = docs.filter(d => (folder === '' || folder === 'null' ? !d.folderId : d.folderId === folder));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (q) {
|
|
76
|
+
const term = q.toLowerCase();
|
|
77
|
+
docs = docs.filter(d =>
|
|
78
|
+
(d.title || '').toLowerCase().includes(term) ||
|
|
79
|
+
(d.content || '').toLowerCase().includes(term)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
docs.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
84
|
+
return reply.send(docs);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// POST /documents
|
|
88
|
+
fastify.post(`${prefix}/documents`, { preHandler: [authenticate] }, async (req, reply) => {
|
|
89
|
+
const { title = 'Untitled', content = '', folderId = null, tags = [] } = req.body || {};
|
|
90
|
+
const now = new Date().toISOString();
|
|
91
|
+
const doc = {
|
|
92
|
+
id: randomUUID(),
|
|
93
|
+
title,
|
|
94
|
+
content,
|
|
95
|
+
folderId: folderId || null,
|
|
96
|
+
tags,
|
|
97
|
+
userId: req.user.id,
|
|
98
|
+
wordCount: countWords(content),
|
|
99
|
+
createdAt: now,
|
|
100
|
+
updatedAt: now
|
|
101
|
+
};
|
|
102
|
+
await writeDoc(doc);
|
|
103
|
+
return reply.code(201).send(doc);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// GET /documents/:id
|
|
107
|
+
fastify.get(`${prefix}/documents/:id`, {preHandler: [authenticate]}, async (req, reply) => {
|
|
108
|
+
const doc = await readDoc(req.params.id);
|
|
109
|
+
if (!doc) return reply.code(404).send({ error: 'Document not found' });
|
|
110
|
+
if (config.scope === 'user' && doc.userId !== req.user.id) {
|
|
111
|
+
return reply.code(404).send({ error: 'Document not found' });
|
|
112
|
+
}
|
|
113
|
+
return reply.send(doc);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// PUT /documents/:id
|
|
117
|
+
fastify.put(`${prefix}/documents/:id`, {preHandler: [authenticate]}, async (req, reply) => {
|
|
118
|
+
const doc = await readDoc(req.params.id);
|
|
119
|
+
if (!doc) return reply.code(404).send({ error: 'Document not found' });
|
|
120
|
+
if (config.scope === 'user' && doc.userId !== req.user.id) {
|
|
121
|
+
return reply.code(404).send({ error: 'Document not found' });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const body = req.body || {};
|
|
125
|
+
|
|
126
|
+
// Auto-create version if content changed
|
|
127
|
+
if (body.content !== undefined && body.content !== doc.content) {
|
|
128
|
+
const docVersionsDir = path.join(versionsDir, doc.id);
|
|
129
|
+
await fs.mkdir(docVersionsDir, { recursive: true });
|
|
130
|
+
const existing = await fs.readdir(docVersionsDir).catch(() => []);
|
|
131
|
+
const nums = existing.map(f => parseInt(f)).filter(n => !isNaN(n));
|
|
132
|
+
const nextNum = nums.length ? Math.max(...nums) + 1 : 1;
|
|
133
|
+
if (nextNum <= (config.maxVersions || 50)) {
|
|
134
|
+
await fs.writeFile(
|
|
135
|
+
path.join(docVersionsDir, `${nextNum}.json`),
|
|
136
|
+
JSON.stringify({ num: nextNum, content: doc.content, createdAt: new Date().toISOString() }, null, 2)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const updated = {
|
|
142
|
+
...doc,
|
|
143
|
+
title: body.title !== undefined ? body.title : doc.title,
|
|
144
|
+
content: body.content !== undefined ? body.content : doc.content,
|
|
145
|
+
folderId: body.folderId !== undefined ? body.folderId : doc.folderId,
|
|
146
|
+
tags: body.tags !== undefined ? body.tags : doc.tags,
|
|
147
|
+
updatedAt: new Date().toISOString()
|
|
148
|
+
};
|
|
149
|
+
updated.wordCount = countWords(updated.content);
|
|
150
|
+
await writeDoc(updated);
|
|
151
|
+
return reply.send(updated);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// DELETE /documents/:id
|
|
155
|
+
fastify.delete(`${prefix}/documents/:id`, {preHandler: [authenticate]}, async (req, reply) => {
|
|
156
|
+
const doc = await readDoc(req.params.id);
|
|
157
|
+
if (!doc) return reply.code(404).send({ error: 'Document not found' });
|
|
158
|
+
if (config.scope === 'user' && doc.userId !== req.user.id) {
|
|
159
|
+
return reply.code(404).send({ error: 'Document not found' });
|
|
160
|
+
}
|
|
161
|
+
// Delete doc file
|
|
162
|
+
await fs.rm(path.join(documentsDir, `${doc.id}.json`), { force: true });
|
|
163
|
+
// Delete all versions
|
|
164
|
+
await fs.rm(path.join(versionsDir, doc.id), { recursive: true, force: true });
|
|
165
|
+
return reply.send({ ok: true });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// POST /documents/:id/duplicate
|
|
169
|
+
fastify.post(`${prefix}/documents/:id/duplicate`, {preHandler: [authenticate]}, async (req, reply) => {
|
|
170
|
+
const doc = await readDoc(req.params.id);
|
|
171
|
+
if (!doc) return reply.code(404).send({ error: 'Document not found' });
|
|
172
|
+
if (config.scope === 'user' && doc.userId !== req.user.id) {
|
|
173
|
+
return reply.code(404).send({ error: 'Document not found' });
|
|
174
|
+
}
|
|
175
|
+
const now = new Date().toISOString();
|
|
176
|
+
const copy = {
|
|
177
|
+
...doc,
|
|
178
|
+
id: randomUUID(),
|
|
179
|
+
title: `${doc.title} (Copy)`,
|
|
180
|
+
createdAt: now,
|
|
181
|
+
updatedAt: now
|
|
182
|
+
};
|
|
183
|
+
await writeDoc(copy);
|
|
184
|
+
return reply.code(201).send(copy);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// -------------------------
|
|
188
|
+
// Versions
|
|
189
|
+
// -------------------------
|
|
190
|
+
|
|
191
|
+
// GET /documents/:id/versions
|
|
192
|
+
fastify.get(`${prefix}/documents/:id/versions`, {preHandler: [authenticate]}, async (req, reply) => {
|
|
193
|
+
const doc = await readDoc(req.params.id);
|
|
194
|
+
if (!doc) return reply.code(404).send({ error: 'Document not found' });
|
|
195
|
+
if (config.scope === 'user' && doc.userId !== req.user.id) {
|
|
196
|
+
return reply.code(404).send({ error: 'Document not found' });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const docVersionsDir = path.join(versionsDir, doc.id);
|
|
200
|
+
const files = await fs.readdir(docVersionsDir).catch(() => []);
|
|
201
|
+
const versions = [];
|
|
202
|
+
for (const file of files) {
|
|
203
|
+
if (!file.endsWith('.json')) continue;
|
|
204
|
+
try {
|
|
205
|
+
const raw = await fs.readFile(path.join(docVersionsDir, file), 'utf8');
|
|
206
|
+
versions.push(JSON.parse(raw));
|
|
207
|
+
} catch {
|
|
208
|
+
// skip
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
versions.sort((a, b) => b.num - a.num);
|
|
212
|
+
return reply.send(versions);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// GET /documents/:id/versions/:num
|
|
216
|
+
fastify.get(`${prefix}/documents/:id/versions/:num`, {preHandler: [authenticate]}, async (req, reply) => {
|
|
217
|
+
const doc = await readDoc(req.params.id);
|
|
218
|
+
if (!doc) return reply.code(404).send({ error: 'Document not found' });
|
|
219
|
+
if (config.scope === 'user' && doc.userId !== req.user.id) {
|
|
220
|
+
return reply.code(404).send({ error: 'Document not found' });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const versionFile = path.join(versionsDir, doc.id, `${req.params.num}.json`);
|
|
224
|
+
try {
|
|
225
|
+
const raw = await fs.readFile(versionFile, 'utf8');
|
|
226
|
+
return reply.send(JSON.parse(raw));
|
|
227
|
+
} catch {
|
|
228
|
+
return reply.code(404).send({ error: 'Version not found' });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// POST /documents/:id/versions/:num/restore
|
|
233
|
+
fastify.post(`${prefix}/documents/:id/versions/:num/restore`, {preHandler: [authenticate]}, async (req, reply) => {
|
|
234
|
+
const doc = await readDoc(req.params.id);
|
|
235
|
+
if (!doc) return reply.code(404).send({ error: 'Document not found' });
|
|
236
|
+
if (config.scope === 'user' && doc.userId !== req.user.id) {
|
|
237
|
+
return reply.code(404).send({ error: 'Document not found' });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const versionFile = path.join(versionsDir, doc.id, `${req.params.num}.json`);
|
|
241
|
+
let version;
|
|
242
|
+
try {
|
|
243
|
+
const raw = await fs.readFile(versionFile, 'utf8');
|
|
244
|
+
version = JSON.parse(raw);
|
|
245
|
+
} catch {
|
|
246
|
+
return reply.code(404).send({ error: 'Version not found' });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Save current state as a new version first
|
|
250
|
+
const docVersionsDir = path.join(versionsDir, doc.id);
|
|
251
|
+
await fs.mkdir(docVersionsDir, { recursive: true });
|
|
252
|
+
const existing = await fs.readdir(docVersionsDir).catch(() => []);
|
|
253
|
+
const nums = existing.map(f => parseInt(f)).filter(n => !isNaN(n));
|
|
254
|
+
const nextNum = nums.length ? Math.max(...nums) + 1 : 1;
|
|
255
|
+
if (nextNum <= (config.maxVersions || 50)) {
|
|
256
|
+
await fs.writeFile(
|
|
257
|
+
path.join(docVersionsDir, `${nextNum}.json`),
|
|
258
|
+
JSON.stringify({ num: nextNum, content: doc.content, createdAt: new Date().toISOString() }, null, 2)
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Restore
|
|
263
|
+
const restored = {
|
|
264
|
+
...doc,
|
|
265
|
+
content: version.content,
|
|
266
|
+
wordCount: countWords(version.content),
|
|
267
|
+
updatedAt: new Date().toISOString()
|
|
268
|
+
};
|
|
269
|
+
await writeDoc(restored);
|
|
270
|
+
return reply.send(restored);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// -------------------------
|
|
274
|
+
// Folders
|
|
275
|
+
// -------------------------
|
|
276
|
+
|
|
277
|
+
// GET /folders
|
|
278
|
+
fastify.get(`${prefix}/folders`, {preHandler: [authenticate]}, async (req, reply) => {
|
|
279
|
+
let folders = await foldersStore.readArray();
|
|
280
|
+
if (config.scope === 'user') {
|
|
281
|
+
folders = folders.filter(f => f.userId === req.user.id);
|
|
282
|
+
}
|
|
283
|
+
return reply.send(folders);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// POST /folders
|
|
287
|
+
fastify.post(`${prefix}/folders`, { preHandler: [authenticate] }, async (req, reply) => {
|
|
288
|
+
const { name, parentId = null } = req.body || {};
|
|
289
|
+
if (!name || !name.trim()) return reply.code(400).send({ error: 'Name is required' });
|
|
290
|
+
const folder = {
|
|
291
|
+
id: randomUUID(),
|
|
292
|
+
name: name.trim(),
|
|
293
|
+
parentId: parentId || null,
|
|
294
|
+
userId: req.user.id,
|
|
295
|
+
createdAt: new Date().toISOString()
|
|
296
|
+
};
|
|
297
|
+
await foldersStore.appendItem(folder);
|
|
298
|
+
return reply.code(201).send(folder);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// PUT /folders/:id
|
|
302
|
+
fastify.put(`${prefix}/folders/:id`, {preHandler: [authenticate]}, async (req, reply) => {
|
|
303
|
+
const folders = await foldersStore.readArray();
|
|
304
|
+
const folder = folders.find(f => f.id === req.params.id);
|
|
305
|
+
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
|
|
306
|
+
if (config.scope === 'user' && folder.userId !== req.user.id) {
|
|
307
|
+
return reply.code(404).send({ error: 'Folder not found' });
|
|
308
|
+
}
|
|
309
|
+
const { name } = req.body || {};
|
|
310
|
+
if (!name || !name.trim()) return reply.code(400).send({ error: 'Name is required' });
|
|
311
|
+
const updated = await foldersStore.updateItem(req.params.id, { name: name.trim() });
|
|
312
|
+
return reply.send(updated);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// DELETE /folders/:id
|
|
316
|
+
fastify.delete(`${prefix}/folders/:id`, {preHandler: [authenticate]}, async (req, reply) => {
|
|
317
|
+
const folders = await foldersStore.readArray();
|
|
318
|
+
const folder = folders.find(f => f.id === req.params.id);
|
|
319
|
+
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
|
|
320
|
+
if (config.scope === 'user' && folder.userId !== req.user.id) {
|
|
321
|
+
return reply.code(404).send({ error: 'Folder not found' });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Move docs in this folder to root
|
|
325
|
+
const docs = await listDocs();
|
|
326
|
+
for (const doc of docs) {
|
|
327
|
+
if (doc.folderId === req.params.id) {
|
|
328
|
+
await writeDoc({ ...doc, folderId: null, updatedAt: new Date().toISOString() });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
await foldersStore.deleteItem(req.params.id);
|
|
333
|
+
return reply.send({ ok: true });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// -------------------------
|
|
337
|
+
// Templates
|
|
338
|
+
// -------------------------
|
|
339
|
+
|
|
340
|
+
// GET /templates
|
|
341
|
+
fastify.get(`${prefix}/templates`, {preHandler: [authenticate]}, async (req, reply) => {
|
|
342
|
+
let templates = await templatesStore.readArray();
|
|
343
|
+
if (config.scope === 'user') {
|
|
344
|
+
templates = templates.filter(t => t.userId === req.user.id);
|
|
345
|
+
}
|
|
346
|
+
return reply.send(templates);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// POST /templates
|
|
350
|
+
fastify.post(`${prefix}/templates`, { preHandler: [authenticate] }, async (req, reply) => {
|
|
351
|
+
const { name, content = '' } = req.body || {};
|
|
352
|
+
if (!name || !name.trim()) return reply.code(400).send({ error: 'Name is required' });
|
|
353
|
+
const template = {
|
|
354
|
+
id: randomUUID(),
|
|
355
|
+
name: name.trim(),
|
|
356
|
+
content,
|
|
357
|
+
userId: req.user.id,
|
|
358
|
+
createdAt: new Date().toISOString()
|
|
359
|
+
};
|
|
360
|
+
await templatesStore.appendItem(template);
|
|
361
|
+
return reply.code(201).send(template);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// DELETE /templates/:id
|
|
365
|
+
fastify.delete(`${prefix}/templates/:id`, {preHandler: [authenticate]}, async (req, reply) => {
|
|
366
|
+
const templates = await templatesStore.readArray();
|
|
367
|
+
const template = templates.find(t => t.id === req.params.id);
|
|
368
|
+
if (!template) return reply.code(404).send({ error: 'Template not found' });
|
|
369
|
+
if (config.scope === 'user' && template.userId !== req.user.id) {
|
|
370
|
+
return reply.code(404).send({ error: 'Template not found' });
|
|
371
|
+
}
|
|
372
|
+
await templatesStore.deleteItem(req.params.id);
|
|
373
|
+
return reply.send({ ok: true });
|
|
374
|
+
});
|
|
375
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "docs",
|
|
3
|
+
"displayName": "Docs",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Document editor with folders, version history, templates, and find & replace.",
|
|
6
|
+
"author": "Darryl Waterhouse",
|
|
7
|
+
"date": "2026-03-24",
|
|
8
|
+
"icon": "book-open",
|
|
9
|
+
"admin": {
|
|
10
|
+
"sidebar": [
|
|
11
|
+
{ "id": "docs", "text": "Docs", "icon": "book-open", "url": "#/plugins/docs", "section": "#/plugins/docs" }
|
|
12
|
+
],
|
|
13
|
+
"routes": [
|
|
14
|
+
{ "path": "/plugins/docs", "view": "plugin-docs", "title": "Docs - Domma CMS" }
|
|
15
|
+
],
|
|
16
|
+
"views": {
|
|
17
|
+
"plugin-docs": {
|
|
18
|
+
"entry": "docs/admin/views/docs.js",
|
|
19
|
+
"exportName": "docsView"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<div class="view-header">
|
|
2
|
+
<div>
|
|
3
|
+
<h1 class="view-title">Application Detail</h1>
|
|
4
|
+
</div>
|
|
5
|
+
<div class="view-actions">
|
|
6
|
+
<a href="#/job-board/applications" class="btn btn-secondary">Back</a>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div class="view-body">
|
|
11
|
+
<div class="card mb-4">
|
|
12
|
+
<div class="card-header"><h3>Application Info</h3></div>
|
|
13
|
+
<div class="card-body">
|
|
14
|
+
<dl class="dl-grid">
|
|
15
|
+
<dt>Job ID</dt> <dd id="app-job-id">—</dd>
|
|
16
|
+
<dt>Candidate</dt> <dd id="app-candidate">—</dd>
|
|
17
|
+
<dt>Status</dt> <dd id="app-status">—</dd>
|
|
18
|
+
<dt>Applied</dt> <dd id="app-applied">—</dd>
|
|
19
|
+
<dt>CV</dt> <dd id="app-cv">—</dd>
|
|
20
|
+
</dl>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="card mb-4">
|
|
25
|
+
<div class="card-header"><h3>Cover Letter</h3></div>
|
|
26
|
+
<div class="card-body">
|
|
27
|
+
<p id="app-cover-letter" class="whitespace-pre-wrap">—</p>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="card" id="status-section" style="display:none">
|
|
32
|
+
<div class="card-header"><h3>Update Status</h3></div>
|
|
33
|
+
<div class="card-body">
|
|
34
|
+
<div class="form-group">
|
|
35
|
+
<select id="status-select" class="form-control"></select>
|
|
36
|
+
</div>
|
|
37
|
+
<button class="btn btn-primary mt-2" id="btn-update-status">Update Status</button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<div class="view-header">
|
|
2
|
+
<div>
|
|
3
|
+
<h1 class="view-title">Companies</h1>
|
|
4
|
+
<p class="view-subtitle">Manage companies</p>
|
|
5
|
+
</div>
|
|
6
|
+
<div class="view-actions">
|
|
7
|
+
<button class="btn btn-primary" id="btn-create-company">Add Company</button>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="view-body" id="companies-body">
|
|
12
|
+
<div id="agent-link-section" style="display:none" class="card mb-4">
|
|
13
|
+
<div class="card-header"><h3>Link to a Company</h3></div>
|
|
14
|
+
<div class="card-body">
|
|
15
|
+
<div class="form-group">
|
|
16
|
+
<label>Company ID</label>
|
|
17
|
+
<input type="text" id="link-company-id" class="form-control" placeholder="Enter company ID">
|
|
18
|
+
</div>
|
|
19
|
+
<button class="btn btn-secondary mt-2" id="btn-link-company">Link Company</button>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div id="companies-table"></div>
|
|
24
|
+
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<div class="view-header">
|
|
2
|
+
<div>
|
|
3
|
+
<h1 id="jb-heading" class="view-title">Job Board</h1>
|
|
4
|
+
<p class="view-subtitle">Overview and quick access</p>
|
|
5
|
+
</div>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<div class="view-body">
|
|
9
|
+
<div class="grid grid-cols-3 gap-4 mb-6">
|
|
10
|
+
<div class="card">
|
|
11
|
+
<div class="card-body text-center">
|
|
12
|
+
<div class="text-3xl font-bold" id="stat-jobs">—</div>
|
|
13
|
+
<div class="text-muted mt-1">Jobs</div>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="card">
|
|
17
|
+
<div class="card-body text-center">
|
|
18
|
+
<div class="text-3xl font-bold" id="stat-apps">—</div>
|
|
19
|
+
<div class="text-muted mt-1">Applications</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="card" id="stat-companies-card">
|
|
23
|
+
<div class="card-body text-center">
|
|
24
|
+
<div class="text-3xl font-bold" id="stat-companies">—</div>
|
|
25
|
+
<div class="text-muted mt-1">Companies</div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="mb-4" id="quick-links"></div>
|
|
31
|
+
|
|
32
|
+
<div id="recent-apps-section">
|
|
33
|
+
<h2 class="section-title">Recent Applications</h2>
|
|
34
|
+
<div id="recent-apps-table"></div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<div class="view-header">
|
|
2
|
+
<div>
|
|
3
|
+
<h1 class="view-title" id="view-title">New Job</h1>
|
|
4
|
+
</div>
|
|
5
|
+
<div class="view-actions">
|
|
6
|
+
<a href="#/job-board/jobs" class="btn btn-secondary">Cancel</a>
|
|
7
|
+
<button class="btn btn-primary" id="btn-save">Save Job</button>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="view-body">
|
|
12
|
+
<div class="card">
|
|
13
|
+
<div class="card-body">
|
|
14
|
+
<div id="job-form"></div>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<div class="view-header">
|
|
2
|
+
<div>
|
|
3
|
+
<h1 class="view-title">Jobs</h1>
|
|
4
|
+
<p class="view-subtitle">Manage job listings</p>
|
|
5
|
+
</div>
|
|
6
|
+
<div class="view-actions">
|
|
7
|
+
<a href="#/job-board/jobs/new" class="btn btn-primary" id="btn-post-job">
|
|
8
|
+
<span data-icon="plus"></span> Post Job
|
|
9
|
+
</a>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div class="view-body">
|
|
14
|
+
<div id="jobs-table"></div>
|
|
15
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<div class="view-header">
|
|
2
|
+
<div>
|
|
3
|
+
<h1 class="view-title">My Profile</h1>
|
|
4
|
+
<p class="view-subtitle">Manage your candidate profile</p>
|
|
5
|
+
</div>
|
|
6
|
+
<div class="view-actions">
|
|
7
|
+
<button class="btn btn-primary" id="btn-save-profile">Save Profile</button>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="view-body" id="profile-body">
|
|
12
|
+
<div class="card">
|
|
13
|
+
<div class="card-body">
|
|
14
|
+
<div id="profile-form"></div>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { getRoleContext } from '../lib/role-context.js';
|
|
2
|
+
import { jbApi } from '../lib/api.js';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
templateUrl: '/plugins/job-board/admin/templates/application-detail.html',
|
|
6
|
+
async onMount($container) {
|
|
7
|
+
const ctx = getRoleContext();
|
|
8
|
+
|
|
9
|
+
const hash = window.location.hash;
|
|
10
|
+
const match = hash.match(/\/job-board\/applications\/([^/]+)/);
|
|
11
|
+
const appId = match ? match[1] : null;
|
|
12
|
+
if (!appId) {
|
|
13
|
+
E.toast('Application ID not found', { type: 'error' });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let application;
|
|
18
|
+
try {
|
|
19
|
+
application = await jbApi.applications.get(appId);
|
|
20
|
+
} catch {
|
|
21
|
+
E.toast('Failed to load application', { type: 'error' });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const d = application.data || {};
|
|
26
|
+
$container.find('#app-job-id').text(d.job_id || '—');
|
|
27
|
+
$container.find('#app-candidate').text(d.candidate_id || '—');
|
|
28
|
+
$container.find('#app-status').text(d.status || '—');
|
|
29
|
+
$container.find('#app-applied').text(d.applied_at ? new Date(d.applied_at).toLocaleString() : '—');
|
|
30
|
+
$container.find('#app-cover-letter').text(d.cover_letter || '—');
|
|
31
|
+
if (d.cv_url) {
|
|
32
|
+
$container.find('#app-cv').html(`<a href="${d.cv_url}" target="_blank">View CV</a>`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Status update section — company/agent/admin only
|
|
36
|
+
if (!ctx.isCandidate) {
|
|
37
|
+
const statuses = ['submitted', 'reviewed', 'shortlisted', 'interview', 'offered', 'rejected', 'withdrawn'];
|
|
38
|
+
const $sel = $container.find('#status-select');
|
|
39
|
+
statuses.forEach(s => {
|
|
40
|
+
const opt = document.createElement('option');
|
|
41
|
+
opt.value = s;
|
|
42
|
+
opt.textContent = s;
|
|
43
|
+
if (s === d.status) opt.selected = true;
|
|
44
|
+
$sel.get(0)?.appendChild(opt);
|
|
45
|
+
});
|
|
46
|
+
$container.find('#status-section').show();
|
|
47
|
+
|
|
48
|
+
$container.find('#btn-update-status').get(0)?.addEventListener('click', async () => {
|
|
49
|
+
const status = $sel.val();
|
|
50
|
+
try {
|
|
51
|
+
await jbApi.applications.setStatus(appId, status);
|
|
52
|
+
E.toast('Status updated', { type: 'success' });
|
|
53
|
+
$container.find('#app-status').text(status);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
E.toast(err.message || 'Failed to update status', { type: 'error' });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
I.scan($container.get(0));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getRoleContext } from '../lib/role-context.js';
|
|
2
|
+
import { jbApi } from '../lib/api.js';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
templateUrl: '/plugins/job-board/admin/templates/applications.html',
|
|
6
|
+
async onMount($container) {
|
|
7
|
+
const ctx = getRoleContext();
|
|
8
|
+
|
|
9
|
+
let applications = [];
|
|
10
|
+
try {
|
|
11
|
+
const res = await jbApi.applications.list();
|
|
12
|
+
applications = Array.isArray(res) ? res : (res?.entries || []);
|
|
13
|
+
} catch {
|
|
14
|
+
E.toast('Failed to load applications', { type: 'error' });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const statusBadge = (s) => {
|
|
19
|
+
const map = {
|
|
20
|
+
submitted: 'badge-info',
|
|
21
|
+
reviewed: 'badge-secondary',
|
|
22
|
+
shortlisted: 'badge-warning',
|
|
23
|
+
interview: 'badge-primary',
|
|
24
|
+
offered: 'badge-success',
|
|
25
|
+
rejected: 'badge-danger',
|
|
26
|
+
withdrawn: 'badge-secondary'
|
|
27
|
+
};
|
|
28
|
+
return `<span class="badge ${map[s] || 'badge-secondary'}">${s || '—'}</span>`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const columns = [
|
|
32
|
+
{ key: 'data.company_id', label: 'Company' },
|
|
33
|
+
{ key: 'data.job_id', label: 'Job ID' },
|
|
34
|
+
{ key: 'data.candidate_id', label: 'Candidate' },
|
|
35
|
+
{ key: 'data.status', label: 'Status', render: v => statusBadge(v) },
|
|
36
|
+
{ key: 'data.applied_at', label: 'Applied', render: v => v ? new Date(v).toLocaleDateString() : '—' },
|
|
37
|
+
{
|
|
38
|
+
label: 'Actions',
|
|
39
|
+
render: (_, row) => `<a href="#/job-board/applications/${row.id}" class="btn btn-sm btn-secondary">View</a>`
|
|
40
|
+
}
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
T.create($container.find('#applications-table').get(0), { data: applications, columns });
|
|
44
|
+
|
|
45
|
+
I.scan($container.get(0));
|
|
46
|
+
}
|
|
47
|
+
};
|