@a83/orbiter-admin 0.3.12 → 0.3.13

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.12",
3
+ "version": "0.3.13",
4
4
  "description": "Standalone admin server for Orbiter CMS",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
@@ -116,6 +116,10 @@
116
116
  .btn-schedule:hover { background:var(--accent-bg); }
117
117
  .schedule-picker { display:none; gap:4px; margin-bottom:5px; }
118
118
  .schedule-picker.open { display:flex; }
119
+ .expiry-picker { display:none; gap:4px; margin-bottom:5px; }
120
+ .expiry-picker.open { display:flex; }
121
+ .btn-expiry { display:block; width:100%; padding:8px; background:transparent; border:1px solid var(--line); color:var(--muted); font-family:var(--mono); font-size:10px; cursor:pointer; transition:all .12s; border-radius:var(--radius); margin-bottom:5px; }
122
+ .btn-expiry:hover { border-color:var(--mid); color:var(--text); }
119
123
 
120
124
  /* Schema fields in sidebar */
121
125
  .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; }
@@ -399,6 +403,7 @@
399
403
  <button type="button" class="view-btn" id="vbtn-preview" onclick="setViewMode('preview')">Preview</button>
400
404
  </div>
401
405
  <a id="btn-preview-link" href="#" target="_blank" rel="noopener" title="Open preview" style="display:none;font-size:11px;color:var(--muted);text-decoration:none;padding:4px 8px;border:1px solid var(--line);border-radius:var(--radius);">↗ Preview</a>
406
+ <button class="search-trigger" id="search-btn" title="Search (⌘K)" style="background:none;border:none;color:var(--muted);cursor:pointer;padding:4px 8px;font-size:13px;">⌘K</button>
402
407
  <button class="scheme-toggle" id="scheme-toggle" title="Toggle scheme">◐</button>
403
408
  <span class="user" id="topbar-user"></span>
404
409
  <span class="logout" id="logout-btn">Sign out</span>
@@ -563,8 +568,10 @@
563
568
  const panel = document.getElementById('meta-panel');
564
569
  const status = IS_NEW ? 'draft' : (entryData?.status ?? 'draft');
565
570
  const updatedAt = entryData?.updated_at;
566
- const publishAt = entryData?.publish_at ?? null;
567
- const publishAtInput = publishAt ? publishAt.replace(' ', 'T').slice(0, 16) : '';
571
+ const publishAt = entryData?.publish_at ?? null;
572
+ const unpublishAt = entryData?.unpublish_at ?? null;
573
+ const publishAtInput = publishAt ? publishAt.replace(' ', 'T').slice(0, 16) : '';
574
+ const unpublishAtInput = unpublishAt ? unpublishAt.replace(' ', 'T').slice(0, 16) : '';
568
575
 
569
576
  let fieldsHtml = '';
570
577
  const seoFields = {};
@@ -688,6 +695,13 @@
688
695
  <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>
689
696
  </div>
690
697
  <button type="button" class="btn-schedule" id="btn-schedule">${status==='scheduled'?'Reschedule':'Schedule'}</button>
698
+ ${status==='published'?`
699
+ <div class="expiry-picker${unpublishAt?' open':''}" id="expiry-picker">
700
+ <input type="datetime-local" class="field-input" id="unpublish-at-input" value="${unpublishAtInput}" style="flex:1;font-size:10px;padding:5px 8px;" />
701
+ <button type="button" id="btn-expiry-confirm" style="padding:5px 10px;background:var(--bg3);border:1px solid var(--line);color:var(--text);font-family:var(--mono);font-size:10px;cursor:pointer;border-radius:var(--radius);white-space:nowrap;">Set</button>
702
+ </div>
703
+ <button type="button" class="btn-expiry" id="btn-expiry">${unpublishAt?'Change expiry':'Set expiry'}</button>
704
+ `:''}
691
705
  </div>
692
706
  <div class="meta-section">
693
707
  <div class="meta-label">Details</div>
@@ -790,6 +804,12 @@
790
804
  if (!val) { alert('Please select a date and time.'); return; }
791
805
  saveEntry('scheduled');
792
806
  });
807
+ document.getElementById('btn-expiry')?.addEventListener('click', () => {
808
+ document.getElementById('expiry-picker').classList.toggle('open');
809
+ });
810
+ document.getElementById('btn-expiry-confirm')?.addEventListener('click', () => {
811
+ saveEntry('published');
812
+ });
793
813
 
794
814
  // Version restore buttons
795
815
  panel.querySelectorAll('.v-restore-btn').forEach(btn => {
@@ -1006,6 +1026,12 @@
1006
1026
  if (!raw) { if (!isAutosave) alert('Please select a date and time.'); return; }
1007
1027
  publish_at = raw.replace('T', ' ') + ':00';
1008
1028
  }
1029
+ // Resolve unpublish_at for published entries with expiry
1030
+ let unpublish_at = null;
1031
+ if (status === 'published') {
1032
+ const raw = document.getElementById('unpublish-at-input')?.value;
1033
+ unpublish_at = raw ? raw.replace('T', ' ') + ':00' : null;
1034
+ }
1009
1035
 
1010
1036
  const title = document.getElementById('title-input').value;
1011
1037
  syncToHidden();
@@ -1029,6 +1055,22 @@
1029
1055
  }
