@a83/orbiter-admin 0.3.7 → 0.3.9

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.9",
4
4
  "description": "Standalone admin server for Orbiter CMS",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
@@ -480,10 +480,11 @@
480
480
  document.getElementById('collection-id-display').textContent = COLLECTION;
481
481
 
482
482
  // Load collection schema + entry
483
- const [colData, entryData, versionsData, mediaData] = await Promise.all([
483
+ const [colData, entryData, versionsData, activityData, mediaData] = await Promise.all([
484
484
  fetch(`/api/collections/${COLLECTION}`,{credentials:'include'}).then(r=>r.ok?r.json():null),
485
485
  IS_NEW ? null : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}`,{credentials:'include'}).then(r=>r.ok?r.json():null),
486
486
  IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/versions`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
487
+ IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/activity`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
487
488
  fetch('/api/media',{credentials:'include'}).then(r=>r.json()).catch(()=>[]),
488
489
  ]);
489
490
 
@@ -506,14 +507,20 @@
506
507
  }
507
508
  }
508
509
 
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);
510
+ // Fetch preview URL + token for this collection
511
+ const [previewMeta, previewTokenMeta] = await Promise.all([
512
+ fetch(`/api/meta/preview_url~${COLLECTION}`,{credentials:'include'}).then(r=>r.json()).catch(()=>null),
513
+ fetch('/api/meta/preview~token',{credentials:'include'}).then(r=>r.json()).catch(()=>null),
514
+ ]);
511
515
  const previewUrlTemplate = previewMeta?.value || '';
516
+ const previewToken = previewTokenMeta?.value || '';
512
517
  function updatePreviewLink() {
513
518
  const btn = document.getElementById('btn-preview-link');
514
519
  if (!btn || !previewUrlTemplate) return;
515
520
  const slug = document.getElementById('slug-input')?.value || currentSlug || SLUG;
516
- btn.href = previewUrlTemplate.replace('{slug}', slug);
521
+ const base = previewUrlTemplate.replace('{slug}', slug);
522
+ const sep = base.includes('?') ? '&' : '?';
523
+ btn.href = previewToken ? base + sep + 'preview_token=' + previewToken : base;
517
524
  btn.style.display = '';
518
525
  }
519
526
 
@@ -625,6 +632,15 @@
625
632
  </div>
626
633
  `).join('');
627
634
 
635
+ const actionLabel = { create:'Created', update:'Saved', publish:'Published', unpublish:'Unpublished', delete:'Moved to trash', restore:'Restored' };
636
+ const actHtml = activityData.slice(0,10).map(a=>`
637
+ <div style="display:flex;align-items:baseline;gap:8px;padding:4px 0;border-bottom:1px solid var(--line2);font-size:10px;">
638
+ <span style="color:var(--text);font-weight:500;flex-shrink:0">${actionLabel[a.action]??a.action}</span>
639
+ <span style="color:var(--muted);flex:1;text-align:right">${a.username}</span>
640
+ <span style="color:var(--muted);font-family:var(--mono);flex-shrink:0">${new Date(a.created_at).toLocaleDateString()}</span>
641
+ </div>
642
+ `).join('');
643
+
628
644
  const hasSeo = !!(seoFields.titleKey || seoFields.descKey);
629
645
  const serpHtml = hasSeo ? `<div class="serp-preview" id="serp-preview">
630
646
  <div class="serp-url" id="serp-url">${location.host}/${COLLECTION}/<span id="serp-slug-part">${escHtml(IS_NEW?'…':SLUG)}</span></div>
@@ -658,6 +674,7 @@
658
674
  ${updatedAt ? `<div class="meta-field"><div class="field-label">Modified</div><div class="field-readonly">${new Date(updatedAt).toLocaleDateString()}</div></div>` : ''}
659
675
  </div>
660
676
  ${versionsData.length ? `<div class="meta-section"><div class="meta-label">History</div>${versHtml}</div>` : ''}
677
+ ${activityData.length ? `<div class="meta-section"><div class="meta-label">Activity</div>${actHtml}</div>` : ''}
661
678
  `;
662
679
 
663
680
  // Wire up weekday toggles
@@ -100,15 +100,18 @@
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="trash" style="margin-left:auto">🗑 Trash</button>
103
104
  </div>
104
105
 
105
106
  <!-- Bulk action bar -->
106
107
  <div class="bulk-bar" id="bulk-bar">
107
108
  <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>
109
+ <button class="btn btn-ghost btn-sm bulk-normal" id="bulk-publish">Publish</button>
110
+ <button class="btn btn-ghost btn-sm bulk-normal" id="bulk-draft">Unpublish</button>
111
+ <button class="btn btn-ghost btn-sm bulk-trash" id="bulk-restore" style="display:none">Restore</button>
110
112
  <span class="bulk-bar-spacer"></span>
111
- <button class="btn btn-danger btn-sm" id="bulk-delete">Delete selected</button>
113
+ <button class="btn btn-danger btn-sm bulk-normal" id="bulk-delete">Move to Trash</button>
114
+ <button class="btn btn-danger btn-sm bulk-trash" id="bulk-permanent" style="display:none">Delete forever</button>
112
115
  </div>
113
116
 
114
117
  <div class="table-wrap" id="entries-wrap">
@@ -140,7 +143,10 @@
140
143
  let selected = new Set();
141
144
 
142
145
  function updateBulkBar() {
143
- const bar = document.getElementById('bulk-bar');
146
+ const bar = document.getElementById('bulk-bar');
147
+ const trash = activeFilter === 'trash';
148
+ document.querySelectorAll('.bulk-normal').forEach(el => el.style.display = trash ? 'none' : '');
149
+ document.querySelectorAll('.bulk-trash').forEach(el => el.style.display = trash ? '' : 'none');
144
150
  if (selected.size > 0) {
145
151
  bar.classList.add('visible');
146
152
  document.getElementById('bulk-count').textContent = selected.size;
@@ -165,6 +171,7 @@
165
171
  wrap.innerHTML = '<div class="empty"><div class="empty-icon">◈</div>No entries yet</div>';
166
172
  return;
167
173
  }
174
+ const isTrash = activeFilter === 'trash';
168
175
  const canSort = !activeFilter;
169
176
  wrap.innerHTML = `
170
177
  <table>
@@ -172,13 +179,14 @@
172
179
  <tr>
173
180
  ${canSort ? '<th class="drag-col"></th>' : ''}
174
181
  <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>
182
+ <th>Title / Slug</th><th>${isTrash ? 'Deleted' : 'Status'}</th><th>${isTrash ? '' : 'Updated'}</th><th></th>
176
183
  </tr>
177
184
  </thead>
178
185
  <tbody>
179
186
  ${entries.map(e => {
180
187
  const title = e.data?.title || e.slug;
181
188
  const updated = e.updated_at ? e.updated_at.split(' ')[0] : '—';
189
+ const deleted = e.deleted_at ? e.deleted_at.split(' ')[0] : '—';
182
190
  const nextStatus = e.status === 'published' ? 'draft' : 'published';
183
191
  const toggleLabel = e.status === 'published' ? 'Unpublish' : 'Publish';
184
192
  return `<tr data-slug="${e.slug}"${canSort ? ' draggable="true"' : ''}>
@@ -188,14 +196,18 @@
188
196
  <div style="color:var(--heading);font-weight:500">${title}</div>
189
197
  <div style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-top:2px">${e.slug}</div>
190
198
  </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>
199
+ <td>${isTrash ? `<span style="font-family:var(--mono);font-size:11px;color:var(--muted)">${deleted}</span>` : `<span class="badge badge-${e.status}">${e.status}</span>`}</td>
200
+ <td style="font-family:var(--mono);font-size:11px;color:var(--muted)">${isTrash ? '' : updated}</td>
193
201
  <td style="width:1%;white-space:nowrap">
194
202
  <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>
203
+ ${isTrash
204
+ ? `<button class="btn-row restore-btn" data-slug="${e.slug}">Restore</button>
205
+ <button class="btn-row btn-row-danger perm-del-btn" data-slug="${e.slug}">Delete forever</button>`
206
+ : `<a class="btn-row" href="/editor.html?collection=${colId}&slug=${e.slug}">Edit</a>
207
+ <button class="btn-row btn-row-toggle status-toggle" data-slug="${e.slug}" data-next="${nextStatus}">${toggleLabel}</button>
208
+ <button class="btn-row btn-row-icon dup-btn" data-slug="${e.slug}" title="Duplicate">⧉</button>
209
+ <button class="btn-row btn-row-danger delete-btn" data-slug="${e.slug}">Trash</button>`
210
+ }
199
211
  </div>
200
212
  </td>
201
213
  </tr>`;
@@ -288,16 +300,36 @@
288
300
  });
289
301
  });
290
302
 
291
- // Delete
303
+ // Delete → Trash
292
304
  wrap.querySelectorAll('.delete-btn').forEach(btn => {
293
305
  btn.addEventListener('click', async () => {
294
- if (!confirm(`Delete "${btn.dataset.slug}"?`)) return;
295
306
  await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}`, {
296
307
  method: 'DELETE', credentials: 'include',
297
308
  });
298
309
  loadEntries();
299
310
  });
