@a83/orbiter-admin 0.3.13 → 0.3.14
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/package.json +2 -1
- package/public/editor.html +32 -0
- package/public/schema.html +19 -1
- package/public/settings.html +49 -3
- package/src/email.js +50 -0
- package/src/routes/comments.js +2 -0
- package/src/routes/entries.js +2 -0
- package/src/routes/locks.js +48 -0
- package/src/routes/meta.js +2 -0
- package/src/server.js +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a83/orbiter-admin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"description": "Standalone admin server for Orbiter CMS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server.js",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"@a83/orbiter-core": "^0.3.2",
|
|
30
30
|
"@hono/node-server": "^1.14.4",
|
|
31
31
|
"hono": "^4.7.11",
|
|
32
|
+
"nodemailer": "^8.0.10",
|
|
32
33
|
"sharp": "^0.34.5"
|
|
33
34
|
}
|
|
34
35
|
}
|
package/public/editor.html
CHANGED
|
@@ -409,6 +409,9 @@
|
|
|
409
409
|
<span class="logout" id="logout-btn">Sign out</span>
|
|
410
410
|
</div>
|
|
411
411
|
</header>
|
|
412
|
+
<div id="lock-banner" style="display:none;background:color-mix(in srgb,var(--gold) 12%,var(--bg2));border-bottom:1px solid color-mix(in srgb,var(--gold) 30%,transparent);padding:7px 20px;font-size:11px;color:var(--text);display:none;align-items:center;gap:8px;">
|
|
413
|
+
<span style="color:var(--gold);">⚠</span> <span id="lock-banner-msg"></span>
|
|
414
|
+
</div>
|
|
412
415
|
<div class="editor-shell" id="editor-shell">
|
|
413
416
|
<div class="editor-main" id="editor-main">
|
|
414
417
|
<div id="saved-flash" style="display:none;" class="saved-flash">Entry saved</div>
|
|
@@ -517,6 +520,35 @@
|
|
|
517
520
|
|
|
518
521
|
if (!colData) { location.replace('/collections.html'); }
|
|
519
522
|
|
|
523
|
+
// Entry locking — claim lock, warn if someone else is editing
|
|
524
|
+
let lockHeld = false;
|
|
525
|
+
let lockHeartbeat = null;
|
|
526
|
+
async function claimLock() {
|
|
527
|
+
if (IS_NEW) return;
|
|
528
|
+
const res = await fetch(`/api/locks/${COLLECTION}/${SLUG}`,{method:'POST',credentials:'include'}).catch(()=>null);
|
|
529
|
+
if (!res) return;
|
|
530
|
+
if (res.status === 409) {
|
|
531
|
+
const j = await res.json().catch(()=>({}));
|
|
532
|
+
const banner = document.getElementById('lock-banner');
|
|
533
|
+
document.getElementById('lock-banner-msg').textContent = `${j.by ?? 'Someone'} is currently editing this entry. Your changes may conflict.`;
|
|
534
|
+
banner.style.display = 'flex';
|
|
535
|
+
lockHeld = false;
|
|
536
|
+
} else {
|
|
537
|
+
lockHeld = true;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async function releaseLock() {
|
|
541
|
+
if (!lockHeld || IS_NEW) return;
|
|
542
|
+
lockHeld = false;
|
|
543
|
+
await fetch(`/api/locks/${COLLECTION}/${SLUG}`,{method:'DELETE',credentials:'include'}).catch(()=>{});
|
|
544
|
+
}
|
|
545
|
+
claimLock().then(()=>{
|
|
546
|
+
if (IS_NEW) return;
|
|
547
|
+
lockHeartbeat = setInterval(()=>fetch(`/api/locks/${COLLECTION}/${SLUG}`,{method:'POST',credentials:'include'}).catch(()=>{}), 60_000);
|
|
548
|
+
});
|
|
549
|
+
window.addEventListener('beforeunload', () => { if (lockHeld) releaseLock(); });
|
|
550
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden && lockHeld) releaseLock(); });
|
|
551
|
+
|
|
520
552
|
// Breadcrumb
|
|
521
553
|
document.getElementById('back-to-col').textContent = colData.label;
|
|
522
554
|
document.getElementById('back-to-col').href = `/entries.html?col=${COLLECTION}&label=${encodeURIComponent(colData.label)}`;
|
package/public/schema.html
CHANGED
|
@@ -390,9 +390,11 @@
|
|
|
390
390
|
${total>0?'<div class="delete-warn">Adding fields is non-destructive. Removing fields hides existing data but does not delete it.</div>':''}
|
|
391
391
|
<div id="edit-field-rows" class="field-rows"></div>
|
|
392
392
|
<div class="editor-actions">
|
|
393
|
-
<div style="display:flex;gap:10px;">
|
|
393
|
+
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
|
394
394
|
<button class="btn btn-primary" id="btn-save-edit">Save</button>
|
|
395
395
|
<button class="btn" id="btn-add-edit">+ Add field</button>
|
|
396
|
+
<button class="btn" id="btn-export-schema">↓ Export schema</button>
|
|
397
|
+
<label class="btn" style="cursor:pointer;">↑ Import schema<input type="file" id="import-schema-file" accept=".json" style="display:none"></label>
|
|
396
398
|
</div>
|
|
397
399
|
<button class="btn" id="btn-del-col" style="color:var(--red);border-color:var(--red);">Delete collection</button>
|
|
398
400
|
</div>
|
|
@@ -401,6 +403,22 @@
|
|
|
401
403
|
enableFieldDrag(rowsEl);
|
|
402
404
|
Object.entries(schema).forEach(([k,f])=>addFieldRow(rowsEl,k,f.label??k,f.type??'string',!!f.required,(f.options??[]).join(', '),f.collection??'',f.multiple!==false));
|
|
403
405
|
document.getElementById('btn-add-edit').addEventListener('click',()=>addFieldRow(rowsEl));
|
|
406
|
+
document.getElementById('btn-export-schema').addEventListener('click',()=>{
|
|
407
|
+
const blob=new Blob([JSON.stringify(serializeSchema(rowsEl),null,2)],{type:'application/json'});
|
|
408
|
+
const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`${col.id}-schema.json`; a.click(); URL.revokeObjectURL(a.href);
|
|
409
|
+
});
|
|
410
|
+
document.getElementById('import-schema-file').addEventListener('change',e=>{
|
|
411
|
+
const file=e.target.files[0]; if (!file) return;
|
|
412
|
+
const reader=new FileReader();
|
|
413
|
+
reader.onload=ev=>{
|
|
414
|
+
try {
|
|
415
|
+
const s=JSON.parse(ev.target.result);
|
|
416
|
+
rowsEl.innerHTML='';
|
|
417
|
+
Object.entries(s).forEach(([k,f])=>addFieldRow(rowsEl,k,f.label??k,f.type??'string',!!f.required,(f.options??[]).join(', '),f.collection??'',f.multiple!==false));
|
|
418
|
+
} catch { alert('Invalid schema JSON'); }
|
|
419
|
+
};
|
|
420
|
+
reader.readAsText(file); e.target.value='';
|
|
421
|
+
});
|
|
404
422
|
// Load existing preview URL for this collection
|
|
405
423
|
fetch(`/api/meta/preview_url~${col.id}`,{credentials:'include'}).then(r=>r.json()).then(d=>{
|
|
406
424
|
const inp=document.getElementById('edit-preview-url'); if(inp)inp.value=d.value??'';
|
package/public/settings.html
CHANGED
|
@@ -449,6 +449,42 @@
|
|
|
449
449
|
</div>
|
|
450
450
|
</div>
|
|
451
451
|
|
|
452
|
+
<div class="settings-group">
|
|
453
|
+
<div class="group-header">Email notifications</div>
|
|
454
|
+
<div class="setting-row">
|
|
455
|
+
<div><div class="setting-label">SMTP host</div><div class="setting-desc">e.g. smtp.fastmail.com, smtp.gmail.com</div></div>
|
|
456
|
+
<input class="input" name="email.smtp_host" value="${get('email.smtp_host')||''}" placeholder="smtp.example.com" />
|
|
457
|
+
</div>
|
|
458
|
+
<div class="setting-row">
|
|
459
|
+
<div><div class="setting-label">SMTP port</div><div class="setting-desc">Usually 587 (STARTTLS) or 465 (SSL)</div></div>
|
|
460
|
+
<input class="input" name="email.smtp_port" type="number" value="${get('email.smtp_port')||'587'}" placeholder="587" style="max-width:100px" />
|
|
461
|
+
</div>
|
|
462
|
+
<div class="setting-row">
|
|
463
|
+
<div><div class="setting-label">SMTP username</div></div>
|
|
464
|
+
<input class="input" name="email.smtp_user" value="${get('email.smtp_user')||''}" placeholder="you@example.com" />
|
|
465
|
+
</div>
|
|
466
|
+
<div class="setting-row">
|
|
467
|
+
<div><div class="setting-label">SMTP password</div></div>
|
|
468
|
+
<input class="input" type="password" name="email.smtp_pass" value="${get('email.smtp_pass')||''}" placeholder="••••••••" />
|
|
469
|
+
</div>
|
|
470
|
+
<div class="setting-row">
|
|
471
|
+
<div><div class="setting-label">From address</div><div class="setting-desc">Defaults to SMTP username if blank</div></div>
|
|
472
|
+
<input class="input" name="email.smtp_from" value="${get('email.smtp_from')||''}" placeholder="Orbiter <noreply@example.com>" />
|
|
473
|
+
</div>
|
|
474
|
+
<div class="setting-row">
|
|
475
|
+
<div><div class="setting-label">Notify to</div><div class="setting-desc">Recipient email address for notifications</div></div>
|
|
476
|
+
<input class="input" name="email.notify_to" value="${get('email.notify_to')||''}" placeholder="admin@example.com" />
|
|
477
|
+
</div>
|
|
478
|
+
<div class="setting-row">
|
|
479
|
+
<div><div class="setting-label">Notify on publish</div><div class="setting-desc">Send email when an entry is published</div></div>
|
|
480
|
+
<input type="checkbox" name="email.notify_publish" value="1" ${get('email.notify_publish')==='1'?'checked':''} style="accent-color:var(--accent);width:14px;height:14px;" />
|
|
481
|
+
</div>
|
|
482
|
+
<div class="setting-row">
|
|
483
|
+
<div><div class="setting-label">Notify on comment</div><div class="setting-desc">Send email when a new comment is posted</div></div>
|
|
484
|
+
<input type="checkbox" name="email.notify_comment" value="1" ${get('email.notify_comment')==='1'?'checked':''} style="accent-color:var(--accent);width:14px;height:14px;" />
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
|
|
452
488
|
<div class="save-row">
|
|
453
489
|
<button type="submit" class="btn-save">Save settings</button>
|
|
454
490
|
</div>
|
|
@@ -668,9 +704,19 @@
|
|
|
668
704
|
['media.s3_access_key', fd.get('media.s3_access_key')],
|
|
669
705
|
['media.s3_secret_key', fd.get('media.s3_secret_key')],
|
|
670
706
|
['media.s3_public_url', fd.get('media.s3_public_url')],
|
|
671
|
-
['api.enabled',
|
|
672
|
-
['api.token',
|
|
673
|
-
['preview.token',
|
|
707
|
+
['api.enabled', fd.get('api.enabled') ? '1' : '0'],
|
|
708
|
+
['api.token', fd.get('api.token')],
|
|
709
|
+
['preview.token', document.getElementById('preview-token-display').value || null],
|
|
710
|
+
['media.img_max_width', fd.get('media.img_max_width')],
|
|
711
|
+
['media.img_quality', fd.get('media.img_quality')],
|
|
712
|
+
['email.smtp_host', fd.get('email.smtp_host')],
|
|
713
|
+
['email.smtp_port', fd.get('email.smtp_port')],
|
|
714
|
+
['email.smtp_user', fd.get('email.smtp_user')],
|
|
715
|
+
['email.smtp_pass', fd.get('email.smtp_pass')],
|
|
716
|
+
['email.smtp_from', fd.get('email.smtp_from')],
|
|
717
|
+
['email.notify_to', fd.get('email.notify_to')],
|
|
718
|
+
['email.notify_publish', fd.get('email.notify_publish') ? '1' : '0'],
|
|
719
|
+
['email.notify_comment', fd.get('email.notify_comment') ? '1' : '0'],
|
|
674
720
|
]);
|
|
675
721
|
showBanner('site-banner','banner-ok','Settings saved');
|
|
676
722
|
});
|
package/src/email.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
|
+
import { openPod } from '@a83/orbiter-core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Send a notification email if SMTP is configured.
|
|
6
|
+
* @param {string} podPath
|
|
7
|
+
* @param {string} event — 'publish' | 'comment'
|
|
8
|
+
* @param {object} ctx — { collection, slug, username, body? }
|
|
9
|
+
*/
|
|
10
|
+
export async function sendNotification(podPath, event, ctx = {}) {
|
|
11
|
+
let db;
|
|
12
|
+
try {
|
|
13
|
+
db = openPod(podPath);
|
|
14
|
+
const shouldSend = event === 'publish'
|
|
15
|
+
? db.getMeta('email.notify_publish') === '1'
|
|
16
|
+
: db.getMeta('email.notify_comment') === '1';
|
|
17
|
+
if (!shouldSend) { db.close(); return; }
|
|
18
|
+
|
|
19
|
+
const host = db.getMeta('email.smtp_host') ?? '';
|
|
20
|
+
const port = parseInt(db.getMeta('email.smtp_port') ?? '587', 10);
|
|
21
|
+
const user = db.getMeta('email.smtp_user') ?? '';
|
|
22
|
+
const pass = db.getMeta('email.smtp_pass') ?? '';
|
|
23
|
+
const from = db.getMeta('email.smtp_from') || user;
|
|
24
|
+
const to = db.getMeta('email.notify_to') ?? '';
|
|
25
|
+
const site = db.getMeta('site.name') ?? 'Orbiter';
|
|
26
|
+
db.close();
|
|
27
|
+
|
|
28
|
+
if (!host || !to) return;
|
|
29
|
+
|
|
30
|
+
const transport = nodemailer.createTransport({
|
|
31
|
+
host, port,
|
|
32
|
+
secure: port === 465,
|
|
33
|
+
auth: user ? { user, pass } : undefined,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
let subject, text;
|
|
37
|
+
if (event === 'publish') {
|
|
38
|
+
subject = `[${site}] Entry published: ${ctx.collection}/${ctx.slug}`;
|
|
39
|
+
text = `${ctx.username ?? 'Someone'} just published "${ctx.slug}" in the "${ctx.collection}" collection.`;
|
|
40
|
+
} else {
|
|
41
|
+
subject = `[${site}] New comment on ${ctx.collection}/${ctx.slug}`;
|
|
42
|
+
text = `${ctx.username ?? 'Someone'} commented on "${ctx.slug}":\n\n${ctx.body ?? ''}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await transport.sendMail({ from, to, subject, text });
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.warn('[email]', e.message);
|
|
48
|
+
db?.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/routes/comments.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { openPod } from '@a83/orbiter-core';
|
|
3
|
+
import { sendNotification } from '../email.js';
|
|
3
4
|
|
|
4
5
|
export const commentRoutes = new Hono();
|
|
5
6
|
|
|
@@ -25,6 +26,7 @@ commentRoutes.post('/:collectionId/entries/:slug/comments', async (c) => {
|
|
|
25
26
|
const username = c.get('user')?.username ?? 'unknown';
|
|
26
27
|
const id = db.createComment(entry.id, username, body.trim());
|
|
27
28
|
db.close();
|
|
29
|
+
sendNotification(c.get('podPath'), 'comment', { collection: collectionId, slug, username, body: body.trim() }).catch(()=>{});
|
|
28
30
|
return c.json({ ok: true, id }, 201);
|
|
29
31
|
});
|
|
30
32
|
|
package/src/routes/entries.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { openPod } from '@a83/orbiter-core';
|
|
3
|
+
import { sendNotification } from '../email.js';
|
|
3
4
|
|
|
4
5
|
export const entryRoutes = new Hono();
|
|
5
6
|
|
|
@@ -108,6 +109,7 @@ entryRoutes.put('/:collectionId/entries/:slug', async (c) => {
|
|
|
108
109
|
|
|
109
110
|
if (body.status === 'published' && before?.status !== 'published') {
|
|
110
111
|
fireWebhook(c.get('podPath'));
|
|
112
|
+
sendNotification(c.get('podPath'), 'publish', { collection: collectionId, slug: body.slug ?? slug, username }).catch(()=>{});
|
|
111
113
|
}
|
|
112
114
|
return c.json(updated);
|
|
113
115
|
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { openPod } from '@a83/orbiter-core';
|
|
3
|
+
|
|
4
|
+
export const lockRoutes = new Hono();
|
|
5
|
+
|
|
6
|
+
const STALE_MS = 90_000; // lock expires after 90 s without refresh
|
|
7
|
+
|
|
8
|
+
function lockKey(collection, slug) {
|
|
9
|
+
return `lock.${collection}.${slug}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseLock(val) {
|
|
13
|
+
if (!val) return null;
|
|
14
|
+
const [username, ts] = val.split('|');
|
|
15
|
+
const age = Date.now() - new Date(ts).getTime();
|
|
16
|
+
if (age > STALE_MS) return null; // stale — treat as free
|
|
17
|
+
return { username, ts };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// POST /api/locks/:collection/:slug — claim or refresh lock
|
|
21
|
+
lockRoutes.post('/:collection/:slug', (c) => {
|
|
22
|
+
const { collection, slug } = c.req.param();
|
|
23
|
+
const username = c.get('user')?.username ?? 'unknown';
|
|
24
|
+
const db = openPod(c.get('podPath'));
|
|
25
|
+
const key = lockKey(collection, slug);
|
|
26
|
+
const existing = parseLock(db.getMeta(key));
|
|
27
|
+
|
|
28
|
+
if (existing && existing.username !== username) {
|
|
29
|
+
db.close();
|
|
30
|
+
return c.json({ locked: true, by: existing.username }, 409);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
db.setMeta(key, `${username}|${new Date().toISOString()}`);
|
|
34
|
+
db.close();
|
|
35
|
+
return c.json({ locked: false });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// DELETE /api/locks/:collection/:slug — release lock
|
|
39
|
+
lockRoutes.delete('/:collection/:slug', (c) => {
|
|
40
|
+
const { collection, slug } = c.req.param();
|
|
41
|
+
const username = c.get('user')?.username ?? 'unknown';
|
|
42
|
+
const db = openPod(c.get('podPath'));
|
|
43
|
+
const key = lockKey(collection, slug);
|
|
44
|
+
const existing = parseLock(db.getMeta(key));
|
|
45
|
+
if (existing && existing.username === username) db.setMeta(key, '');
|
|
46
|
+
db.close();
|
|
47
|
+
return c.json({ ok: true });
|
|
48
|
+
});
|
package/src/routes/meta.js
CHANGED
|
@@ -15,6 +15,8 @@ const ALLOWED_KEYS = [
|
|
|
15
15
|
'dashboard.notes', 'dashboard.todos',
|
|
16
16
|
'ui.theme',
|
|
17
17
|
'format_version',
|
|
18
|
+
'email.smtp_host', 'email.smtp_port', 'email.smtp_user', 'email.smtp_pass',
|
|
19
|
+
'email.smtp_from', 'email.notify_publish', 'email.notify_comment', 'email.notify_to',
|
|
18
20
|
];
|
|
19
21
|
|
|
20
22
|
const PREVIEW_URL_RE = /^preview_url\.[a-z0-9_-]+$/;
|
package/src/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { githubRoutes } from './routes/github.js';
|
|
|
23
23
|
import { infoRoutes } from './routes/info.js';
|
|
24
24
|
import { importRoutes } from './routes/import.js';
|
|
25
25
|
import { commentRoutes } from './routes/comments.js';
|
|
26
|
+
import { lockRoutes } from './routes/locks.js';
|
|
26
27
|
import { requireAuth } from './middleware/auth.js';
|
|
27
28
|
|
|
28
29
|
const { version: adminVersion } = JSON.parse(
|
|
@@ -74,6 +75,7 @@ export function createApp(podPath) {
|
|
|
74
75
|
api.route('/import', importRoutes);
|
|
75
76
|
api.route('/collections', commentRoutes);
|
|
76
77
|
api.route('/', commentRoutes);
|
|
78
|
+
api.route('/locks', lockRoutes);
|
|
77
79
|
|
|
78
80
|
app.route('/api', api);
|
|
79
81
|
|