@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 +1 -1
- package/public/editor.html +75 -20
- package/public/entries.html +58 -18
- package/public/settings.html +19 -0
- package/public/style.css +3 -2
- package/src/routes/entries.js +61 -10
- package/src/routes/meta.js +1 -1
- package/src/server.js +21 -0
package/package.json
CHANGED
package/public/editor.html
CHANGED
|
@@ -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
|
|
105
|
-
.status-dot.draft
|
|
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
|
|
108
|
-
.status-lbl.draft
|
|
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
|
|
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
|
-
|
|
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
|
|
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-
|
|
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')
|
|
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();
|
package/public/entries.html
CHANGED
|
@@ -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">
|
|
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
|
|
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
|
|
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
|
|
183
|
-
const
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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',
|
|
326
|
-
document.getElementById('bulk-draft').addEventListener('click',
|
|
327
|
-
document.getElementById('bulk-delete').addEventListener('click',
|
|
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');
|
package/public/settings.html
CHANGED
|
@@ -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
|
|
940
|
-
.badge-draft
|
|
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; }
|
package/src/routes/entries.js
CHANGED
|
@@ -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))
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 });
|
package/src/routes/meta.js
CHANGED
|
@@ -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);
|