@a83/orbiter-admin 0.3.1 → 0.3.3

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.1",
3
+ "version": "0.3.3",
4
4
  "description": "Standalone admin server for Orbiter CMS",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
@@ -26,7 +26,7 @@
26
26
  "directory": "packages/admin"
27
27
  },
28
28
  "dependencies": {
29
- "@a83/orbiter-core": "^0.3.1",
29
+ "@a83/orbiter-core": "^0.3.2",
30
30
  "@hono/node-server": "^1.14.4",
31
31
  "hono": "^4.7.11"
32
32
  }
package/public/build.html CHANGED
@@ -56,6 +56,7 @@
56
56
  <div class="sidebar-footer">
57
57
  <div class="pod-name" id="pod-name">content.pod</div>
58
58
  <div class="pod-info" id="pod-info"></div>
59
+ <div class="pod-version" id="pod-version"></div>
59
60
  <div class="pod-status"><span class="pod-dot"></span>pod synced</div>
60
61
  </div>
61
62
  </nav>
@@ -36,6 +36,7 @@
36
36
  <div class="sidebar-footer">
37
37
  <div class="pod-name" id="pod-name">content.pod</div>
38
38
  <div class="pod-info" id="pod-info"></div>
39
+ <div class="pod-version" id="pod-version"></div>
39
40
  <div class="pod-status"><span class="pod-dot"></span>pod synced</div>
40
41
  </div>
41
42
  </nav>
@@ -136,6 +136,7 @@
136
136
  <div class="sidebar-footer">
137
137
  <div class="pod-name" id="pod-name">content.pod</div>
138
138
  <div class="pod-info" id="pod-info"></div>
139
+ <div class="pod-version" id="pod-version"></div>
139
140
  <div class="pod-status"><span class="pod-dot"></span>pod synced</div>
140
141
  </div>
141
142
  </nav>
@@ -114,6 +114,13 @@
114
114
  /* Schema fields in sidebar */
115
115
  .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; }
116
116
  .field-input:focus,.field-select:focus { border-color:var(--accent); }
117
+ .bool-toggle { display:flex; align-items:center; gap:8px; padding:4px 0; cursor:pointer; user-select:none; }
118
+ .bool-toggle input[type=checkbox] { display:none; }
119
+ .bool-track { width:32px; height:18px; border-radius:9px; background:var(--line); border:1px solid var(--line); transition:background .15s,border-color .15s; flex-shrink:0; position:relative; }
120
+ .bool-track::after { content:''; position:absolute; top:2px; left:2px; width:12px; height:12px; border-radius:50%; background:var(--muted); transition:transform .15s,background .15s; }
121
+ .bool-toggle input:checked + .bool-track { background:var(--accent); border-color:var(--accent); }
122
+ .bool-toggle input:checked + .bool-track::after { transform:translateX(14px); background:#fff; }
123
+ .bool-label { font-size:11px; color:var(--text); font-family:var(--mono); }
117
124
  .media-drop-zone { border:1px dashed var(--line); background:var(--bg0); height:72px; cursor:pointer; transition:border-color .12s,background .12s; overflow:hidden; display:flex; align-items:center; justify-content:center; position:relative; border-radius:var(--radius); margin-top:5px; }
118
125
  .media-drop-zone:hover { border-color:var(--gold); background:var(--gold-bg); }
119
126
  .media-drop-zone.has-image { border-style:solid; }
@@ -331,6 +338,19 @@
331
338
  }
332
339
  .vid-url-cancel:hover { border-color:var(--mid); color:var(--text); }
333
340
 
341
+ /* ── Callout blocks ─────────────────────────────────────────── */
342
+ .be-block[data-type="callout"] { background:color-mix(in srgb,var(--accent) 8%,transparent); border-left:3px solid var(--accent); border-radius:0 var(--radius) var(--radius) 0; padding:10px 14px; margin:4px 0; color:var(--text); font-size:13px; }
343
+
344
+ /* ── Table blocks ───────────────────────────────────────────── */
345
+ .be-block[data-type="table"] { padding:0; overflow:auto; margin:4px 0; }
346
+ .be-table { border-collapse:collapse; width:100%; font-size:12px; }
347
+ .be-table th,.be-table td { border:1px solid var(--line); padding:6px 10px; min-width:80px; outline:none; }
348
+ .be-table th { background:var(--bg2); font-weight:600; }
349
+ .be-table td:focus,.be-table th:focus { border-color:var(--accent); background:color-mix(in srgb,var(--accent) 5%,transparent); }
350
+ .table-toolbar { display:flex; gap:6px; padding:6px 8px; background:var(--bg2); border-bottom:1px solid var(--line); font-size:10px; }
351
+ .table-toolbar button { background:none; border:1px solid var(--line); color:var(--text); padding:2px 8px; border-radius:var(--radius); cursor:pointer; font-size:10px; font-family:var(--mono); }
352
+ .table-toolbar button:hover { border-color:var(--accent); color:var(--accent); }
353
+
334
354
  /* SERP preview */
335
355
  .serp-preview { background:var(--bg0); border:1px solid var(--line); border-radius:var(--radius); padding:12px 14px; margin-top:12px; }
336
356
  .serp-url { font-size:9px; color:var(--jade); margin-bottom:3px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
@@ -357,6 +377,7 @@
357
377
  <button type="button" class="view-btn" id="vbtn-split" onclick="setViewMode('split')">Split</button>
358
378
  <button type="button" class="view-btn" id="vbtn-preview" onclick="setViewMode('preview')">Preview</button>
359
379
  </div>
380
+ <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>
360
381
  <button class="scheme-toggle" id="scheme-toggle" title="Toggle scheme">◐</button>
361
382
  <span class="user" id="topbar-user"></span>
362
383
  <span class="logout" id="logout-btn">Sign out</span>
@@ -438,13 +459,24 @@
438
459
  location.replace('/login.html');
439
460
  });
440
461
 
441
- // Parse ?collection=X&slug=Y from URL
462
+ // Parse ?collection=X&slug=Y&singleton=1 from URL
442
463
  const params = new URLSearchParams(location.search);
443
464
  const COLLECTION = params.get('collection');
444
- const SLUG = params.get('slug') ?? 'new';
445
- const IS_NEW = SLUG === 'new';
465
+ const IS_SINGLETON = params.get('singleton') === '1';
446
466
 