1030
1056
  }
1031
1057
 
1058
+ // Required field validation (skip on autosave)
1059
+ if (!isAutosave) {
1060
+ const missing = extraFields
1061
+ .filter(([key, field]) => {
1062
+ if (!field.required) return false;
1063
+ const val = data[key];
1064
+ if (Array.isArray(val)) return val.length === 0;
1065
+ return val === '' || val === null || val === undefined;
1066
+ })
1067
+ .map(([key, field]) => field.label || key);
1068
+ if (missing.length) {
1069
+ alert(`Required field${missing.length > 1 ? 's' : ''} missing: ${missing.join(', ')}`);
1070
+ return;
1071
+ }
1072
+ }
1073
+
1032
1074
  if (IS_NEW && !currentSlug) {
1033
1075
  // Create new entry
1034
1076
  const res = await fetch(`/api/collections/${COLLECTION}/entries`,{
@@ -1053,7 +1095,7 @@
1053
1095
  const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}`,{
1054
1096
  method:'PUT', credentials:'include',
1055
1097
  headers:{'Content-Type':'application/json'},
1056
- body: JSON.stringify({ slug, data, status, publish_at }),
1098
+ body: JSON.stringify({ slug, data, status, publish_at, unpublish_at }),
1057
1099
  });
1058
1100
  if (res.ok) {
1059
1101
  const json = await res.json();
@@ -1941,6 +1983,7 @@
1941
1983
  <input type="file" id="img-file-inp" accept="image/*" style="display:none">
1942
1984
 
1943
1985
  <script src="/sidebar.js"></script>
1986
+ <script src="/search.js"></script>
1944
1987
  <script src="/router.js"></script>
1945
1988
  </body>
1946
1989
  </html>
@@ -274,14 +274,15 @@ entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
274
274
  // PATCH /api/collections/:id/entries/:slug/status
275
275
  entryRoutes.patch('/:collectionId/entries/:slug/status', async (c) => {
276
276
  const { collectionId, slug } = c.req.param();
277
- const { status, publish_at } = await c.req.json();
277
+ const { status, publish_at, unpublish_at } = await c.req.json();
278
278
  if (!['draft', 'published', 'scheduled'].includes(status)) return c.json({ error: 'Invalid status' }, 400);
279
279
  if (status === 'scheduled' && !publish_at) return c.json({ error: 'publish_at required for scheduled status' }, 400);
280
280
  const db = openPod(c.get('podPath'));
281
281
  const entry = db.getEntry(collectionId, slug);
282
282
  if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
283
- const pa = status === 'scheduled' ? publish_at : null;
284
- db.updateEntry(collectionId, slug, { slug, data: entry.data, status, publish_at: pa });
283
+ const pa = status === 'scheduled' ? publish_at : null;
284
+ const ua = status === 'published' ? (unpublish_at ?? null) : null;
285
+ db.updateEntry(collectionId, slug, { slug, data: entry.data, status, publish_at: pa, unpublish_at: ua });
285
286
  const username = c.get('user')?.username ?? 'unknown';
286
287
  if (status === 'scheduled') db.logAudit(entry.id, username, 'schedule');
287
288
  db.close();
package/src/server.js CHANGED
@@ -101,17 +101,23 @@ serve({ fetch: createApp(POD_PATH).fetch, port: PORT }, () => {
101
101
  setInterval(() => {
102
102
  try {
103
103
  const db = openPod(POD_PATH);
104
- const due = db.getScheduledDue();
105
- if (!due.length) { db.close(); return; }
104
+ const due = db.getScheduledDue();
105
+ const expired = db.getExpiredDue();
106
+ if (!due.length && !expired.length) { db.close(); return; }
106
107
  const now = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
107
108
  for (const entry of due) {
108
109
  db.db.prepare("UPDATE _entries SET status = 'published', publish_at = NULL, updated_at = ? WHERE id = ?").run(now, entry.id);
109
110
  db.logAudit(entry.id, 'scheduler', 'publish');
110
111
  }
112
+ for (const entry of expired) {
113
+ db.db.prepare("UPDATE _entries SET status = 'draft', unpublish_at = NULL, updated_at = ? WHERE id = ?").run(now, entry.id);
114
+ db.logAudit(entry.id, 'scheduler', 'unpublish');
115
+ }
111
116
  const webhookUrl = db.getMeta('build.webhook_url') ?? '';
112
117
  if (webhookUrl) db.setMeta('build.last_triggered', new Date().toISOString());
113
118
  db.close();
114
- console.log(`[scheduler] Published ${due.length} scheduled entr${due.length === 1 ? 'y' : 'ies'}`);
119
+ if (due.length) console.log(`[scheduler] Published ${due.length} scheduled entr${due.length === 1 ? 'y' : 'ies'}`);
120
+ if (expired.length) console.log(`[scheduler] Unpublished ${expired.length} expired entr${expired.length === 1 ? 'y' : 'ies'}`);
115
121
  if (webhookUrl) fetch(webhookUrl, { method: 'POST' }).catch(() => {});
116
122
  } catch (e) {
117
123
  console.warn('[scheduler]', e.message);