@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a83/orbiter-admin",
3
- "version": "0.3.13",
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
  }
@@ -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)}`;
@@ -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??'';
@@ -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 &lt;noreply@example.com&gt;" />
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', fd.get('api.enabled') ? '1' : '0'],
672
- ['api.token', fd.get('api.token')],
673
- ['preview.token', document.getElementById('preview-token-display').value || null],
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
+ }
@@ -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
 
@@ -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
+ });
@@ -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