447
467
  if (!COLLECTION) { location.replace('/collections.html'); }
468
+
469
+ // Singleton: resolve the canonical entry slug before proceeding
470
+ let SLUG = params.get('slug') ?? 'new';
471
+ if (IS_SINGLETON) {
472
+ const singletonEntry = await fetch(`/api/collections/${COLLECTION}/singleton`,{credentials:'include'}).then(r=>r.ok?r.json():null);
473
+ if (singletonEntry?.slug) {
474
+ SLUG = singletonEntry.slug;
475
+ history.replaceState(null,'',`/editor.html?collection=${encodeURIComponent(COLLECTION)}&slug=${encodeURIComponent(SLUG)}&singleton=1`);
476
+ }
477
+ }
478
+ const IS_NEW = SLUG === 'new';
479
+
448
480
  document.getElementById('collection-id-display').textContent = COLLECTION;
449
481
 
450
482
  // Load collection schema + entry
@@ -474,6 +506,17 @@
474
506
  }
475
507
  }
476
508
 
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);
511
+ const previewUrlTemplate = previewMeta?.value || '';
512
+ function updatePreviewLink() {
513
+ const btn = document.getElementById('btn-preview-link');
514
+ if (!btn || !previewUrlTemplate) return;
515
+ const slug = document.getElementById('slug-input')?.value || currentSlug || SLUG;
516
+ btn.href = previewUrlTemplate.replace('{slug}', slug);
517
+ btn.style.display = '';
518
+ }
519
+
477
520
  // Set title + slug
478
521
  const titleInput = document.getElementById('title-input');
479
522
  titleInput.value = IS_NEW ? '' : (entryData?.data?.title ?? SLUG);
@@ -506,7 +549,10 @@
506
549
  fieldsHtml += `<div class="meta-field" data-field-key="${key}" ${field.showWhen?`data-show-when="${field.showWhen}"`:''}><div class="field-label">${escHtml(field.label??key)}</div>`;
507
550
  }
508
551
 
509
- if (field.type==='datetime') {
552
+ if (field.type==='boolean') {
553
+ const checked = val===true||val==='true'||val===1;
554
+ fieldsHtml += `<label class="bool-toggle"><input type="checkbox" name="${key}"${checked?' checked':''} /><span class="bool-track"></span><span class="bool-label">${checked?'An':'Aus'}</span></label>`;
555
+ } else if (field.type==='datetime') {
510
556
  fieldsHtml += `<input type="datetime-local" class="field-input" name="${key}" value="${escHtml(val??'')}" />`;
511
557
  } else if (field.type==='date') {
512
558
  fieldsHtml += `<input type="date" class="field-input" name="${key}" value="${escHtml(val??'')}" />`;
@@ -518,7 +564,7 @@
518
564
  ${[['Mon','Mo'],['Tue','Di'],['Wed','Mi'],['Thu','Do'],['Fri','Fr'],['Sat','Sa'],['Sun','So']].map(([d,l])=>`<button type="button" class="wd-btn${active.includes(d)?' active':''}" data-day="${d}" data-key="${key}">${l}</button>`).join('')}
519
565
  <input type="hidden" name="${key}" id="wd-val-${key}" value="${active.join(',')}" />
520
566
  </div>`;
521
- } else if (field.type==='media') {
567
+ } else if (field.type==='image'||field.type==='media') {
522
568
  const hasVal = !!(val);
523
569
  fieldsHtml += `<div>
524
570
  <select class="field-select" name="${key}" id="media-sel-${key}">
@@ -657,6 +703,15 @@
657
703
  zone.addEventListener('drop',e=>{e.preventDefault();zone.classList.remove('drag-over');if(e.dataTransfer.files.length){const fi={files:e.dataTransfer.files};uploadMediaField(key,fi);}});
658
704
  });
659
705
 
706
+ // Boolean toggle — live label + dirty
707
+ panel.querySelectorAll('.bool-toggle input[type=checkbox]').forEach(cb=>{
708
+ cb.addEventListener('change',()=>{
709
+ const lbl = cb.closest('.bool-toggle')?.querySelector('.bool-label');
710
+ if (lbl) lbl.textContent = cb.checked ? 'An' : 'Aus';
711
+ markDirty();
712
+ });
713
+ });
714
+
660
715
  // Publish / draft buttons
661
716
  document.getElementById('btn-publish').addEventListener('click',()=>saveEntry('published'));
662
717
  document.getElementById('btn-draft').addEventListener('click',()=>saveEntry('draft'));
@@ -665,6 +720,7 @@
665
720
  document.getElementById('slug-input').addEventListener('input', e=>{
666
721
  e.target.dataset.manual='1';
667
722
  document.getElementById('slug-preview').textContent = e.target.value || '…';
723
+ updatePreviewLink();
668
724
  });
669
725
 
670
726
  // Status select sync
@@ -816,7 +872,9 @@
816
872
  // Gather extra field values
817
873
  const data = { title, body };