300
311
  });
312
+
313
+ // Restore from Trash
314
+ wrap.querySelectorAll('.restore-btn').forEach(btn => {
315
+ btn.addEventListener('click', async () => {
316
+ await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}/restore`, {
317
+ method: 'POST', credentials: 'include',
318
+ });
319
+ loadEntries();
320
+ });
321
+ });
322
+
323
+ // Permanent delete
324
+ wrap.querySelectorAll('.perm-del-btn').forEach(btn => {
325
+ btn.addEventListener('click', async () => {
326
+ if (!confirm(`Permanently delete "${btn.dataset.slug}"? This cannot be undone.`)) return;
327
+ await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}/permanent`, {
328
+ method: 'DELETE', credentials: 'include',
329
+ });
330
+ loadEntries();
331
+ });
332
+ });
301
333
  }
302
334
 
303
335
  // Filter buttons
@@ -322,9 +354,15 @@
322
354
  });
323
355
  loadEntries();
324
356
  }
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'));
357
+ document.getElementById('bulk-publish').addEventListener('click', () => bulkAction('publish'));
358
+ document.getElementById('bulk-draft').addEventListener('click', () => bulkAction('draft'));
359
+ document.getElementById('bulk-delete').addEventListener('click', () => bulkAction('delete'));
360
+ document.getElementById('bulk-restore').addEventListener('click', () => bulkAction('restore'));
361
+ document.getElementById('bulk-permanent').addEventListener('click', async () => {
362
+ const n = selected.size;
363
+ if (!confirm(`Permanently delete ${n} entr${n !== 1 ? 'ies' : 'y'}? This cannot be undone.`)) return;
364
+ await bulkAction('permanent');
365
+ });
328
366
 
