@a83/orbiter-admin 0.3.7 → 0.3.10

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.7",
3
+ "version": "0.3.10",
4
4
  "description": "Standalone admin server for Orbiter CMS",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
@@ -101,15 +101,21 @@
101
101
  /* Status */
102
102
  .status-bar { display:flex; align-items:center; gap:8px; padding:8px 0; margin-bottom:10px; }
103
103
  .status-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; }
104
- .status-dot.published { background:var(--jade); }
105
- .status-dot.draft { background:var(--gold); }
104
+ .status-dot.published { background:var(--jade); }
105
+ .status-dot.draft { background:var(--gold); }
106
+ .status-dot.scheduled { background:var(--accent); }
106
107
  .status-lbl { font-size:11px; }
107
- .status-lbl.published { color:var(--jade); }
108
- .status-lbl.draft { color:var(--gold); }
108
+ .status-lbl.published { color:var(--jade); }
109
+ .status-lbl.draft { color:var(--gold); }
110
+ .status-lbl.scheduled { color:var(--accent); }
109
111
  .btn-publish { display:block; width:100%; padding:9px; background:var(--gold); border:none; color:var(--bg0); font-family:var(--mono); font-size:10px; letter-spacing:.12em; cursor:pointer; transition:background .15s; margin-bottom:5px; border-radius:var(--radius); }