818
874
  for (const [key, field] of extraFields) {
819
- if (field.type==='array'||field.type==='weekdays') {
875
+ if (field.type==='boolean') {
876
+ data[key] = !!(document.querySelector(`[name="${key}"]`)?.checked);
877
+ } else if (field.type==='array'||field.type==='weekdays') {
820
878
  const raw = document.getElementById('wd-val-'+key)?.value || document.querySelector(`[name="${key}"]`)?.value || '';
821
879
  data[key] = raw ? raw.split(',').map(s=>s.trim()).filter(Boolean) : [];
822
880
  } else if (field.type==='relation') {
@@ -881,11 +939,21 @@
881
939
  const indicator = document.getElementById('autosave-indicator');
882
940
  let autosaveTimer = null;
883
941
 
942
+ let _dirty = false;
943
+ function markDirty() { _dirty = true; }
944
+ function markClean() { _dirty = false; }
945
+
946
+ window.addEventListener('beforeunload', e => {
947
+ if (_dirty) { e.preventDefault(); e.returnValue = ''; }
948
+ });
949
+
884
950
  function setIndicator(state) {
885
951
  const states = { pending:{text:'Unsaved',color:'var(--gold)'}, saving:{text:'Saving…',color:'var(--muted)'}, saved:{text:'✓ Saved',color:'var(--jade)'}, error:{text:'Error',color:'var(--red)'} };
886
952
  const s = states[state] ?? {};
887
953
  indicator.textContent = s.text ?? '';
888
954
  indicator.style.color = s.color ?? '';
955
+ if (state === 'saved') markClean();
956
+ if (state === 'pending') markDirty();
889
957
  }
890
958
 
891
959
  function scheduleAutosave() {
@@ -954,6 +1022,20 @@
954
1022
  lines.push(`::video[${b.dataset.videoUrl ?? ''}]`);
955
1023
  return;
956
1024
  }
1025
+ if (type==='callout') {
1026
+ lines.push(`::callout[${b.innerHTML.replace(/\n+/g,' ')}]`);
1027
+ return;
1028
+ }
1029
+ if (type==='table') {
1030
+ const tbl = b.querySelector('table');
1031
+ if (tbl) {
1032
+ const clean = tbl.cloneNode(true);
1033
+ clean.querySelectorAll('[contenteditable]').forEach(c=>c.removeAttribute('contenteditable'));
1034
+ // Collapse newlines so the chunk-based parser never splits table HTML
1035
+ lines.push(`::table[${clean.outerHTML.replace(/\n+/g,' ')}]`);
1036
+ }
1037
+ return;
1038
+ }
957
1039
  const text = htmlToMd(b.innerHTML);
958
1040
  if (type==='h1') lines.push('# '+text);
959
1041
  else if (type==='h2') lines.push('## '+text);
@@ -970,16 +1052,43 @@
970
1052
 
971
1053
  function syncToHidden() { bodyInput.value = serialize(); updateWc(); }
972
1054
 
973
- const BLK_PH = { h1:'Heading 1',h2:'Heading 2',h3:'Heading 3',blockquote:'Quote…',pre:'Code…',ul:'List item',ol:'List item',p:'Write something…' };
1055
+ const BLK_PH = { h1:'Heading 1',h2:'Heading 2',h3:'Heading 3',blockquote:'Quote…',pre:'Code…',ul:'List item',ol:'List item',callout:'Callout…',p:'Write something…' };
974
1056
 
975
1057
  function createBlock(type, content) {
976
1058
  const el = document.createElement('div');
977
1059
  el.className='be-block'; el.dataset.type=type||'p'; el.dataset.ph=BLK_PH[type]||'Write something…';
978
1060
  if (type==='hr') { el.contentEditable='false'; }
1061
+ else if (type==='table') {
1062
+ el.contentEditable='false';
1063
+ el.innerHTML = `<div class="table-toolbar">
1064
+ <button onclick="tableAddRow(this)">+ Row</button>
1065
+ <button onclick="tableAddCol(this)">+ Col</button>
1066
+ <button onclick="tableDelRow(this)">− Row</button>
1067
+ <button onclick="tableDelCol(this)">− Col</button>
1068
+ </div>
1069
+ <table class="be-table">
1070
+ <thead><tr><th contenteditable="true"></th><th contenteditable="true"></th></tr></thead>
1071
+ <tbody><tr><td contenteditable="true"></td><td contenteditable="true"></td></tr><tr><td contenteditable="true"></td><td contenteditable="true"></td></tr></tbody>
1072
+ </table>`;
1073
+ }
979
1074
  else { el.contentEditable='true'; el.innerHTML=content||''; }
980
1075
  return el;
981
1076
  }
982
1077
 
1078
+ function createTableBlock(tableEl) {
1079
+ const el = document.createElement('div');
1080
+ el.className='be-block'; el.dataset.type='table'; el.dataset.ph='';
1081
+ el.contentEditable='false';
1082
+ el.innerHTML = `<div class="table-toolbar">
1083
+ <button onclick="tableAddRow(this)">+ Row</button>
1084
+ <button onclick="tableAddCol(this)">+ Col</button>
1085
+ <button onclick="tableDelRow(this)">− Row</button>
1086
+ <button onclick="tableDelCol(this)">− Col</button>
1087
+ </div>`;
1088
+ el.appendChild(tableEl);
1089
+ return el;
1090
+ }
1091
+
983
1092
  function parseMd(md) {
984
1093
  beEditor.innerHTML='';
985
1094
  if (!md) { beEditor.appendChild(createBlock('p','')); return; }
@@ -988,6 +1097,27 @@
988
1097
  // Video block: ::video[url]
989
1098
  const vidM = chunk.match(/^::video\[(.+)\]$/);
990
1099
  if (vidM) { beEditor.appendChild(createVideoBlock(vidM[1])); return; }
1100
+ // Callout block: ::callout[html]
1101
+ const calloutM = chunk.match(/^::callout\[([\s\S]*)\]$/);
1102
+ if (calloutM) {
1103
+ const b = createBlock('callout', '');
1104
+ b.innerHTML = calloutM[1];
1105
+ beEditor.appendChild(b);
1106
+ return;
1107
+ }
1108
+ // Table block: ::table[<table>...</table>]
1109
+ const tableM = chunk.match(/^::table\[([\s\S]*)\]$/);
1110
+ if (tableM) {
1111
+ const tmp = document.createElement('div');
1112
+ tmp.innerHTML = tableM[1];
1113
+ const tbl = tmp.querySelector('table');
1114
+ if (tbl) {
1115
+ tbl.className = 'be-table';
1116
+ tbl.querySelectorAll('th,td').forEach(c=>c.setAttribute('contenteditable','true'));
1117
+ beEditor.appendChild(createTableBlock(tbl));
1118
+ }
1119
+ return;
1120
+ }
991
1121
  // Image block: ![alt](mediaId) or ![alt](mediaId){.align}
992
1122
  const imgM = chunk.match(/^!\[([^\]]*)\]\(([^)]+)\)(?:\{\.(\w+)\})?$/);
993
1123
  if (imgM) { beEditor.appendChild(createImgBlock(imgM[2], imgM[1], imgM[3]||'center')); return; }
@@ -1047,7 +1177,30 @@
1047
1177
  if (e.key==='Enter') { e.preventDefault(); bpInsert(bpIdx); return; }
1048
1178
  if (e.key==='Escape') { e.preventDefault(); bpClose(); return; }
1049
1179
  }
1180
+ // Tab inside a table cell: move to next/prev cell
1181
+ if (e.key==='Tab') {
1182
+ const cell=e.target.closest('.be-table th,.be-table td');
1183
+ if (cell) {
1184
+ e.preventDefault();
1185
+ const table=cell.closest('table');
1186
+ const cells=Array.from(table.querySelectorAll('th[contenteditable],td[contenteditable]'));
1187
+ const idx=cells.indexOf(cell);
1188
+ const nextCell=e.shiftKey ? cells[idx-1] : cells[idx+1];
1189
+ if (nextCell) { nextCell.focus(); } else if (!e.shiftKey) {
1190
+ // Tab past last cell: add a new row
1191
+ const cols=table.rows[0].cells.length;
1192
+ const tr=document.createElement('tr');
1193
+ for (let i=0;i<cols;i++){const td=document.createElement('td');td.contentEditable='true';tr.appendChild(td);}
1194
+ table.tBodies[0].appendChild(tr);
1195
+ tr.cells[0].focus();
1196
+ syncToHidden(); scheduleAutosave();
1197
+ }
1198
+ return;
1199
+ }
1200
+ }
1050
1201
  const b=getFocusedBlock(); if (!b) return;
1202
+ // Skip block-level Enter/Backspace handling when focus is inside a table cell
1203
+ if (b.dataset.type==='table') return;
1051
1204
  if (e.key==='Enter'&&!e.shiftKey) {
1052
1205
  e.preventDefault();
1053
1206
  const type=b.dataset.type; const newType=(type==='ul'||type==='ol')?type:'p';
@@ -1062,7 +1215,7 @@
1062
1215
  });