329
367
  // New entry modal
330
368
  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 () => {
@@ -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,15 @@ 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 {
103
+ db.logAudit(updated.id, username, 'update');
104
+ }
95
105
  db.close();
96
106
 
97
107
  if (body.status === 'published' && before?.status !== 'published') {
@@ -100,16 +110,51 @@ entryRoutes.put('/:collectionId/entries/:slug', async (c) => {
100
110
  return c.json(updated);
101
111
  });
102
112
 
103
- // DELETE /api/collections/:id/entries/:slug
113
+ // DELETE /api/collections/:id/entries/:slug → soft delete (trash)
104
114
  entryRoutes.delete('/:collectionId/entries/:slug', (c) => {
115
+ const { collectionId, slug } = c.req.param();
116
+ const db = openPod(c.get('podPath'));
117
+ const entry = db.getEntry(collectionId, slug);
118
+ if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
119
+ db.deleteEntry(collectionId, slug);
120
+ db.logAudit(entry.id, c.get('user')?.username ?? 'unknown', 'delete');
121
+ db.close();
122
+ return c.json({ ok: true });
123
+ });
124
+
125
+ // POST /api/collections/:id/entries/:slug/restore — move from trash back to draft
126
+ entryRoutes.post('/:collectionId/entries/:slug/restore', (c) => {
127
+ const { collectionId, slug } = c.req.param();
128
+ const db = openPod(c.get('podPath'));
129
+ const row = db.db.prepare('SELECT * FROM _entries WHERE collection_id = ? AND slug = ? AND deleted_at IS NOT NULL').get(collectionId, slug);
130
+ const ok = db.restoreEntry(collectionId, slug);
131
+ if (row) db.logAudit(row.id, c.get('user')?.username ?? 'unknown', 'restore');
132
+ db.close();
133
+ if (!ok) return c.json({ error: 'Not found in trash' }, 404);
134
+ return c.json({ ok: true });
135
+ });
136
+
137
+ // DELETE /api/collections/:id/entries/:slug/permanent — hard delete from trash
138
+ entryRoutes.delete('/:collectionId/entries/:slug/permanent', (c) => {
105
139
  const { collectionId, slug } = c.req.param();
106
140
  const db = openPod(c.get('podPath'));
107
- const ok = db.deleteEntry(collectionId, slug);
141
+ const ok = db.permanentDeleteEntry(collectionId, slug);
108
142
  db.close();
109
143
  if (!ok) return c.json({ error: 'Not found' }, 404);
110
144
  return c.json({ ok: true });
111
145
  });
112
146
 
147
+ // GET /api/collections/:id/entries/:slug/activity
148
+ entryRoutes.get('/:collectionId/entries/:slug/activity', (c) => {
149
+ const { collectionId, slug } = c.req.param();
150
+ const db = openPod(c.get('podPath'));
151
+ const entry = db.db.prepare('SELECT * FROM _entries WHERE collection_id = ? AND slug = ?').get(collectionId, slug);
152
+ if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
153
+ const log = db.getAuditLog(entry.id);
154
+ db.close();
155
+ return c.json(log);
156
+ });
157
+
113
158
  // GET /api/collections/:id/entries/:slug/versions
114
159
  entryRoutes.get('/:collectionId/entries/:slug/versions', (c) => {
115
160
  const { collectionId, slug } = c.req.param();
@@ -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',