110
112
  .btn-publish:hover { background:#7a5520; }
111
- .btn-draft { display:block; width:100%; padding:8px; background:transparent; border:1px solid var(--line); color:var(--mid); font-family:var(--mono); font-size:10px; cursor:pointer; transition:all .12s; border-radius:var(--radius); }
113
+ .btn-draft { display:block; width:100%; padding:8px; background:transparent; border:1px solid var(--line); color:var(--mid); font-family:var(--mono); font-size:10px; cursor:pointer; transition:all .12s; border-radius:var(--radius); margin-bottom:5px; }
112
114
  .btn-draft:hover { border-color:var(--mid); color:var(--text); }
115
+ .btn-schedule { display:block; width:100%; padding:8px; background:transparent; border:1px solid var(--accent); color:var(--accent); font-family:var(--mono); font-size:10px; cursor:pointer; transition:all .12s; border-radius:var(--radius); margin-bottom:5px; }
116
+ .btn-schedule:hover { background:var(--accent-bg); }
117
+ .schedule-picker { display:none; gap:4px; margin-bottom:5px; }
118
+ .schedule-picker.open { display:flex; }
113
119
 
114
120
  /* Schema fields in sidebar */
115
121
  .field-input,.field-select { width:100%; background:var(--bg0); border:1px solid var(--line); padding:6px 8px; color:var(--heading); font-family:var(--mono); font-size:11px; outline:none; appearance:none; transition:border-color .15s; border-radius:var(--radius); box-sizing:border-box; }
@@ -480,10 +486,11 @@
480
486
  document.getElementById('collection-id-display').textContent = COLLECTION;
481
487
 
482
488
  // Load collection schema + entry
483
- const [colData, entryData, versionsData, mediaData] = await Promise.all([
489
+ const [colData, entryData, versionsData, activityData, mediaData] = await Promise.all([
484
490
  fetch(`/api/collections/${COLLECTION}`,{credentials:'include'}).then(r=>r.ok?r.json():null),
485
491
  IS_NEW ? null : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}`,{credentials:'include'}).then(r=>r.ok?r.json():null),
486
492
  IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/versions`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
493
+ IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/activity`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
487
494
  fetch('/api/media',{credentials:'include'}).then(r=>r.json()).catch(()=>[]),
488
495
  ]);
489
496
 
@@ -506,14 +513,20 @@
506
513
  }
507
514
  }
508
515
 
509
- // Fetch preview URL for this collection
510
- const previewMeta = await fetch(`/api/meta/preview_url~${COLLECTION}`,{credentials:'include'}).then(r=>r.json()).catch(()=>null);
516
+ // Fetch preview URL + token for this collection
517
+ const [previewMeta, previewTokenMeta] = await Promise.all([
518
+ fetch(`/api/meta/preview_url~${COLLECTION}`,{credentials:'include'}).then(r=>r.json()).catch(()=>null),
519
+ fetch('/api/meta/preview~token',{credentials:'include'}).then(r=>r.json()).catch(()=>null),
520
+ ]);
511
521
  const previewUrlTemplate = previewMeta?.value || '';
522
+ const previewToken = previewTokenMeta?.value || '';
512
523
  function updatePreviewLink() {
513
524
  const btn = document.getElementById('btn-preview-link');
514
525
  if (!btn || !previewUrlTemplate) return;
515
526
  const slug = document.getElementById('slug-input')?.value || currentSlug || SLUG;
516
- btn.href = previewUrlTemplate.replace('{slug}', slug);
527
+ const base = previewUrlTemplate.replace('{slug}', slug);
528
+ const sep = base.includes('?') ? '&' : '?';
529
+ btn.href = previewToken ? base + sep + 'preview_token=' + previewToken : base;
517
530
  btn.style.display = '';
518
531
  }
519
532
 
@@ -532,8 +545,10 @@
532
545
 
533
546
  function renderMetaPanel() {
534
547
  const panel = document.getElementById('meta-panel');
535
- const status = IS_NEW ? 'draft' : (entryData?.status ?? 'draft');
548
+ const status = IS_NEW ? 'draft' : (entryData?.status ?? 'draft');
536
549
  const updatedAt = entryData?.updated_at;
550
+ const publishAt = entryData?.publish_at ?? null;
551
+ const publishAtInput = publishAt ? publishAt.replace(' ', 'T').slice(0, 16) : '';
537
552
 
538
553
  let fieldsHtml = '';
539
554
  const seoFields = {};
@@ -625,6 +640,15 @@
625
640
  </div>
626
641
  `).join('');
627
642
 
643
+ const actionLabel = { create:'Created', update:'Saved', publish:'Published', unpublish:'Unpublished', delete:'Moved to trash', restore:'Restored' };
644
+ const actHtml = activityData.slice(0,10).map(a=>`
645
+ <div style="display:flex;align-items:baseline;gap:8px;padding:4px 0;border-bottom:1px solid var(--line2);font-size:10px;">
646
+ <span style="color:var(--text);font-weight:500;flex-shrink:0">${actionLabel[a.action]??a.action}</span>
647
+ <span style="color:var(--muted);flex:1;text-align:right">${a.username}</span>
648
+ <span style="color:var(--muted);font-family:var(--mono);flex-shrink:0">${new Date(a.created_at).toLocaleDateString()}</span>
649
+ </div>
650
+ `).join('');
651
+
628
652
  const hasSeo = !!(seoFields.titleKey || seoFields.descKey);
629
653
  const serpHtml = hasSeo ? `<div class="serp-preview" id="serp-preview">
630
654
  <div class="serp-url" id="serp-url">${location.host}/${COLLECTION}/<span id="serp-slug-part">${escHtml(IS_NEW?'…':SLUG)}</span></div>
@@ -637,10 +661,16 @@
637
661
  <div class="meta-section">
638
662
  <div class="status-bar">
639
663
  <div class="status-dot ${status}" id="status-dot"></div>
640
- <div class="status-lbl ${status}" id="status-lbl">${status==='published'?'Published':'Draft'}</div>
664
+ <div class="status-lbl ${status}" id="status-lbl">${status==='published'?'Published':status==='scheduled'?'Scheduled':'Draft'}</div>
665
+ ${status==='scheduled'&&publishAt?`<span style="font-size:10px;color:var(--muted);margin-left:auto">${new Date(publishAt.replace(' ','T')).toLocaleDateString()}</span>`:''}
666
+ </div>
667
+ <button type="button" class="btn-publish" id="btn-publish">${status==='published'?'Republish':'Publish now'}</button>
668
+ <button type="button" class="btn-draft" id="btn-draft">${status==='scheduled'?'Cancel schedule':'Save as draft'}</button>
669
+ <div class="schedule-picker${status==='scheduled'?' open':''}" id="schedule-picker">
670
+ <input type="datetime-local" class="field-input" id="publish-at-input" value="${publishAtInput}" style="flex:1;font-size:10px;padding:5px 8px;" />
671
+ <button type="button" id="btn-schedule-confirm" style="padding:5px 10px;background:var(--accent);border:none;color:var(--bg0);font-family:var(--mono);font-size:10px;cursor:pointer;border-radius:var(--radius);white-space:nowrap;">Set</button>
641
672
  </div>
642
- <button type="button" class="btn-publish" id="btn-publish">Publish</button>
643
- <button type="button" class="btn-draft" id="btn-draft">Save as draft</button>
673
+ <button type="button" class="btn-schedule" id="btn-schedule">${status==='scheduled'?'Reschedule':'Schedule'}</button>
644
674
  </div>
645
675
  <div class="meta-section">
646
676
  <div class="meta-label">Details</div>
@@ -653,11 +683,13 @@
653
683
  <select class="field-select" id="status-select">
654
684
  <option value="draft" ${status==='draft' ?'selected':''}>Draft</option>
655
685
  <option value="published" ${status==='published' ?'selected':''}>Published</option>
686
+ <option value="scheduled" ${status==='scheduled' ?'selected':''}>Scheduled</option>
656
687
  </select>
657
688
  </div>
658
689
  ${updatedAt ? `<div class="meta-field"><div class="field-label">Modified</div><div class="field-readonly">${new Date(updatedAt).toLocaleDateString()}</div></div>` : ''}
659
690
  </div>
660
691
  ${versionsData.length ? `<div class="meta-section"><div class="meta-label">History</div>${versHtml}</div>` : ''}
692
+ ${activityData.length ? `<div class="meta-section"><div class="meta-label">Activity</div>${actHtml}</div>` : ''}
661
693
  `;
662
694
 
663
695
  // Wire up weekday toggles
@@ -712,9 +744,17 @@
712
744
  });
713
745
  });