1063
1216
 
1064
1217
  beEditor.addEventListener('input',()=>{
1065
- const b=getFocusedBlock(); if (!b||b.dataset.type==='hr') { syncToHidden(); scheduleAutosave(); updatePreview(); return; }
1218
+ const b=getFocusedBlock(); if (!b||b.dataset.type==='hr'||b.dataset.type==='table') { syncToHidden(); scheduleAutosave(); updatePreview(); return; }
1066
1219
  if (bpOpen&&bpTargetBlock) {
1067
1220
  const text=bpTargetBlock.innerText; const typed=text.substring(1);
1068
1221
  if (typed.includes('\n')) bpClose(); else { bpQuery=typed; bpIdx=0; bpRender(); } return;
@@ -1181,6 +1334,8 @@
1181
1334
  {label:'Heading 3', icon:'H3', hint:'###', type:'h3'},
1182
1335
  {label:'Quote', icon:'❝', hint:'>', type:'blockquote'},
1183
1336
  {label:'Code block', icon:'{}', hint:'```', type:'pre'},
1337
+ {label:'Callout', icon:'ℹ', hint:'note', type:'callout'},
1338
+ {label:'Table', icon:'▦', hint:'tbl', type:'table'},
1184
1339
  {label:'List', icon:'·', hint:'-', type:'ul'},
1185
1340
  {label:'Numbered', icon:'1.', hint:'1.', type:'ol'},
1186
1341
  {label:'Divider', icon:'—', hint:'---', type:'hr'},
@@ -1236,6 +1391,10 @@
1236
1391
  if (blk.type==='hr') {
1237
1392
  const hr=createBlock('hr',''); const next=createBlock('p','');
1238
1393
  bpTargetBlock.replaceWith(hr); hr.after(next); focusStart(next);
1394
+ } else if (blk.type==='table') {
1395
+ const tbl=createBlock('table',''); const next=createBlock('p','');
1396
+ bpTargetBlock.replaceWith(tbl); tbl.after(next);
1397
+ const firstCell=tbl.querySelector('th,td'); if (firstCell) firstCell.focus();
1239
1398
  } else { changeBlockType(blk.type,bpTargetBlock); }
1240
1399
  syncToHidden(); scheduleAutosave(); updatePreview(); bpClose();
1241
1400
  }
@@ -1540,8 +1699,34 @@
1540
1699
  if (e.key==='Escape') { e.preventDefault(); closeVideoPicker(); }
1541
1700
  });
1542
1701
 
1702
+ // ── Table toolbar helpers ─────────────────────────────────────────
1703
+ window.tableAddRow = function(btn) {
1704
+ const table = btn.closest('.be-block').querySelector('table');
1705
+ const cols = table.rows[0].cells.length;
1706
+ const tr = document.createElement('tr');
1707
+ for (let i=0;i<cols;i++) { const td=document.createElement('td'); td.contentEditable='true'; tr.appendChild(td); }
1708
+ table.tBodies[0].appendChild(tr);
1709
+ syncToHidden(); scheduleAutosave();
1710
+ };
1711
+ window.tableAddCol = function(btn) {
1712
+ const table = btn.closest('.be-block').querySelector('table');
1713
+ Array.from(table.rows).forEach(row => { const cell = row.cells[0].tagName==='TH' ? document.createElement('th') : document.createElement('td'); cell.contentEditable='true'; row.appendChild(cell); });
1714
+ syncToHidden(); scheduleAutosave();
1715
+ };
1716
+ window.tableDelRow = function(btn) {
1717
+ const table = btn.closest('.be-block').querySelector('table');
1718
+ if (table.tBodies[0].rows.length > 1) table.tBodies[0].deleteRow(-1);
1719
+ syncToHidden(); scheduleAutosave();
1720
+ };
1721
+ window.tableDelCol = function(btn) {
1722
+ const table = btn.closest('.be-block').querySelector('table');
1723
+ if (table.rows[0].cells.length > 1) Array.from(table.rows).forEach(row => row.deleteCell(-1));
1724
+ syncToHidden(); scheduleAutosave();
1725
+ };
1726
+
1543
1727
  // ── Init ──────────────────────────────────────────────────────────
1544
1728
  renderMetaPanel();
1729
+ updatePreviewLink();
1545
1730
  parseMd(IS_NEW ? '' : (entryData?.data?.body ?? ''));
1546
1731
  syncToHidden(); // serializes blocks → bodyInput, then calls updateWc()
1547
1732
  updatePreview();
@@ -39,6 +39,12 @@
39
39
  .filter-tab { display:inline-flex; align-items:center; padding:3px 11px; font-size:10px; font-family:var(--mono); border-radius:20px; border:1px solid var(--line); background:none; color:var(--muted); cursor:pointer; transition:all 0.12s; }
40
40
  .filter-tab:hover { color:var(--text); border-color:var(--mid); }
