@a83/orbiter-admin 0.3.9 โ†’ 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.9",
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; }
@@ -539,8 +545,10 @@
539
545
 
540
546
  function renderMetaPanel() {
541
547
  const panel = document.getElementById('meta-panel');
542
- const status = IS_NEW ? 'draft' : (entryData?.status ?? 'draft');
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 = {};
@@ -653,10 +661,16 @@
653
661
  <div class="meta-section">
654
662
  <div class="status-bar">
655
663
  <div class="status-dot ${status}" id="status-dot"></div>
656
- <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>`:''}
657
666
  </div>
658
- <button type="button" class="btn-publish" id="btn-publish">Publish</button>
659
- <button type="button" class="btn-draft" id="btn-draft">Save as draft</button>
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>
672
+ </div>
673
+ <button type="button" class="btn-schedule" id="btn-schedule">${status==='scheduled'?'Reschedule':'Schedule'}</button>
660
674
  </div>
661
675
  <div class="meta-section">
662
676
  <div class="meta-label">Details</div>
@@ -669,6 +683,7 @@
669
683
  <select class="field-select" id="status-select">
670
684
  <option value="draft" ${status==='draft' ?'selected':''}>Draft</option>
671
685
  <option value="published" ${status==='published' ?'selected':''}>Published</option>
686
+ <option value="scheduled" ${status==='scheduled' ?'selected':''}>Scheduled</option>
672
687
  </select>
673
688
  </div>
674
689
  ${updatedAt ? `<div class="meta-field"><div class="field-label">Modified</div><div class="field-readonly">${new Date(updatedAt).toLocaleDateString()}</div></div>` : ''}
@@ -729,9 +744,17 @@
729
744
  });
730
745
  });
731
746
 
732
- // Publish / draft buttons
733
- document.getElementById('btn-publish').addEventListener('click',()=>saveEntry('published'));
734
- 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
+ });
735
758
 
736
759
  // Slug input โ†’ slug preview
737
760
  document.getElementById('slug-input').addEventListener('input', e=>{
@@ -802,8 +825,15 @@
802
825
  function updateStatusUI(status) {
803
826
  document.getElementById('status-dot').className = 'status-dot ' + status;
804
827
  document.getElementById('status-lbl').className = 'status-lbl ' + status;
805
- document.getElementById('status-lbl').textContent = status==='published'?'Published':'Draft';
806
- 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';
807
837
  }
808
838
 
809
839
  function renderTags(key) {
@@ -880,6 +910,14 @@
880
910
  let currentPath = location.pathname + location.search;
881
911
 
882
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
+
883
921
  const title = document.getElementById('title-input').value;
884
922
  syncToHidden();
885
923
  const body = document.getElementById('body-input').value;
@@ -907,7 +945,7 @@
907
945
  const res = await fetch(`/api/collections/${COLLECTION}/entries`,{
908
946
  method:'POST', credentials:'include',
909
947
  headers:{'Content-Type':'application/json'},
910
- body: JSON.stringify({ slug, data, status }),
948
+ body: JSON.stringify({ slug, data, status, publish_at }),
911
949
  });
912
950
  const json = await res.json();
913
951
  if (json.slug || json.id) {
@@ -926,7 +964,7 @@
926
964
  const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}`,{
927
965
  method:'PUT', credentials:'include',
928
966
  headers:{'Content-Type':'application/json'},
929
- body: JSON.stringify({ slug, data, status }),
967
+ body: JSON.stringify({ slug, data, status, publish_at }),
930
968
  });
931
969
  if (res.ok) {
932
970
  const json = await res.json();
@@ -100,6 +100,7 @@
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>
103
104
  <button class="filter-tab" data-status="trash" style="margin-left:auto">๐Ÿ—‘ Trash</button>
104
105
  </div>
105
106
 
@@ -187,8 +188,9 @@
187
188
  const title = e.data?.title || e.slug;
188
189
  const updated = e.updated_at ? e.updated_at.split(' ')[0] : 'โ€”';
189
190
  const deleted = e.deleted_at ? e.deleted_at.split(' ')[0] : 'โ€”';
190
- const nextStatus = e.status === 'published' ? 'draft' : 'published';
191
- const toggleLabel = e.status === 'published' ? 'Unpublish' : 'Publish';
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]}` : '';
192
194
  return `<tr data-slug="${e.slug}"${canSort ? ' draggable="true"' : ''}>
193
195
  ${canSort ? `<td class="drag-col"><span class="drag-handle" title="Drag to reorder">โ ฟ</span></td>` : ''}
194
196
  <td class="cb-col"><input type="checkbox" class="row-cb" data-slug="${e.slug}" ${selected.has(e.slug) ? 'checked' : ''} /></td>
@@ -196,7 +198,7 @@
196
198
  <div style="color:var(--heading);font-weight:500">${title}</div>
197
199
  <div style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-top:2px">${e.slug}</div>
198
200
  </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>
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>
200
202
  <td style="font-family:var(--mono);font-size:11px;color:var(--muted)">${isTrash ? '' : updated}</td>
201
203
  <td style="width:1%;white-space:nowrap">
202
204
  <div class="row-actions">
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; }
@@ -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
  }
@@ -186,12 +188,16 @@ entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
186
188
  // PATCH /api/collections/:id/entries/:slug/status
187
189
  entryRoutes.patch('/:collectionId/entries/:slug/status', async (c) => {
188
190
  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);
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);
191
194
  const db = openPod(c.get('podPath'));
192
195
  const entry = db.getEntry(collectionId, slug);
193
196
  if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
194
- 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');
195
201
  db.close();
196
202
  if (status === 'published') fireWebhook(c.get('podPath'));
197
203
  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);