@a83/orbiter-admin 0.3.13 → 0.3.15

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,12 +1,15 @@
1
1
  {
2
2
  "name": "@a83/orbiter-admin",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "Standalone admin server for Orbiter CMS",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
7
+ "bin": {
8
+ "orbiter-admin": "./src/cli.js"
9
+ },
7
10
  "scripts": {
8
- "start": "node src/server.js",
9
- "dev": "node --watch src/server.js"
11
+ "start": "node src/cli.js",
12
+ "dev": "node --watch src/cli.js"
10
13
  },
11
14
  "engines": {
12
15
  "node": ">=20.0.0"
@@ -29,6 +32,7 @@
29
32
  "@a83/orbiter-core": "^0.3.2",
30
33
  "@hono/node-server": "^1.14.4",
31
34
  "hono": "^4.7.11",
35
+ "nodemailer": "^8.0.10",
32
36
  "sharp": "^0.34.5"
33
37
  }
34
38
  }
package/public/build.html CHANGED
@@ -61,7 +61,7 @@
61
61
  </div>
62
62
  </nav>
63
63
  <main class="main">
64
- <div class="page-header">
64
+ <div class="page-header glass-card">
65
65
  <h1 class="page-title">Build</h1>
66
66
  <p class="page-sub" id="last-triggered"></p>
67
67
  </div>
@@ -41,7 +41,7 @@
41
41
  </div>
42
42
  </nav>
43
43
  <main class="main">
44
- <div class="page-header" style="display:flex;align-items:flex-start;justify-content:space-between">
44
+ <div class="page-header glass-card" style="display:flex;align-items:flex-start;justify-content:space-between">
45
45
  <div>
46
46
  <h1 class="page-title">Collections</h1>
47
47
  <p class="page-sub">All content types in this pod</p>
@@ -205,7 +205,7 @@
205
205
 
206
206
  <!-- Workspace: Notes + To-do -->
207
207
  <div class="workspace glass-card">
208
- <div class="ws-panel">
208
+ <div class="ws-panel" id="notes">
209
209
  <div class="ws-head">
210
210
  <div class="section-title">Notes</div>
211
211
  <div style="display:flex;align-items:center;gap:10px">
@@ -215,7 +215,7 @@
215
215
  </div>
216
216
  <textarea class="notes-area" id="notes-area" placeholder="Jot something down…"></textarea>
217
217
  </div>
218
- <div class="ws-panel">
218
+ <div class="ws-panel" id="todos">
219
219
  <div class="ws-head">
220
220
  <div class="section-title">To-Do</div>
221
221
  <div style="display:flex;align-items:center;gap:10px">
@@ -236,7 +236,34 @@
236
236
  </div>
237
237
  </div>
238
238
 
239
-
239
+ <script>
240
+ // Scroll to workspace panel when navigated via dock hash link
241
+ (function () {
242
+ var hash = location.hash;
243
+ if (hash === '#notes' || hash === '#todos') {
244
+ window.addEventListener('load', function () {
245
+ var target = document.getElementById(hash.slice(1));
246
+ if (target) {
247
+ setTimeout(function () {
248
+ var main = document.querySelector('.main');
249
+ if (main) {
250
+ main.scrollTo({ top: target.offsetTop - 60, behavior: 'smooth' });
251
+ } else {
252
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
253
+ }
254
+ if (hash === '#notes') {
255
+ var ta = document.getElementById('notes-area');
256
+ if (ta) ta.focus();
257
+ } else {
258
+ var inp = document.getElementById('todo-input');
259
+ if (inp) inp.focus();
260
+ }
261
+ }, 400);
262
+ }
263
+ });
264
+ }
265
+ })();
266
+ </script>
240
267
  <script type="module">
241
268
  // Auth
242
269
  const me = await fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json()).catch(() => null);
