@a83/orbiter-admin 0.3.9 → 0.3.11
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 +69 -17
- package/public/entries.html +33 -4
- package/public/style.css +3 -2
- package/src/routes/entries.js +95 -3
- 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; }
|
|
@@ -539,8 +545,10 @@
|
|
|
539
545
|
|
|
540
546
|
function renderMetaPanel() {
|
|
541
547
|
const panel = document.getElementById('meta-panel');
|
|
542
|
-
const status
|
|
548
|
+
const status = IS_NEW ? 'draft' : (entryData?.status ?? 'draft');
|
|
543
549
|
const updatedAt = entryData?.updated_at;
|
|
550
|
+
const publishAt = entryData?.publish_at ?? null;
|
|
551
|
+
const publishAtInput = publishAt ? publishAt.replace(' ', 'T').slice(0, 16) : '';
|
|
544
552
|
|
|
545
553
|
let fieldsHtml = '';
|
|
546
554
|
const seoFields = {};
|
|
@@ -625,10 +633,11 @@
|
|
|
625
633
|
}
|
|
626
634
|
|
|
627
635
|
const versHtml = versionsData.slice(0,8).map((v,i)=>`
|
|
628
|
-
<div class="version-row">
|
|
636
|
+
<div class="version-row" data-vid="${v.id}">
|
|
629
637
|
<div class="v-dot${i===0?' cur':''}"></div>
|
|
630
638
|
<div class="v-hash">${v.id.slice(0,7)}</div>
|
|
631
639
|
<div class="v-time">${new Date(v.created_at).toLocaleDateString()}</div>
|
|
640
|
+
${i===0 ? '' : `<button class="btn-row v-restore-btn" data-vid="${v.id}" style="margin-left:auto;font-size:9px;padding:2px 6px">Restore</button>`}
|
|
632
641
|
</div>
|
|
633
642
|
`).join('');
|
|
634
643
|
|
|
@@ -653,10 +662,16 @@
|
|
|
653
662
|
<div class="meta-section">
|
|
654
663
|
<div class="status-bar">
|
|
655
664
|
<div class="status-dot ${status}" id="status-dot"></div>
|
|
656
|
-
<div class="status-lbl ${status}" id="status-lbl">${status==='published'?'Published':'Draft'}</div>
|
|
665
|
+
<div class="status-lbl ${status}" id="status-lbl">${status==='published'?'Published':status==='scheduled'?'Scheduled':'Draft'}</div>
|
|
666
|
+
${status==='scheduled'&&publishAt?`<span style="font-size:10px;color:var(--muted);margin-left:auto">${new Date(publishAt.replace(' ','T')).toLocaleDateString()}</span>`:''}
|
|
657
667
|
</div>
|
|
658
|
-
<button type="button" class="btn-publish" id="btn-publish"
|
|
659
|
-
<button type="button" class="btn-draft" id="btn-draft"
|
|
668
|
+
<button type="button" class="btn-publish" id="btn-publish">${status==='published'?'Republish':'Publish now'}</button>
|
|
669
|
+
<button type="button" class="btn-draft" id="btn-draft">${status==='scheduled'?'Cancel schedule':'Save as draft'}</button>
|
|
670
|
+
<div class="schedule-picker${status==='scheduled'?' open':''}" id="schedule-picker">
|
|
671
|
+
<input type="datetime-local" class="field-input" id="publish-at-input" value="${publishAtInput}" style="flex:1;font-size:10px;padding:5px 8px;" />
|
|
672
|
+
<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>
|
|
673
|
+
</div>
|
|
674
|
+
<button type="button" class="btn-schedule" id="btn-schedule">${status==='scheduled'?'Reschedule':'Schedule'}</button>
|
|
660
675
|
</div>
|
|
661
676
|
<div class="meta-section">
|
|
662
677
|
<div class="meta-label">Details</div>
|
|
@@ -669,6 +684,7 @@
|
|
|
669
684
|
<select class="field-select" id="status-select">
|
|
670
685
|
<option value="draft" ${status==='draft' ?'selected':''}>Draft</option>
|
|
671
686
|
<option value="published" ${status==='published' ?'selected':''}>Published</option>
|
|
687
|
+
<option value="scheduled" ${status==='scheduled' ?'selected':''}>Scheduled</option>
|
|
672
688
|
</select>
|
|
673
689
|
</div>
|
|
674
690
|
${updatedAt ? `<div class="meta-field"><div class="field-label">Modified</div><div class="field-readonly">${new Date(updatedAt).toLocaleDateString()}</div></div>` : ''}
|
|
@@ -729,9 +745,30 @@
|
|
|
729
745
|
});
|
|
730
746
|
});
|
|
731
747
|
|
|
732
|
-
// Publish / draft buttons
|
|
733
|
-
document.getElementById('btn-publish').addEventListener('click',()=>saveEntry('published'));
|
|
734
|
-
document.getElementById('btn-draft').addEventListener('click',()=>saveEntry('draft'));
|
|
748
|
+
// Publish / draft / schedule buttons
|
|
749
|
+
document.getElementById('btn-publish').addEventListener('click', () => saveEntry('published'));
|
|
750
|
+
document.getElementById('btn-draft').addEventListener('click', () => saveEntry('draft'));
|
|
751
|
+
document.getElementById('btn-schedule').addEventListener('click', () => {
|
|
752
|
+
document.getElementById('schedule-picker').classList.toggle('open');
|
|
753
|
+
});
|
|
754
|
+
document.getElementById('btn-schedule-confirm').addEventListener('click', () => {
|
|
755
|
+
const val = document.getElementById('publish-at-input').value;
|
|
756
|
+
if (!val) { alert('Please select a date and time.'); return; }
|
|
757
|
+
saveEntry('scheduled');
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
// Version restore buttons
|
|
761
|
+
panel.querySelectorAll('.v-restore-btn').forEach(btn => {
|
|
762
|
+
btn.addEventListener('click', async () => {
|
|
763
|
+
if (!confirm('Restore this version? Current content will be overwritten.')) return;
|
|
764
|
+
const vid = btn.dataset.vid;
|
|
765
|
+
const targetSlug = currentSlug || SLUG;
|
|
766
|
+
const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}/versions/${vid}/restore`, {
|
|
767
|
+
method: 'POST', credentials: 'include',
|
|
768
|
+
});
|
|
769
|
+
if (res.ok) { location.reload(); } else { alert('Restore failed.'); }
|
|
770
|
+
});
|
|
771
|
+
});
|
|
735
772
|
|
|
736
773
|
// Slug input → slug preview
|
|
737
774
|
document.getElementById('slug-input').addEventListener('input', e=>{
|
|
@@ -802,8 +839,15 @@
|
|
|
802
839
|
function updateStatusUI(status) {
|
|
803
840
|
document.getElementById('status-dot').className = 'status-dot ' + status;
|
|
804
841
|
document.getElementById('status-lbl').className = 'status-lbl ' + status;
|
|
805
|
-
document.getElementById('status-lbl').textContent = status==='published'?'Published':'Draft';
|
|
806
|
-
document.getElementById('status-select')
|
|
842
|
+
document.getElementById('status-lbl').textContent = status === 'published' ? 'Published' : status === 'scheduled' ? 'Scheduled' : 'Draft';
|
|
843
|
+
const sel = document.getElementById('status-select');
|
|
844
|
+
if (sel) sel.value = status;
|
|
845
|
+
const publishBtn = document.getElementById('btn-publish');
|
|
846
|
+
const draftBtn = document.getElementById('btn-draft');
|
|
847
|
+
const schedBtn = document.getElementById('btn-schedule');
|
|
848
|
+
if (publishBtn) publishBtn.textContent = status === 'published' ? 'Republish' : 'Publish now';
|
|
849
|
+
if (draftBtn) draftBtn.textContent = status === 'scheduled' ? 'Cancel schedule' : 'Save as draft';
|
|
850
|
+
if (schedBtn) schedBtn.textContent = status === 'scheduled' ? 'Reschedule' : 'Schedule';
|
|
807
851
|
}
|
|
808
852
|
|
|
809
853
|
function renderTags(key) {
|
|
@@ -880,6 +924,14 @@
|
|
|
880
924
|
let currentPath = location.pathname + location.search;
|
|
881
925
|
|
|
882
926
|
async function saveEntry(status, isAutosave=false) {
|
|
927
|
+
// Resolve publish_at for scheduled entries
|
|
928
|
+
let publish_at = null;
|
|
929
|
+
if (status === 'scheduled') {
|
|
930
|
+
const raw = document.getElementById('publish-at-input')?.value;
|
|
931
|
+
if (!raw) { if (!isAutosave) alert('Please select a date and time.'); return; }
|
|
932
|
+
publish_at = raw.replace('T', ' ') + ':00';
|
|
933
|
+
}
|
|
934
|
+
|
|
883
935
|
const title = document.getElementById('title-input').value;
|
|
884
936
|
syncToHidden();
|
|
885
937
|
const body = document.getElementById('body-input').value;
|
|
@@ -907,7 +959,7 @@
|
|
|
907
959
|
const res = await fetch(`/api/collections/${COLLECTION}/entries`,{
|
|
908
960
|
method:'POST', credentials:'include',
|
|
909
961
|
headers:{'Content-Type':'application/json'},
|
|
910
|
-
body: JSON.stringify({ slug, data, status }),
|
|
962
|
+
body: JSON.stringify({ slug, data, status, publish_at }),
|
|
911
963
|
});
|
|
912
964
|
const json = await res.json();
|
|
913
965
|
if (json.slug || json.id) {
|
|
@@ -926,7 +978,7 @@
|
|
|
926
978
|
const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}`,{
|
|
927
979
|
method:'PUT', credentials:'include',
|
|
928
980
|
headers:{'Content-Type':'application/json'},
|
|
929
|
-
body: JSON.stringify({ slug, data, status }),
|
|
981
|
+
body: JSON.stringify({ slug, data, status, publish_at }),
|
|
930
982
|
});
|
|
931
983
|
if (res.ok) {
|
|
932
984
|
const json = await res.json();
|
package/public/entries.html
CHANGED
|
@@ -90,7 +90,11 @@
|
|
|
90
90
|
<h1 class="page-title" id="page-title">Entries</h1>
|
|
91
91
|
<p class="page-sub" id="page-sub"></p>
|
|
92
92
|
</div>
|
|
93
|
-
<
|
|
93
|
+
<div style="display:flex;gap:6px;align-items:center;flex-shrink:0;margin-top:4px">
|
|
94
|
+
<a id="export-btn" href="#" class="btn btn-ghost btn-sm" title="Export as CSV">↓ CSV</a>
|
|
95
|
+
<label class="btn btn-ghost btn-sm" style="cursor:pointer" title="Import from CSV">↑ CSV<input type="file" id="import-file" accept=".csv" style="display:none" /></label>
|
|
96
|
+
<button class="btn btn-primary" id="new-btn">+ New entry</button>
|
|
97
|
+
</div>
|
|
94
98
|
</div>
|
|
95
99
|
|
|
96
100
|
<!-- Filter + table glass card -->
|
|
@@ -100,6 +104,7 @@
|
|
|
100
104
|
<button class="filter-tab active" data-status="">All</button>
|
|
101
105
|
<button class="filter-tab" data-status="published">Published</button>
|
|
102
106
|
<button class="filter-tab" data-status="draft">Drafts</button>
|
|
107
|
+
<button class="filter-tab" data-status="scheduled">Scheduled</button>
|
|
103
108
|
<button class="filter-tab" data-status="trash" style="margin-left:auto">🗑 Trash</button>
|
|
104
109
|
</div>
|
|
105
110
|
|
|
@@ -187,8 +192,9 @@
|
|
|
187
192
|
const title = e.data?.title || e.slug;
|
|
188
193
|
const updated = e.updated_at ? e.updated_at.split(' ')[0] : '—';
|
|
189
194
|
const deleted = e.deleted_at ? e.deleted_at.split(' ')[0] : '—';
|
|
190
|
-
const nextStatus
|
|
191
|
-
const toggleLabel = e.status === 'published' ? 'Unpublish' : 'Publish';
|
|
195
|
+
const nextStatus = e.status === 'published' ? 'draft' : 'published';
|
|
196
|
+
const toggleLabel = e.status === 'published' ? 'Unpublish' : e.status === 'scheduled' ? 'Publish now' : 'Publish';
|
|
197
|
+
const schedInfo = e.status === 'scheduled' && e.publish_at ? ` · ${e.publish_at.split(' ')[0]}` : '';
|
|
192
198
|
return `<tr data-slug="${e.slug}"${canSort ? ' draggable="true"' : ''}>
|
|
193
199
|
${canSort ? `<td class="drag-col"><span class="drag-handle" title="Drag to reorder">⠿</span></td>` : ''}
|
|
194
200
|
<td class="cb-col"><input type="checkbox" class="row-cb" data-slug="${e.slug}" ${selected.has(e.slug) ? 'checked' : ''} /></td>
|
|
@@ -196,7 +202,7 @@
|
|
|
196
202
|
<div style="color:var(--heading);font-weight:500">${title}</div>
|
|
197
203
|
<div style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-top:2px">${e.slug}</div>
|
|
198
204
|
</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>
|
|
205
|
+
<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>
|
|
200
206
|
<td style="font-family:var(--mono);font-size:11px;color:var(--muted)">${isTrash ? '' : updated}</td>
|
|
201
207
|
<td style="width:1%;white-space:nowrap">
|
|
202
208
|
<div class="row-actions">
|
|
@@ -364,6 +370,29 @@
|
|
|
364
370
|
await bulkAction('permanent');
|
|
365
371
|
});
|
|
366
372
|
|
|
373
|
+
// CSV Export
|
|
374
|
+
document.getElementById('export-btn').href = `/api/collections/${colId}/entries/export.csv`;
|
|
375
|
+
|
|
376
|
+
// CSV Import
|
|
377
|
+
document.getElementById('import-file').addEventListener('change', async e => {
|
|
378
|
+
const file = e.target.files[0];
|
|
379
|
+
if (!file) return;
|
|
380
|
+
const text = await file.text();
|
|
381
|
+
const res = await fetch(`/api/collections/${colId}/entries/import.csv`, {
|
|
382
|
+
method: 'POST', credentials: 'include',
|
|
383
|
+
headers: { 'Content-Type': 'text/csv' },
|
|
384
|
+
body: text,
|
|
385
|
+
});
|
|
386
|
+
const json = await res.json();
|
|
387
|
+
if (res.ok) {
|
|
388
|
+
alert(`Import complete: ${json.created} created, ${json.updated} updated${json.skipped ? ', ' + json.skipped + ' skipped' : ''}.`);
|
|
389
|
+
renderEntries();
|
|
390
|
+
} else {
|
|
391
|
+
alert('Import failed: ' + (json.error ?? 'Unknown error'));
|
|
392
|
+
}
|
|
393
|
+
e.target.value = '';
|
|
394
|
+
});
|
|
395
|
+
|
|
367
396
|
// New entry modal
|
|
368
397
|
const overlay = document.getElementById('modal-overlay');
|
|
369
398
|
document.getElementById('new-btn').addEventListener('click', () => {
|
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
|
@@ -99,6 +99,8 @@ entryRoutes.put('/:collectionId/entries/:slug', async (c) => {
|
|
|
99
99
|
db.logAudit(updated.id, username, 'publish');
|
|
100
100
|
} else if (body.status === 'draft' && before?.status === 'published') {
|
|
101
101
|
db.logAudit(updated.id, username, 'unpublish');
|
|
102
|
+
} else if (body.status === 'scheduled' && before?.status !== 'scheduled') {
|
|
103
|
+
db.logAudit(updated.id, username, 'schedule');
|
|
102
104
|
} else {
|
|
103
105
|
db.logAudit(updated.id, username, 'update');
|
|
104
106
|
}
|
|
@@ -168,6 +170,92 @@ entryRoutes.get('/:collectionId/entries/:slug/versions', (c) => {
|
|
|
168
170
|
return c.json(versions);
|
|
169
171
|
});
|
|
170
172
|
|
|
173
|
+
// POST /api/collections/:id/entries/:slug/versions/:versionId/restore
|
|
174
|
+
entryRoutes.post('/:collectionId/entries/:slug/versions/:versionId/restore', (c) => {
|
|
175
|
+
const { collectionId, slug, versionId } = c.req.param();
|
|
176
|
+
const db = openPod(c.get('podPath'));
|
|
177
|
+
const entry = db.getEntry(collectionId, slug);
|
|
178
|
+
if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
179
|
+
const ok = db.restoreVersion(entry.id, versionId);
|
|
180
|
+
if (ok) db.logAudit(entry.id, c.get('user')?.username ?? 'unknown', 'restore_version');
|
|
181
|
+
db.close();
|
|
182
|
+
if (!ok) return c.json({ error: 'Version not found' }, 404);
|
|
183
|
+
return c.json({ ok: true });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// GET /api/collections/:id/entries/export.csv
|
|
187
|
+
entryRoutes.get('/:collectionId/entries/export.csv', (c) => {
|
|
188
|
+
const { collectionId } = c.req.param();
|
|
189
|
+
const db = openPod(c.get('podPath'));
|
|
190
|
+
const col = db.getCollection(collectionId);
|
|
191
|
+
if (!col) { db.close(); return c.json({ error: 'Collection not found' }, 404); }
|
|
192
|
+
const entries = db.getEntries(collectionId);
|
|
193
|
+
const schema = col.schema ? JSON.parse(col.schema) : {};
|
|
194
|
+
const fields = Object.keys(schema);
|
|
195
|
+
const headers = ['slug', 'status', ...fields];
|
|
196
|
+
const csvEsc = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
|
197
|
+
const rows = [headers.map(csvEsc).join(',')];
|
|
198
|
+
for (const e of entries) {
|
|
199
|
+
rows.push(headers.map(h => {
|
|
200
|
+
if (h === 'slug') return csvEsc(e.slug);
|
|
201
|
+
if (h === 'status') return csvEsc(e.status);
|
|
202
|
+
const v = e.data[h];
|
|
203
|
+
return csvEsc(Array.isArray(v) ? v.join(';') : v);
|
|
204
|
+
}).join(','));
|
|
205
|
+
}
|
|
206
|
+
db.close();
|
|
207
|
+
return new Response(rows.join('\n'), {
|
|
208
|
+
headers: {
|
|
209
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
210
|
+
'Content-Disposition': `attachment; filename="${collectionId}.csv"`,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// POST /api/collections/:id/entries/import.csv
|
|
216
|
+
entryRoutes.post('/:collectionId/entries/import.csv', async (c) => {
|
|
217
|
+
const { collectionId } = c.req.param();
|
|
218
|
+
const db = openPod(c.get('podPath'));
|
|
219
|
+
if (!db.getCollection(collectionId)) { db.close(); return c.json({ error: 'Collection not found' }, 404); }
|
|
220
|
+
const text = await c.req.text();
|
|
221
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
222
|
+
if (lines.length < 2) { db.close(); return c.json({ error: 'Empty CSV' }, 400); }
|
|
223
|
+
const parseCsv = line => {
|
|
224
|
+
const cols = []; let cur = ''; let inQ = false;
|
|
225
|
+
for (let i = 0; i < line.length; i++) {
|
|
226
|
+
const ch = line[i];
|
|
227
|
+
if (ch === '"' && !inQ) { inQ = true; continue; }
|
|
228
|
+
if (ch === '"' && inQ && line[i+1] === '"') { cur += '"'; i++; continue; }
|
|
229
|
+
if (ch === '"' && inQ) { inQ = false; continue; }
|
|
230
|
+
if (ch === ',' && !inQ) { cols.push(cur); cur = ''; continue; }
|
|
231
|
+
cur += ch;
|
|
232
|
+
}
|
|
233
|
+
cols.push(cur);
|
|
234
|
+
return cols;
|
|
235
|
+
};
|
|
236
|
+
const headers = parseCsv(lines[0]);
|
|
237
|
+
let created = 0, updated = 0, skipped = 0;
|
|
238
|
+
for (const line of lines.slice(1)) {
|
|
239
|
+
const vals = parseCsv(line);
|
|
240
|
+
const row = Object.fromEntries(headers.map((h, i) => [h, vals[i] ?? '']));
|
|
241
|
+
if (!row.slug) { skipped++; continue; }
|
|
242
|
+
const data = {};
|
|
243
|
+
for (const h of headers.filter(h => h !== 'slug' && h !== 'status')) {
|
|
244
|
+
data[h] = row[h];
|
|
245
|
+
}
|
|
246
|
+
const status = ['draft','published'].includes(row.status) ? row.status : 'draft';
|
|
247
|
+
if (db.getEntry(collectionId, row.slug)) {
|
|
248
|
+
db.updateEntry(collectionId, row.slug, { slug: row.slug, data, status });
|
|
249
|
+
updated++;
|
|
250
|
+
} else {
|
|
251
|
+
db.createEntry(collectionId, row.slug, data, status);
|
|
252
|
+
created++;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
db.close();
|
|
256
|
+
return c.json({ ok: true, created, updated, skipped });
|
|
257
|
+
});
|
|
258
|
+
|
|
171
259
|
// POST /api/collections/:id/entries/:slug/duplicate
|
|
172
260
|
entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
|
|
173
261
|
const { collectionId, slug } = c.req.param();
|
|
@@ -186,12 +274,16 @@ entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
|
|
|
186
274
|
// PATCH /api/collections/:id/entries/:slug/status
|
|
187
275
|
entryRoutes.patch('/:collectionId/entries/:slug/status', async (c) => {
|
|
188
276
|
const { collectionId, slug } = c.req.param();
|
|
189
|
-
const { status } = await c.req.json();
|
|
190
|
-
if (!['draft', 'published'].includes(status)) return c.json({ error: 'Invalid status' }, 400);
|
|
277
|
+
const { status, publish_at } = await c.req.json();
|
|
278
|
+
if (!['draft', 'published', 'scheduled'].includes(status)) return c.json({ error: 'Invalid status' }, 400);
|
|
279
|
+
if (status === 'scheduled' && !publish_at) return c.json({ error: 'publish_at required for scheduled status' }, 400);
|
|
191
280
|
const db = openPod(c.get('podPath'));
|
|
192
281
|
const entry = db.getEntry(collectionId, slug);
|
|
193
282
|
if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
194
|
-
|
|
283
|
+
const pa = status === 'scheduled' ? publish_at : null;
|
|
284
|
+
db.updateEntry(collectionId, slug, { slug, data: entry.data, status, publish_at: pa });
|
|
285
|
+
const username = c.get('user')?.username ?? 'unknown';
|
|
286
|
+
if (status === 'scheduled') db.logAudit(entry.id, username, 'schedule');
|
|
195
287
|
db.close();
|
|
196
288
|
if (status === 'published') fireWebhook(c.get('podPath'));
|
|
197
289
|
return c.json({ ok: true });
|
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);
|