41
41
  .filter-tab.active { background:var(--accent-bg); color:var(--accent); border-color:var(--accent); }
42
+ .drag-col { width:24px; padding:0 4px !important; text-align:center; }
43
+ .drag-handle { color:var(--line); cursor:grab; font-size:13px; line-height:1; user-select:none; display:block; padding:4px 2px; }
44
+ .drag-handle:hover { color:var(--muted); }
45
+ tr.drag-over td { border-top:2px solid var(--accent); }
46
+ tr[draggable="true"] { transition:opacity .15s; }
47
+ tr.dragging { opacity:.4; }
42
48
  </style>
43
49
  </head>
44
50
  <body>
@@ -69,6 +75,7 @@
69
75
  <div class="sidebar-footer">
70
76
  <div class="pod-name" id="pod-name">content.pod</div>
71
77
  <div class="pod-info" id="pod-info"></div>
78
+ <div class="pod-version" id="pod-version"></div>
72
79
  <div class="pod-status"><span class="pod-dot"></span>pod synced</div>
73
80
  </div>
74
81
  </nav>
@@ -158,10 +165,12 @@
158
165
  wrap.innerHTML = '<div class="empty"><div class="empty-icon">◈</div>No entries yet</div>';
159
166
  return;
160
167
  }
168
+ const canSort = !activeFilter;
161
169
  wrap.innerHTML = `
162
170
  <table>
163
171
  <thead>
164
172
  <tr>
173
+ ${canSort ? '<th class="drag-col"></th>' : ''}
165
174
  <th class="cb-col"><input type="checkbox" id="check-all" title="Select all" /></th>
166
175
  <th>Title / Slug</th><th>Status</th><th>Updated</th><th></th>
167
176
  </tr>
@@ -172,7 +181,8 @@
172
181
  const updated = e.updated_at ? e.updated_at.split(' ')[0] : '—';
173
182
  const nextStatus = e.status === 'published' ? 'draft' : 'published';
174
183
  const toggleLabel = e.status === 'published' ? 'Unpublish' : 'Publish';
175
- return `<tr data-slug="${e.slug}">
184
+ return `<tr data-slug="${e.slug}"${canSort ? ' draggable="true"' : ''}>
185
+ ${canSort ? `<td class="drag-col"><span class="drag-handle" title="Drag to reorder">⠿</span></td>` : ''}
176
186
  <td class="cb-col"><input type="checkbox" class="row-cb" data-slug="${e.slug}" ${selected.has(e.slug) ? 'checked' : ''} /></td>
177
187
  <td>
178
188
  <div style="color:var(--heading);font-weight:500">${title}</div>
@@ -212,6 +222,47 @@
212
222
  });
213
223
  });
214
224
 
225
+ // Drag-to-reorder
226
+ if (canSort) {
227
+ let dragSrc = null;
228
+ wrap.querySelectorAll('tr[draggable]').forEach(row => {
229
+ row.addEventListener('dragstart', e => {
230
+ dragSrc = row;
231
+ row.classList.add('dragging');
232
+ e.dataTransfer.effectAllowed = 'move';
233
+ });
234
+ row.addEventListener('dragend', () => {
235
+ dragSrc = null;
236
+ wrap.querySelectorAll('tr').forEach(r => r.classList.remove('dragging','drag-over'));
237
+ });
238
+ row.addEventListener('dragover', e => {
239
+ e.preventDefault();
240
+ e.dataTransfer.dropEffect = 'move';
241
+ wrap.querySelectorAll('tr').forEach(r => r.classList.remove('drag-over'));
242
+ if (row !== dragSrc) row.classList.add('drag-over');
243
+ });
244
+ row.addEventListener('dragleave', () => row.classList.remove('drag-over'));
245
+ row.addEventListener('drop', async e => {
246
+ e.preventDefault();
247
+ row.classList.remove('drag-over');
248
+ if (!dragSrc || dragSrc === row) return;
249
+ const tbody = row.closest('tbody');
250
+ const rows = [...tbody.querySelectorAll('tr')];
251
+ const fromIdx = rows.indexOf(dragSrc);
252
+ const toIdx = rows.indexOf(row);
253
+ tbody.removeChild(dragSrc);
254
+ if (fromIdx < toIdx) row.after(dragSrc);
255
+ else row.before(dragSrc);
256
+ const slugs = [...tbody.querySelectorAll('tr')].map(r => r.dataset.slug);
257
+ await fetch(`/api/collections/${colId}/entries/reorder`, {
258
+ method: 'PATCH', credentials: 'include',
259
+ headers: { 'Content-Type': 'application/json' },
260
+ body: JSON.stringify({ slugs }),
261
+ });
262
+ });
263
+ });
264
+ }
265
+
215
266
  // Status toggle
216
267
  wrap.querySelectorAll('.status-toggle').forEach(btn => {
217
268
  btn.addEventListener('click', async () => {
@@ -106,6 +106,7 @@
106
106
  <div class="sidebar-footer">
107
107
  <div class="pod-name" id="pod-name">content.pod</div>
108
108
  <div class="pod-info" id="pod-info"></div>
109
+ <div class="pod-version" id="pod-version"></div>
109
110
  <div class="pod-status"><span class="pod-dot"></span>pod synced</div>
110
111
  </div>
111
112
  </nav>
package/public/media.html CHANGED
@@ -67,6 +67,7 @@
67
67
  <div class="sidebar-footer">
68
68
  <div class="pod-name" id="pod-name">content.pod</div>
69
69
  <div class="pod-info" id="pod-info"></div>
70
+ <div class="pod-version" id="pod-version"></div>
70
71
  <div class="pod-status"><span class="pod-dot"></span>pod synced</div>
71
72
  </div>
72
73
  </nav>
@@ -75,6 +75,7 @@
75
75
  <div class="sidebar-footer">
76
76
  <div class="pod-name" id="pod-name">content.pod</div>
77
77
  <div class="pod-info" id="pod-info"></div>
78
+ <div class="pod-version" id="pod-version"></div>
78
79
  <div class="pod-status"><span class="pod-dot"></span>pod synced</div>
79
80
  </div>
80
81
  </nav>
@@ -106,15 +107,16 @@
106
107
  const FIELD_TYPES = [
107
108
  {value:'string', label:'String — single line text'},
108
109
  {value:'richtext', label:'Rich Text — block editor'},
110
+ {value:'boolean', label:'Boolean — on / off toggle'},
109
111
  {value:'date', label:'Date'},
110
112
  {value:'datetime', label:'Date & time'},
111
- {value:'array', label:'Array — tags, comma-separated'},
112
- {value:'media', label:'Media — image / file'},
113
- {value:'select', label:'Select — dropdown'},
114
- {value:'url', label:'URL'},
115
113
  {value:'number', label:'Number'},
116
- {value:'weekdays', label:'Weekdays'},
114
+ {value:'url', label:'URL'},
115
+ {value:'select', label:'Select — dropdown'},
116
+ {value:'array', label:'Array — tags, comma-separated'},
117
+ {value:'image', label:'Image — media picker'},
117
118
  {value:'relation', label:'Relation — link to collection'},
119
+ {value:'weekdays', label:'Weekdays'},
118
120
  ];
119
121
 
120
122
  let allColls = [];
@@ -272,7 +274,16 @@
272
274
  <label>Label</label>
273
275
  <input class="input" id="new-col-label" placeholder="e.g. Posts, Products" />
274
276
  </div>
277
+ <div class="field-group" style="grid-column:1/-1">
278
+ <label>Preview URL</label>
279
+ <input class="input" id="new-preview-url" placeholder="https://mysite.com/posts/{slug}" />
280
+ <div class="field-id-hint">{slug} is replaced with the entry slug when opening preview</div>
281
+ </div>
275
282
  </div>
283
+ <label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text);cursor:pointer;margin-bottom:12px;">
284
+ <input type="checkbox" id="new-col-singleton" />
285
+ <span>Singleton — single entry, opens directly from sidebar (for About pages, site config, etc.)</span>
286
+ </label>
276
287
  <div class="section-label">Fields</div>
277
288
  <div id="new-field-rows" class="field-rows"></div>
278
289
  <div class="editor-actions">
@@ -292,10 +303,14 @@
292
303
  const label=document.getElementById('new-col-label').value.trim();
293
304
  if (!id||!label) { showBannerIn('banner','banner-err','ID and label are required'); return; }
294
305
  const schema=serializeSchema(document.getElementById('new-field-rows'));
295
- const res=await fetch('/api/collections',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,label,schema})});
306
+ const previewUrl=(document.getElementById('new-preview-url')?.value??'').trim();
307
+ const singleton=!!(document.getElementById('new-col-singleton')?.checked);
308
+ const res=await fetch('/api/collections',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,label,schema,singleton})});
296
309
  const json=await res.json();
297
- if (res.ok) { selectedId=id; await loadColls(); const col=allColls.find(c=>c.id===id); if(col)renderEditor(col); }
298
- else showBannerIn('banner','banner-err',json.error??'Failed');
310
+ if (res.ok) {
311
+ if (previewUrl) await fetch(`/api/meta/preview_url~${id}`,{method:'PUT',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({value:previewUrl})}).catch(()=>{});
312
+ selectedId=id; await loadColls(); const col=allColls.find(c=>c.id===id); if(col)renderEditor(col);
313
+ } else showBannerIn('banner','banner-err',json.error??'Failed');
299
314
  });
300
315
  }
301
316
 
@@ -319,7 +334,16 @@
319
334
  <label>Label</label>
320
335
  <input class="input" id="edit-label" value="${escHtml(col.label)}" />
321
336
  </div>
337
+ <div class="field-group" style="grid-column:1/-1">
338
+ <label>Preview URL</label>
339
+ <input class="input" id="edit-preview-url" placeholder="https://mysite.com/posts/{slug}" />
340
+ <div class="field-id-hint">{slug} is replaced with the entry slug when opening preview</div>
341
+ </div>
322
342
  </div>
343
+ <label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text);cursor:pointer;margin-bottom:12px;">
344
+ <input type="checkbox" id="edit-col-singleton"${col.singleton?' checked':''} />
345
+ <span>Singleton — single entry, opens directly from sidebar</span>
346
+ </label>
323
347
  <div class="section-label">Fields</div>
324
348
  ${total>0?'<div class="delete-warn">Adding fields is non-destructive. Removing fields hides existing data but does not delete it.</div>':''}
325
349
  <div id="edit-field-rows" class="field-rows"></div>
@@ -334,10 +358,19 @@
334
358
  const rowsEl=document.getElementById('edit-field-rows');
335
359
  Object.entries(schema).forEach(([k,f])=>addFieldRow(rowsEl,k,f.label??k,f.type??'string',!!f.required,(f.options??[]).join(', '),f.collection??'',f.multiple!==false));
336
360
  document.getElementById('btn-add-edit').addEventListener('click',()=>addFieldRow(rowsEl));
361
+ // Load existing preview URL for this collection
362
+ fetch(`/api/meta/preview_url~${col.id}`,{credentials:'include'}).then(r=>r.json()).then(d=>{
363
+ const inp=document.getElementById('edit-preview-url'); if(inp)inp.value=d.value??'';
364
+ }).catch(()=>{});
337
365
  document.getElementById('btn-save-edit').addEventListener('click',async()=>{
338
366
  const label=document.getElementById('edit-label').value.trim();
339
367
  const schema=serializeSchema(rowsEl);
340
- const res=await fetch(`/api/collections/${col.id}`,{method:'PUT',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({label,schema})});
368
+ const previewUrl=(document.getElementById('edit-preview-url')?.value??'').trim();
369
+ const singleton=!!(document.getElementById('edit-col-singleton')?.checked);
370
+ const [res] = await Promise.all([
371
+ fetch(`/api/collections/${col.id}`,{method:'PUT',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({label,schema,singleton})}),
372
+ fetch(`/api/meta/preview_url~${col.id}`,{method:'PUT',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({value:previewUrl})}),
373
+ ]);
341
374
  if (res.ok) { await loadColls(); showBannerIn('banner','banner-ok','Saved'); }
342
375
  else { const j=await res.json(); showBannerIn('banner','banner-err',j.error??'Failed'); }
343
376
  });
@@ -245,6 +245,7 @@
245
245
  <div class="sidebar-footer">
246
246
  <div class="pod-name" id="pod-name">content.pod</div>
247
247
  <div class="pod-info" id="pod-info"></div>
248
+ <div class="pod-version" id="pod-version"></div>
248
249
  <div class="pod-status"><span class="pod-dot"></span>pod synced</div>
249
250
  </div>
250
251
  </nav>
package/public/sidebar.js CHANGED
@@ -40,14 +40,19 @@
40
40
  var frag = document.createDocumentFragment();
41
41
 
42
42
  topLevel.forEach(function (col) {
43
- var isActive = (page === 'entries') && activeCol === col.id;
43
+ var isSingleton = !!col.singleton;
44
+ var isActive = isSingleton
45
+ ? (page === 'editor') && activeCol === col.id
46
+ : (page === 'entries') && activeCol === col.id;
44
47
  var a = document.createElement('a');
45
48
  a.className = 'nav-item' + (isActive ? ' active' : '');
46
- a.href = '/entries.html?col=' + encodeURIComponent(col.id) + '&label=' + encodeURIComponent(col.label);
49
+ a.href = isSingleton
50
+ ? '/editor.html?collection=' + encodeURIComponent(col.id) + '&singleton=1'
51
+ : '/entries.html?col=' + encodeURIComponent(col.id) + '&label=' + encodeURIComponent(col.label);
47
52
  a.innerHTML =
48
- '<span class="nav-icon">▤</span>' +
53
+ '<span class="nav-icon">' + (isSingleton ? '◈' : '▤') + '</span>' +
49
54
  '<span class="nav-label">' + col.label + '</span>' +
50
- '<span class="nav-badge">' + col.total + '</span>';
55
+ (isSingleton ? '' : '<span class="nav-badge">' + col.total + '</span>');
51
56
  frag.appendChild(a);
52
57
 
53
58
  // Children
@@ -84,6 +89,15 @@
84
89
  var total = collections.reduce(function (s, c) { return s + c.total; }, 0);
85
90
  podInfoEl.textContent = collections.length + ' collections · ' + total + ' entries';
86
91
  }
92
+
93
+ // version line
94
+ var podVersionEl = sidebar.querySelector('#pod-version');
95
+ if (podVersionEl) {
96
+ var parts = [];
97
+ if (info.adminVersion) parts.push('Orbiter v' + info.adminVersion);
98
+ if (info.formatVersion) parts.push('pod v' + info.formatVersion);
99
+ podVersionEl.textContent = parts.join(' · ');
100
+ }
87
101
  })
88
102
  .catch(function () {});
89
103
  });
package/public/style.css CHANGED
@@ -905,7 +905,8 @@ a:hover { color: var(--heading); }
905
905
  .sidebar-footer { margin-top: auto; padding: 14px 18px; border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); }
906
906
  .pod-name { color: var(--text); font-size: 12px; font-weight: 500; margin-bottom: 3px; display: flex; align-items: center; gap: 5px; }
907
907
  .pod-name::before { content: "◆"; font-size: 6px; color: var(--gold); }
908
- .pod-info { font-size: 10px; color: var(--muted); margin-bottom: 6px; }
908
+ .pod-info { font-size: 10px; color: var(--muted); margin-bottom: 2px; }
909
+ .pod-version { font-size: 10px; color: var(--muted); opacity: 0.6; margin-bottom: 4px; }
909
910
  .pod-status { display: flex; align-items: center; margin-top: 8px; }
910
911
  .pod-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--jade); margin-right: 5px; flex-shrink: 0; animation: pulse 2.5s ease-in-out infinite; }
911
912
  @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
package/public/users.html CHANGED
@@ -67,6 +67,7 @@
67
67
  <div class="sidebar-footer">
68
68
  <div class="pod-name" id="pod-name">content.pod</div>
69
69
  <div class="pod-info" id="pod-info"></div>
70
+ <div class="pod-version" id="pod-version"></div>
70
71
  <div class="pod-status"><span class="pod-dot"></span>pod synced</div>
71
72
  </div>
72
73
  </nav>
@@ -9,8 +9,9 @@ collectionRoutes.get('/', (c) => {
9
9
  const db = openPod(c.get('podPath'));
10
10
  const cols = db.getCollections().map(col => ({
11
11
  ...col,
12
- schema: col.schema ? JSON.parse(col.schema) : {},
13
- total: db.getEntries(col.id).length,
12
+ schema: col.schema ? JSON.parse(col.schema) : {},
13
+ singleton: !!col.singleton,
14
+ total: db.getEntries(col.id).length,
14
15
  }));
15
16
  db.close();
16
17
  return c.json(cols);
@@ -22,12 +23,26 @@ collectionRoutes.get('/:id', (c) => {
22
23
  const col = db.getCollection(c.req.param('id'));
23
24
  db.close();
24
25
  if (!col) return c.json({ error: 'Not found' }, 404);
25
- return c.json({ ...col, schema: col.schema ? JSON.parse(col.schema) : {} });
26
+ return c.json({ ...col, schema: col.schema ? JSON.parse(col.schema) : {}, singleton: !!col.singleton });
27
+ });
28
+
29
+ // GET /api/collections/:id/singleton — get or auto-create the single entry
30
+ collectionRoutes.get('/:id/singleton', (c) => {
31
+ const db = openPod(c.get('podPath'));
32
+ const col = db.getCollection(c.req.param('id'));
33
+ if (!col) { db.close(); return c.json({ error: 'Not found' }, 404); }
34
+ let entries = db.getEntries(col.id);
35
+ if (entries.length === 0) {
36
+ db.createEntry(col.id, 'index', {}, 'draft');
37
+ entries = db.getEntries(col.id);
38
+ }
39
+ db.close();
40
+ return c.json(entries[0]);
26
41
  });
27
42
 
28
43
  // POST /api/collections (admin only)
29
44
  collectionRoutes.post('/', requireAdmin, async (c) => {
30
- const { id, label, schema = {} } = await c.req.json();
45
+ const { id, label, schema = {}, singleton = false } = await c.req.json();
31
46
  if (!id || !label) return c.json({ error: 'id and label are required' }, 400);
32
47
 
33
48
  const safeId = id.toLowerCase().replace(/[^a-z0-9_]/g, '_').replace(/^_+|_+$/g, '');
@@ -36,22 +51,22 @@ collectionRoutes.post('/', requireAdmin, async (c) => {
36
51
  db.close();
37
52
  return c.json({ error: `Collection "${safeId}" already exists` }, 409);
38
53
  }
39
- db.createCollection(safeId, label, schema);
54
+ db.createCollection(safeId, label, schema, singleton);
40
55
  const created = db.getCollection(safeId);
41
56
  db.close();
42
- return c.json({ ...created, schema }, 201);
57
+ return c.json({ ...created, schema, singleton: !!singleton }, 201);
43
58
  });
44
59
 
45
60
  // PUT /api/collections/:id (admin only)
46
61
  collectionRoutes.put('/:id', requireAdmin, async (c) => {
47
- const { label, schema } = await c.req.json();
62
+ const { label, schema, singleton } = await c.req.json();
48
63
  const db = openPod(c.get('podPath'));
49
64
  const col = db.getCollection(c.req.param('id'));
50
65
  if (!col) { db.close(); return c.json({ error: 'Not found' }, 404); }
51
- db.updateCollection(col.id, label ?? col.label, schema ?? JSON.parse(col.schema ?? '{}'));
66
+ db.updateCollection(col.id, label ?? col.label, schema ?? JSON.parse(col.schema ?? '{}'), singleton);
52
67
  const updated = db.getCollection(col.id);
53
68
  db.close();
54
- return c.json({ ...updated, schema: updated.schema ? JSON.parse(updated.schema) : {} });
69
+ return c.json({ ...updated, schema: updated.schema ? JSON.parse(updated.schema) : {}, singleton: !!updated.singleton });
55
70
  });
56
71
 
57
72
  // DELETE /api/collections/:id (admin only)
@@ -11,6 +11,19 @@ function fireWebhook(podPath) {
11
11
  if (url) fetch(url, { method: 'POST' }).catch(() => {});
12
12
  }
13
13
 
14
+ // PATCH /api/collections/:id/entries/reorder — set sort_order by slug array
15
+ entryRoutes.patch('/:collectionId/entries/reorder', async (c) => {
16
+ const { collectionId } = c.req.param();
17
+ const { slugs } = await c.req.json();
18
+ if (!Array.isArray(slugs)) return c.json({ error: 'slugs must be an array' }, 400);
19
+ const db = openPod(c.get('podPath'));
20
+ const set = db.db.prepare('UPDATE _entries SET sort_order = ? WHERE collection_id = ? AND slug = ?');
21
+ const tx = db.db.transaction(() => { slugs.forEach((slug, i) => set.run(i, collectionId, slug)); });
22
+ tx();
23
+ db.close();
24
+ return c.json({ ok: true });
25
+ });
26
+
14
27
  // GET /api/collections/:id/entries?status=draft|published
15
28
  entryRoutes.get('/:collectionId/entries', (c) => {
16
29
  const { collectionId } = c.req.param();
@@ -95,7 +108,9 @@ entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
95
108
  const db = openPod(c.get('podPath'));
96
109
  const entry = db.getEntry(collectionId, slug);
97
110
  if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
98
- const newSlug = slug + '-copy';
111
+ let newSlug = slug + '-copy';
112
+ let i = 2;
113
+ while (db.getEntry(collectionId, newSlug)) newSlug = `${slug}-copy-${i++}`;
99
114
  db.createEntry(collectionId, newSlug, entry.data, 'draft');
100
115
  const created = db.getEntry(collectionId, newSlug);
101
116
  db.close();
@@ -1,5 +1,13 @@
1
1
  import { Hono } from 'hono';
2
2
  import { openPod } from '@a83/orbiter-core';
3
+ import { readFileSync } from 'fs';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const { version: adminVersion } = JSON.parse(
9
+ readFileSync(join(__dirname, '../../package.json'), 'utf8')
10
+ );
3
11
 
4
12
  export const infoRoutes = new Hono();
5
13
 
@@ -12,8 +20,9 @@ infoRoutes.get('/', (c) => {
12
20
  label: col.label,
13
21
  total: db.getEntries(col.id).length,
14
22
  parent: db.getMeta(`collection.${col.id}.parent`) ?? null,
23
+ singleton: !!col.singleton,
15
24
  }));
16
25
  const version = db.getMeta('format_version') ?? '1';
17
26
  db.close();
18
- return c.json({ podPath, formatVersion: version, collections: cols });
27
+ return c.json({ podPath, formatVersion: version, adminVersion, collections: cols });
19
28
  });
@@ -16,11 +16,14 @@ const ALLOWED_KEYS = [
16
16
  'format_version',
17
17
  ];
18
18
 
19
+ const PREVIEW_URL_RE = /^preview_url\.[a-z0-9_-]+$/;
20
+
19
21
  // GET /api/meta — returns all allowed keys as a flat object
20
22
  metaRoutes.get('/', (c) => {
21
23
  const db = openPod(c.get('podPath'));
22
24
  const out = {};
23
25
  for (const key of ALLOWED_KEYS) out[key] = db.getMeta(key) ?? null;
26
+ // preview_url keys are per-collection and not enumerated here
24
27
  db.close();
25
28
  return c.json(out);
26
29
  });
@@ -39,7 +42,7 @@ metaRoutes.put('/', async (c) => {
39
42
  const body = await c.req.json();
40
43
  const db = openPod(c.get('podPath'));
41
44
  for (const [key, value] of Object.entries(body)) {
42
- if (ALLOWED_KEYS.includes(key)) db.setMeta(key, value == null ? '' : String(value));
45
+ if (ALLOWED_KEYS.includes(key) || PREVIEW_URL_RE.test(key)) db.setMeta(key, value == null ? '' : String(value));
43
46
  }
44
47
  db.close();
45
48
  return c.json({ ok: true });
@@ -48,7 +51,7 @@ metaRoutes.put('/', async (c) => {
48
51
  // PUT /api/meta/:key — single key update
49
52
  metaRoutes.put('/:key', async (c) => {
50
53
  const key = c.req.param('key').replace(/~/g, '.');
51
- if (!ALLOWED_KEYS.includes(key)) return c.json({ error: 'Key not allowed' }, 403);
54
+ if (!ALLOWED_KEYS.includes(key) && !PREVIEW_URL_RE.test(key)) return c.json({ error: 'Key not allowed' }, 403);
52
55
  const { value } = await c.req.json();
53
56
  const db = openPod(c.get('podPath'));
54
57
  db.setMeta(key, value == null ? '' : String(value));