@@ -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)}`;
@@ -111,7 +111,7 @@
111
111
  </div>
112
112
  </nav>
113
113
  <main class="main">
114
- <div class="page-header">
114
+ <div class="page-header glass-card">
115
115
  <h1 class="page-title">Import</h1>
116
116
  </div>
117
117
  <div class="import-wrap" id="import-root">
package/public/media.html CHANGED
@@ -72,7 +72,7 @@
72
72
  </div>
73
73
  </nav>
74
74
  <main class="main">
75
- <div class="page-header">
75
+ <div class="page-header glass-card">
76
76
  <h1 class="page-title">Media</h1>
77
77
  <p class="page-sub" id="media-count"></p>
78
78
  </div>
@@ -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??'';
@@ -250,7 +250,7 @@
250
250
  </div>
251
251
  </nav>
252
252
  <main class="main">
253
- <div class="page-header">
253
+ <div class="page-header glass-card">
254
254
  <h1 class="page-title">Settings</h1>
255
255
  </div>
256
256
  <div class="settings-wrap" id="settings-content">
@@ -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>
@@ -594,6 +630,26 @@
594
630
  <div class="theme-card-name">Glass</div>
595
631
  <div class="theme-card-sub">Glassmorphism · blur</div>
596
632
  </button>
633
+ <button class="style-card \${currentStyle==='xfce'?'active':''}" data-style="xfce">
634
+ <div class="style-preview sp-glass" style="position:relative;overflow:hidden;">
635
+ <div class="sp-orb sp-orb1"></div>
636
+ <div class="sp-orb sp-orb2"></div>
637
+ <div style="position:absolute;top:0;left:0;right:0;height:12%;background:color-mix(in srgb,var(--bg2) 60%,transparent);border-bottom:1px solid var(--line);"></div>
638
+ <div class="sp-body" style="margin:0;padding:8% 5% 25%;">
639
+ <div class="sp-row sp-glass-panel"></div>
640
+ <div class="sp-row sp-row-short sp-glass-panel"></div>
641
+ <div class="sp-row sp-glass-panel"></div>
642
+ </div>
643
+ <div style="position:absolute;bottom:4%;left:50%;transform:translateX(-50%);background:color-mix(in srgb,var(--bg2) 60%,transparent);border:1px solid var(--accent);border-radius:8px;padding:3px 8px;display:flex;gap:4px;align-items:center;">
644
+ <span style="width:6px;height:6px;background:var(--accent);border-radius:50%;display:block;"></span>
645
+ <span style="width:6px;height:6px;background:var(--muted);border-radius:50%;display:block;"></span>
646
+ <span style="width:6px;height:6px;background:var(--gold);border-radius:50%;display:block;"></span>
647
+ <span style="width:6px;height:6px;background:var(--muted);border-radius:50%;display:block;"></span>
648
+ </div>
649
+ </div>
650
+ <div class="theme-card-name">Station</div>
651
+ <div class="theme-card-sub">Dock · no sidebar</div>
652
+ </button>
597
653
  </div>
598
654
  </div>
599
655
  </div>
@@ -668,9 +724,19 @@
668
724
  ['media.s3_access_key', fd.get('media.s3_access_key')],
669
725
  ['media.s3_secret_key', fd.get('media.s3_secret_key')],
670
726
  ['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],
727
+ ['api.enabled', fd.get('api.enabled') ? '1' : '0'],
728
+ ['api.token', fd.get('api.token')],
729
+ ['preview.token', document.getElementById('preview-token-display').value || null],
730
+ ['media.img_max_width', fd.get('media.img_max_width')],
731
+ ['media.img_quality', fd.get('media.img_quality')],
732
+ ['email.smtp_host', fd.get('email.smtp_host')],
733
+ ['email.smtp_port', fd.get('email.smtp_port')],
734
+ ['email.smtp_user', fd.get('email.smtp_user')],
735
+ ['email.smtp_pass', fd.get('email.smtp_pass')],
736
+ ['email.smtp_from', fd.get('email.smtp_from')],
737
+ ['email.notify_to', fd.get('email.notify_to')],
738
+ ['email.notify_publish', fd.get('email.notify_publish') ? '1' : '0'],
739
+ ['email.notify_comment', fd.get('email.notify_comment') ? '1' : '0'],
674
740
  ]);
675
741
  showBanner('site-banner','banner-ok','Settings saved');
676
742
  });
@@ -733,9 +799,13 @@
733
799
  const s = btn.dataset.style;
734
800
  document.querySelectorAll('.style-card').forEach(b => b.classList.toggle('active', b.dataset.style === s));
735
801
  const root = document.documentElement;
736
- if (s === 'glass') root.setAttribute('data-style', 'glass');
737
- else root.removeAttribute('data-style');
802
+ root.removeAttribute('data-style');
803
+ if (s === 'glass' || s === 'xfce') root.setAttribute('data-style', s);
738
804
  localStorage.setItem('orb_style', s);
805
+ // xfce requires a reload to inject the dock
806
+ if (s === 'xfce' || document.documentElement.dataset.style === 'xfce') {
807
+ location.reload();
808
+ }
739
809
  });
740
810
  });
741
811
 
package/public/sidebar.js CHANGED
@@ -1,7 +1,18 @@
1
1
  /**
2
2
  * sidebar.js — dynamically populates collection links + pod footer in every page.
3
3
  * Supports parent/child hierarchy (matching SidebarCollections.astro in the original).
4
+ * Also bootstraps xfce.js when orb_style === 'xfce'.
4
5
  */
6
+ ;(function () {
7
+ if (localStorage.getItem('orb_style') === 'xfce') {
8
+ var xs = document.createElement('script');
9
+ xs.src = '/xfce.js';
10
+ var cs = document.currentScript;
11
+ if (cs && cs.parentNode) cs.parentNode.insertBefore(xs, cs.nextSibling);
12
+ else document.head.appendChild(xs);
13
+ }
14
+ })();
15
+
5
16
  (function () {
6
17
  document.addEventListener('DOMContentLoaded', function () {
7
18
  var params = new URLSearchParams(location.search);