714
746
 
715
- // Publish / draft buttons
716
- document.getElementById('btn-publish').addEventListener('click',()=>saveEntry('published'));
717
- document.getElementById('btn-draft').addEventListener('click',()=>saveEntry('draft'));
747
+ // Publish / draft / schedule buttons
748
+ document.getElementById('btn-publish').addEventListener('click', () => saveEntry('published'));
749
+ document.getElementById('btn-draft').addEventListener('click', () => saveEntry('draft'));
750
+ document.getElementById('btn-schedule').addEventListener('click', () => {
751
+ document.getElementById('schedule-picker').classList.toggle('open');
752
+ });
753
+ document.getElementById('btn-schedule-confirm').addEventListener('click', () => {
754
+ const val = document.getElementById('publish-at-input').value;
755
+ if (!val) { alert('Please select a date and time.'); return; }
756
+ saveEntry('scheduled');
757
+ });
718
758
 
719
759
  // Slug input → slug preview
720
760
  document.getElementById('slug-input').addEventListener('input', e=>{
@@ -785,8 +825,15 @@
785
825
  function updateStatusUI(status) {
786
826
  document.getElementById('status-dot').className = 'status-dot ' + status;
787
827
  document.getElementById('status-lbl').className = 'status-lbl ' + status;
788
- document.getElementById('status-lbl').textContent = status==='published'?'Published':'Draft';
789
- document.getElementById('status-select').value = status;
828
+ document.getElementById('status-lbl').textContent = status === 'published' ? 'Published' : status === 'scheduled' ? 'Scheduled' : 'Draft';
829
+ const sel = document.getElementById('status-select');
830
+ if (sel) sel.value = status;
831
+ const publishBtn = document.getElementById('btn-publish');
832
+ const draftBtn = document.getElementById('btn-draft');
833
+ const schedBtn = document.getElementById('btn-schedule');
834
+ if (publishBtn) publishBtn.textContent = status === 'published' ? 'Republish' : 'Publish now';
835
+ if (draftBtn) draftBtn.textContent = status === 'scheduled' ? 'Cancel schedule' : 'Save as draft';
836
+ if (schedBtn) schedBtn.textContent = status === 'scheduled' ? 'Reschedule' : 'Schedule';
790
837
  }
791
838
 
792
839
  function renderTags(key) {
@@ -863,6 +910,14 @@
863
910
  let currentPath = location.pathname + location.search;
864
911
 
865
912
  async function saveEntry(status, isAutosave=false) {
913
+ // Resolve publish_at for scheduled entries
914
+ let publish_at = null;
915
+ if (status === 'scheduled') {
916
+ const raw = document.getElementById('publish-at-input')?.value;
917
+ if (!raw) { if (!isAutosave) alert('Please select a date and time.'); return; }
918
+ publish_at = raw.replace('T', ' ') + ':00';
919
+ }
920
+
866
921
  const title = document.getElementById('title-input').value;
867
922
  syncToHidden();
868
923
  const body = document.getElementById('body-input').value;
@@ -890,7 +945,7 @@
890
945
  const res = await fetch(`/api/collections/${COLLECTION}/entries`,{
891
946
  method:'POST', credentials:'include',
892
947
  headers:{'Content-Type':'application/json'},
893
- body: JSON.stringify({ slug, data, status }),
948
+ body: JSON.stringify({ slug, data, status, publish_at }),
894
949
  });
895
950
  const json = await res.json();
896
951
  if (json.slug || json.id) {
@@ -909,7 +964,7 @@
909
964
  const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}`,{
910
965
  method:'PUT', credentials:'include',
911
966
  headers:{'Content-Type':'application/json'},
912
- body: JSON.stringify({ slug, data, status }),
967
+ body: JSON.stringify({ slug, data, status, publish_at }),
913
968
  });
914
969
  if (res.ok) {
915
970
  const json = await res.json();
@@ -100,15 +100,19 @@
100
100
  <button class="filter-tab active" data-status="">All</button>
101
101
  <button class="filter-tab" data-status="published">Published</button>
102
102
  <button class="filter-tab" data-status="draft">Drafts</button>
103
+ <button class="filter-tab" data-status="scheduled">Scheduled</button>
104
+ <button class="filter-tab" data-status="trash" style="margin-left:auto">🗑 Trash</button>
103
105
  </div>
104
106
 
105
107
  <!-- Bulk action bar -->
106
108
  <div class="bulk-bar" id="bulk-bar">
107
109
  <span><span class="bulk-bar-count" id="bulk-count">0</span> selected</span>
108
- <button class="btn btn-ghost btn-sm" id="bulk-publish">Publish</button>
109
- <button class="btn btn-ghost btn-sm" id="bulk-draft">Unpublish</button>
110
+ <button class="btn btn-ghost btn-sm bulk-normal" id="bulk-publish">Publish</button>
111
+ <button class="btn btn-ghost btn-sm bulk-normal" id="bulk-draft">Unpublish</button>
112
+ <button class="btn btn-ghost btn-sm bulk-trash" id="bulk-restore" style="display:none">Restore</button>
110
113
  <span class="bulk-bar-spacer"></span>
111
- <button class="btn btn-danger btn-sm" id="bulk-delete">Delete selected</button>
114
+ <button class="btn btn-danger btn-sm bulk-normal" id="bulk-delete">Move to Trash</button>
115
+ <button class="btn btn-danger btn-sm bulk-trash" id="bulk-permanent" style="display:none">Delete forever</button>
112
116
  </div>
113
117
 
114
118
  <div class="table-wrap" id="entries-wrap">
@@ -140,7 +144,10 @@
140
144
  let selected = new Set();
141
145
 
142
146
  function updateBulkBar() {
143
- const bar = document.getElementById('bulk-bar');
147
+ const bar = document.getElementById('bulk-bar');
148
+ const trash = activeFilter === 'trash';
149
+ document.querySelectorAll('.bulk-normal').forEach(el => el.style.display = trash ? 'none' : '');
150
+ document.querySelectorAll('.bulk-trash').forEach(el => el.style.display = trash ? '' : 'none');
144
151
  if (selected.size > 0) {
145
152
  bar.classList.add('visible');
146
153
  document.getElementById('bulk-count').textContent = selected.size;
@@ -165,6 +172,7 @@
165
172
  wrap.innerHTML = '<div class="empty"><div class="empty-icon">◈</div>No entries yet</div>';
166
173
  return;
167
174
  }
175
+ const isTrash = activeFilter === 'trash';
168
176
  const canSort = !activeFilter;
169
177
  wrap.innerHTML = `
170
178
  <table>
@@ -172,15 +180,17 @@
172
180
  <tr>
173
181
  ${canSort ? '<th class="drag-col"></th>' : ''}
174
182
  <th class="cb-col"><input type="checkbox" id="check-all" title="Select all" /></th>
175
- <th>Title / Slug</th><th>Status</th><th>Updated</th><th></th>
183
+ <th>Title / Slug</th><th>${isTrash ? 'Deleted' : 'Status'}</th><th>${isTrash ? '' : 'Updated'}</th><th></th>
176
184
  </tr>
177
185
  </thead>
178
186
  <tbody>
179
187
  ${entries.map(e => {
180
188
  const title = e.data?.title || e.slug;
181
189
  const updated = e.updated_at ? e.updated_at.split(' ')[0] : '—';
182
- const nextStatus = e.status === 'published' ? 'draft' : 'published';
183
- const toggleLabel = e.status === 'published' ? 'Unpublish' : 'Publish';
190
+ const deleted = e.deleted_at ? e.deleted_at.split(' ')[0] : '';
191
+ const nextStatus = e.status === 'published' ? 'draft' : 'published';
192
+ const toggleLabel = e.status === 'published' ? 'Unpublish' : e.status === 'scheduled' ? 'Publish now' : 'Publish';
193
+ const schedInfo = e.status === 'scheduled' && e.publish_at ? ` · ${e.publish_at.split(' ')[0]}` : '';
184
194
  return `<tr data-slug="${e.slug}"${canSort ? ' draggable="true"' : ''}>
185
195
  ${canSort ? `<td class="drag-col"><span class="drag-handle" title="Drag to reorder">⠿</span></td>` : ''}
186
196
  <td class="cb-col"><input type="checkbox" class="row-cb" data-slug="${e.slug}" ${selected.has(e.slug) ? 'checked' : ''} /></td>
@@ -188,14 +198,18 @@
188
198
  <div style="color:var(--heading);font-weight:500">${title}</div>
189
199
  <div style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-top:2px">${e.slug}</div>
190
200
  </td>
191
- <td><span class="badge badge-${e.status}">${e.status}</span></td>
192
- <td style="font-family:var(--mono);font-size:11px;color:var(--muted)">${updated}</td>
201
+ <td>${isTrash ? `<span style="font-family:var(--mono);font-size:11px;color:var(--muted)">${deleted}</span>` : `<span class="badge badge-${e.status}">${e.status}${schedInfo}</span>`}</td>
202
+ <td style="font-family:var(--mono);font-size:11px;color:var(--muted)">${isTrash ? '' : updated}</td>
193
203
  <td style="width:1%;white-space:nowrap">
194
204
  <div class="row-actions">
195
- <a class="btn-row" href="/editor.html?collection=${colId}&slug=${e.slug}">Edit</a>
196
- <button class="btn-row btn-row-toggle status-toggle" data-slug="${e.slug}" data-next="${nextStatus}">${toggleLabel}</button>
197
- <button class="btn-row btn-row-icon dup-btn" data-slug="${e.slug}" title="Duplicate">⧉</button>
198
- <button class="btn-row btn-row-danger delete-btn" data-slug="${e.slug}">Delete</button>
205
+ ${isTrash
206
+ ? `<button class="btn-row restore-btn" data-slug="${e.slug}">Restore</button>
207
+ <button class="btn-row btn-row-danger perm-del-btn" data-slug="${e.slug}">Delete forever</button>`
208
+ : `<a class="btn-row" href="/editor.html?collection=${colId}&slug=${e.slug}">Edit</a>
209
+ <button class="btn-row btn-row-toggle status-toggle" data-slug="${e.slug}" data-next="${nextStatus}">${toggleLabel}</button>
210
+ <button class="btn-row btn-row-icon dup-btn" data-slug="${e.slug}" title="Duplicate">⧉</button>
211
+ <button class="btn-row btn-row-danger delete-btn" data-slug="${e.slug}">Trash</button>`
212
+ }
199
213
  </div>
200
214
  </td>
201
215
  </tr>`;
@@ -288,16 +302,36 @@
288
302
  });
289
303
  });
290
304
 
291
- // Delete
305
+ // Delete → Trash
292
306
  wrap.querySelectorAll('.delete-btn').forEach(btn => {
293
307
  btn.addEventListener('click', async () => {
294
- if (!confirm(`Delete "${btn.dataset.slug}"?`)) return;
295
308
  await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}`, {
296
309
  method: 'DELETE', credentials: 'include',
297
310
  });
298
311
  loadEntries();
299
312
  });
300
313
  });
314
+
315
+ // Restore from Trash
316
+ wrap.querySelectorAll('.restore-btn').forEach(btn => {
317
+ btn.addEventListener('click', async () => {
318
+ await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}/restore`, {
319
+ method: 'POST', credentials: 'include',
320
+ });
321
+ loadEntries();
322
+ });
323
+ });
324
+
325
+ // Permanent delete
326
+ wrap.querySelectorAll('.perm-del-btn').forEach(btn => {
327
+ btn.addEventListener('click', async () => {
328
+ if (!confirm(`Permanently delete "${btn.dataset.slug}"? This cannot be undone.`)) return;
329
+ await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}/permanent`, {
330
+ method: 'DELETE', credentials: 'include',
331
+ });
332
+ loadEntries();
333
+ });
334
+ });
301
335
  }
302
336
 
303
337
  // Filter buttons
@@ -322,9 +356,15 @@
322
356
  });
323
357
  loadEntries();
324
358
  }
325
- document.getElementById('bulk-publish').addEventListener('click', () => bulkAction('publish'));
326
- document.getElementById('bulk-draft').addEventListener('click', () => bulkAction('draft'));
327
- document.getElementById('bulk-delete').addEventListener('click', () => bulkAction('delete'));
359
+ document.getElementById('bulk-publish').addEventListener('click', () => bulkAction('publish'));
360
+ document.getElementById('bulk-draft').addEventListener('click', () => bulkAction('draft'));
361
+ document.getElementById('bulk-delete').addEventListener('click', () => bulkAction('delete'));
362
+ document.getElementById('bulk-restore').addEventListener('click', () => bulkAction('restore'));
363
+ document.getElementById('bulk-permanent').addEventListener('click', async () => {
364
+ const n = selected.size;
365
+ if (!confirm(`Permanently delete ${n} entr${n !== 1 ? 'ies' : 'y'}? This cannot be undone.`)) return;
366
+ await bulkAction('permanent');
367
+ });
328
368
 
329
369
  // New entry modal
330
370
  const overlay = document.getElementById('modal-overlay');
@@ -362,6 +362,13 @@
362
362
  <div><div class="setting-label">API token</div><div class="setting-desc">Optional bearer token to restrict access</div></div>
363
363
  <input class="input" type="password" name="api.token" value="${get('api.token')}" autocomplete="off" placeholder="Leave blank for open access" />
364
364
  </div>
365
+ <div class="setting-row">
366
+ <div><div class="setting-label">Preview token</div><div class="setting-desc">Appended to Preview URLs — allows viewing draft entries on your site</div></div>
367
+ <div style="display:flex;gap:8px;align-items:center">
368
+ <input class="input" type="text" id="preview-token-display" value="${get('preview.token')??''}" readonly style="flex:1;font-family:var(--mono);font-size:11px;" placeholder="No token generated" />
369
+ <button type="button" class="btn btn-ghost btn-sm" id="btn-gen-preview-token">Generate</button>
370
+ </div>
371
+ </div>
365
372
  </div>
366
373
 
367
374
  <div class="settings-group">
@@ -651,10 +658,22 @@
651
658
  ['media.s3_public_url', fd.get('media.s3_public_url')],
652
659
  ['api.enabled', fd.get('api.enabled') ? '1' : '0'],
653
660
  ['api.token', fd.get('api.token')],
661
+ ['preview.token', document.getElementById('preview-token-display').value || null],
654
662
  ]);
655
663
  showBanner('site-banner','banner-ok','Settings saved');
656
664
  });
657
665
 
666
+ // Preview token generator
667
+ document.getElementById('btn-gen-preview-token')?.addEventListener('click', async () => {
668
+ const token = Array.from(crypto.getRandomValues(new Uint8Array(24))).map(b=>b.toString(16).padStart(2,'0')).join('');
669
+ document.getElementById('preview-token-display').value = token;
670
+ await fetch('/api/meta/preview.token', {
671
+ method: 'PUT', credentials: 'include',
672
+ headers: { 'Content-Type': 'application/json' },
673
+ body: JSON.stringify({ value: token }),
674
+ });
675
+ });
676
+
658
677
  // GitHub push (conditional: only wired if section rendered)
659
678
  const ghPushBtn = document.getElementById('github-push-btn');
660
679
  if (ghPushBtn) ghPushBtn.addEventListener('click', async () => {
package/public/style.css CHANGED
@@ -936,8 +936,9 @@ td { padding: 10px 20px; font-size: 12px; color: var(--text); border-bottom: 1px
936
936
  tr:last-child td { border-bottom: none; }
937
937
  tr:hover td { background: var(--row-hover); }
938
938
  .badge { display: inline-block; font-size: 10px; font-family: var(--mono); padding: 2px 7px; border-radius: 2px; letter-spacing: 0.04em; }
939
- .badge-published { background: var(--jade-bg); color: var(--jade); }
940
- .badge-draft { background: var(--gold-bg); color: var(--gold); }
939
+ .badge-published { background: var(--jade-bg); color: var(--jade); }
940
+ .badge-draft { background: var(--gold-bg); color: var(--gold); }
941
+ .badge-scheduled { background: var(--accent-bg); color: var(--accent); }
941
942
 
942
943
  /* ── Buttons ─────────────────────────────────────── */
943
944
  .btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; font-size: 12px; font-family: var(--sans); border-radius: var(--radius); cursor: pointer; transition: all 0.12s; border: 1px solid transparent; font-weight: 400; letter-spacing: 0.01em; }
@@ -16,11 +16,12 @@ entryRoutes.post('/:collectionId/entries/bulk', async (c) => {
16
16
  const { collectionId } = c.req.param();
17
17
  const { action, slugs } = await c.req.json();
18
18
  if (!Array.isArray(slugs) || !slugs.length) return c.json({ error: 'slugs required' }, 400);
19
- if (!['publish', 'draft', 'delete'].includes(action)) return c.json({ error: 'Invalid action' }, 400);
19
+ if (!['publish', 'draft', 'delete', 'restore', 'permanent'].includes(action)) return c.json({ error: 'Invalid action' }, 400);
20
20
  const db = openPod(c.get('podPath'));
21
- if (action === 'delete') {
22
- slugs.forEach(slug => db.deleteEntry(collectionId, slug));
23
- } else {
21
+ if (action === 'delete') { slugs.forEach(slug => db.deleteEntry(collectionId, slug)); }
22
+ else if (action === 'restore') { slugs.forEach(slug => db.restoreEntry(collectionId, slug)); }
23
+ else if (action === 'permanent') { slugs.forEach(slug => db.permanentDeleteEntry(collectionId, slug)); }
24
+ else {
24
25
  const status = action === 'publish' ? 'published' : 'draft';
25
26
  slugs.forEach(slug => {
26
27
  const entry = db.getEntry(collectionId, slug);
@@ -78,6 +79,7 @@ entryRoutes.post('/:collectionId/entries', async (c) => {
78
79
 
79
80
  const id = db.createEntry(collectionId, slug, data, status);
80
81
  const entry = db.getEntry(collectionId, slug);
82
+ db.logAudit(id, c.get('user')?.username ?? 'unknown', 'create');
81
83
  db.close();
82
84
  return c.json({ ...entry, id }, 201);
83
85
  });
@@ -91,7 +93,17 @@ entryRoutes.put('/:collectionId/entries/:slug', async (c) => {
91
93
  const before = db.getEntry(collectionId, slug);
92
94
  const ok = db.updateEntry(collectionId, slug, body);
93
95
  if (!ok) { db.close(); return c.json({ error: 'Not found' }, 404); }
94
- const updated = db.getEntry(collectionId, body.slug ?? slug);
96
+ const updated = db.getEntry(collectionId, body.slug ?? slug);
97
+ const username = c.get('user')?.username ?? 'unknown';
98
+ if (body.status === 'published' && before?.status !== 'published') {
99
+ db.logAudit(updated.id, username, 'publish');
100
+ } else if (body.status === 'draft' && before?.status === 'published') {
101
+ db.logAudit(updated.id, username, 'unpublish');
102
+ } else if (body.status === 'scheduled' && before?.status !== 'scheduled') {
103
+ db.logAudit(updated.id, username, 'schedule');
104
+ } else {
105
+ db.logAudit(updated.id, username, 'update');
106
+ }
95
107
  db.close();
96
108
 
97
109
  if (body.status === 'published' && before?.status !== 'published') {
@@ -100,16 +112,51 @@ entryRoutes.put('/:collectionId/entries/:slug', async (c) => {
100
112
  return c.json(updated);
101
113
  });
102
114
 
103
- // DELETE /api/collections/:id/entries/:slug
115
+ // DELETE /api/collections/:id/entries/:slug → soft delete (trash)
104
116
  entryRoutes.delete('/:collectionId/entries/:slug', (c) => {
117
+ const { collectionId, slug } = c.req.param();
118
+ const db = openPod(c.get('podPath'));
119
+ const entry = db.getEntry(collectionId, slug);
120
+ if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
121
+ db.deleteEntry(collectionId, slug);
122
+ db.logAudit(entry.id, c.get('user')?.username ?? 'unknown', 'delete');
123
+ db.close();
124
+ return c.json({ ok: true });
125
+ });
126
+
127
+ // POST /api/collections/:id/entries/:slug/restore — move from trash back to draft
128
+ entryRoutes.post('/:collectionId/entries/:slug/restore', (c) => {
129
+ const { collectionId, slug } = c.req.param();
130
+ const db = openPod(c.get('podPath'));
131
+ const row = db.db.prepare('SELECT * FROM _entries WHERE collection_id = ? AND slug = ? AND deleted_at IS NOT NULL').get(collectionId, slug);
132
+ const ok = db.restoreEntry(collectionId, slug);
133
+ if (row) db.logAudit(row.id, c.get('user')?.username ?? 'unknown', 'restore');
134
+ db.close();
135
+ if (!ok) return c.json({ error: 'Not found in trash' }, 404);
136
+ return c.json({ ok: true });
137
+ });
138
+
139
+ // DELETE /api/collections/:id/entries/:slug/permanent — hard delete from trash
140
+ entryRoutes.delete('/:collectionId/entries/:slug/permanent', (c) => {
105
141
  const { collectionId, slug } = c.req.param();
106
142
  const db = openPod(c.get('podPath'));
107
- const ok = db.deleteEntry(collectionId, slug);
143
+ const ok = db.permanentDeleteEntry(collectionId, slug);
108
144
  db.close();
109
145
  if (!ok) return c.json({ error: 'Not found' }, 404);
110
146
  return c.json({ ok: true });
111
147
  });
112
148
 
149
+ // GET /api/collections/:id/entries/:slug/activity
150
+ entryRoutes.get('/:collectionId/entries/:slug/activity', (c) => {
151
+ const { collectionId, slug } = c.req.param();
152
+ const db = openPod(c.get('podPath'));
153
+ const entry = db.db.prepare('SELECT * FROM _entries WHERE collection_id = ? AND slug = ?').get(collectionId, slug);
154
+ if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
155
+ const log = db.getAuditLog(entry.id);
156
+ db.close();
157
+ return c.json(log);
158
+ });
159
+
113
160
  // GET /api/collections/:id/entries/:slug/versions
114
161
  entryRoutes.get('/:collectionId/entries/:slug/versions', (c) => {
115
162
  const { collectionId, slug } = c.req.param();
@@ -141,12 +188,16 @@ entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
141
188
  // PATCH /api/collections/:id/entries/:slug/status
142
189
  entryRoutes.patch('/:collectionId/entries/:slug/status', async (c) => {
143
190
  const { collectionId, slug } = c.req.param();
144
- const { status } = await c.req.json();
145
- if (!['draft', 'published'].includes(status)) return c.json({ error: 'Invalid status' }, 400);
191
+ const { status, publish_at } = await c.req.json();
192
+ if (!['draft', 'published', 'scheduled'].includes(status)) return c.json({ error: 'Invalid status' }, 400);
193
+ if (status === 'scheduled' && !publish_at) return c.json({ error: 'publish_at required for scheduled status' }, 400);
146
194
  const db = openPod(c.get('podPath'));
147
195
  const entry = db.getEntry(collectionId, slug);
148
196
  if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
149
- db.updateEntry(collectionId, slug, { slug, data: entry.data, status });
197
+ const pa = status === 'scheduled' ? publish_at : null;
198
+ db.updateEntry(collectionId, slug, { slug, data: entry.data, status, publish_at: pa });
199
+ const username = c.get('user')?.username ?? 'unknown';
200
+ if (status === 'scheduled') db.logAudit(entry.id, username, 'schedule');
150
201
  db.close();
151
202
  if (status === 'published') fireWebhook(c.get('podPath'));
152
203
  return c.json({ ok: true });
@@ -10,7 +10,7 @@ const ALLOWED_KEYS = [
10
10
  'media.backend', 'media.local_path',
11
11
  'media.github_token', 'media.github_repo', 'media.github_branch', 'media.github_dir',
12
12
  'media.s3_bucket', 'media.s3_region', 'media.s3_endpoint', 'media.s3_access_key', 'media.s3_secret_key', 'media.s3_public_url',
13
- 'api.enabled', 'api.token',
13
+ 'api.enabled', 'api.token', 'preview.token',
14
14
  'dashboard.notes', 'dashboard.todos',
15
15
  'ui.theme',
16
16
  'format_version',
package/src/server.js CHANGED
@@ -93,3 +93,24 @@ serve({ fetch: createApp(POD_PATH).fetch, port: PORT }, () => {
93
93
  console.log(`Orbiter Admin API → http://localhost:${PORT}`);
94
94
  console.log(`Pod: ${POD_PATH}`);
95
95
  });
96
+
97
+ // Scheduled publishing — check every 60 s
98
+ setInterval(() => {
99
+ try {
100
+ const db = openPod(POD_PATH);
101
+ const due = db.getScheduledDue();
102
+ if (!due.length) { db.close(); return; }
103
+ const now = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
104
+ for (const entry of due) {
105
+ db.db.prepare("UPDATE _entries SET status = 'published', publish_at = NULL, updated_at = ? WHERE id = ?").run(now, entry.id);
106
+ db.logAudit(entry.id, 'scheduler', 'publish');
107
+ }
108
+ const webhookUrl = db.getMeta('build.webhook_url') ?? '';
109
+ if (webhookUrl) db.setMeta('build.last_triggered', new Date().toISOString());
110
+ db.close();
111
+ console.log(`[scheduler] Published ${due.length} scheduled entr${due.length === 1 ? 'y' : 'ies'}`);
112
+ if (webhookUrl) fetch(webhookUrl, { method: 'POST' }).catch(() => {});
113
+ } catch (e) {
114
+ console.warn('[scheduler]', e.message);
115
+ }
116
+ }, 60_000);