@a83/orbiter-admin 0.3.1 → 0.3.2

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.2",
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
  }
@@ -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>
@@ -158,10 +164,12 @@
158
164
  wrap.innerHTML = '<div class="empty"><div class="empty-icon">◈</div>No entries yet</div>';
159
165
  return;
160
166
  }
167
+ const canSort = !activeFilter;
161
168
  wrap.innerHTML = `
162
169
  <table>
163
170
  <thead>
164
171
  <tr>
172
+ ${canSort ? '<th class="drag-col"></th>' : ''}
165
173
  <th class="cb-col"><input type="checkbox" id="check-all" title="Select all" /></th>
166
174
  <th>Title / Slug</th><th>Status</th><th>Updated</th><th></th>
167
175
  </tr>
@@ -172,7 +180,8 @@
172
180
  const updated = e.updated_at ? e.updated_at.split(' ')[0] : '—';
173
181
  const nextStatus = e.status === 'published' ? 'draft' : 'published';
174
182
  const toggleLabel = e.status === 'published' ? 'Unpublish' : 'Publish';
175
- return `<tr data-slug="${e.slug}">
183
+ return `<tr data-slug="${e.slug}"${canSort ? ' draggable="true"' : ''}>
184
+ ${canSort ? `<td class="drag-col"><span class="drag-handle" title="Drag to reorder">⠿</span></td>` : ''}
176
185
  <td class="cb-col"><input type="checkbox" class="row-cb" data-slug="${e.slug}" ${selected.has(e.slug) ? 'checked' : ''} /></td>
177
186
  <td>
178
187
  <div style="color:var(--heading);font-weight:500">${title}</div>
@@ -212,6 +221,47 @@
212
221
  });
213
222
  });
214
223
 
224
+ // Drag-to-reorder
225
+ if (canSort) {
226
+ let dragSrc = null;
227
+ wrap.querySelectorAll('tr[draggable]').forEach(row => {
228
+ row.addEventListener('dragstart', e => {
229
+ dragSrc = row;
230
+ row.classList.add('dragging');
231
+ e.dataTransfer.effectAllowed = 'move';
232
+ });
233
+ row.addEventListener('dragend', () => {
234
+ dragSrc = null;
235
+ wrap.querySelectorAll('tr').forEach(r => r.classList.remove('dragging','drag-over'));
236
+ });
237
+ row.addEventListener('dragover', e => {
238
+ e.preventDefault();
239
+ e.dataTransfer.dropEffect = 'move';
240
+ wrap.querySelectorAll('tr').forEach(r => r.classList.remove('drag-over'));
241
+ if (row !== dragSrc) row.classList.add('drag-over');
242
+ });
243
+ row.addEventListener('dragleave', () => row.classList.remove('drag-over'));
244
+ row.addEventListener('drop', async e => {
245
+ e.preventDefault();
246
+ row.classList.remove('drag-over');
247
+ if (!dragSrc || dragSrc === row) return;
248
+ const tbody = row.closest('tbody');
249
+ const rows = [...tbody.querySelectorAll('tr')];
250
+ const fromIdx = rows.indexOf(dragSrc);
251
+ const toIdx = rows.indexOf(row);
252
+ tbody.removeChild(dragSrc);
253
+ if (fromIdx < toIdx) row.after(dragSrc);
254
+ else row.before(dragSrc);
255
+ const slugs = [...tbody.querySelectorAll('tr')].map(r => r.dataset.slug);
256
+ await fetch(`/api/collections/${colId}/entries/reorder`, {
257
+ method: 'PATCH', credentials: 'include',
258
+ headers: { 'Content-Type': 'application/json' },
259
+ body: JSON.stringify({ slugs }),
260
+ });
261
+ });
262
+ });
263
+ }
264
+
215
265
  // Status toggle
216
266
  wrap.querySelectorAll('.status-toggle').forEach(btn => {
217
267
  btn.addEventListener('click', async () => {
@@ -106,15 +106,16 @@
106
106
  const FIELD_TYPES = [
107
107
  {value:'string', label:'String — single line text'},
108
108
  {value:'richtext', label:'Rich Text — block editor'},
109
+ {value:'boolean', label:'Boolean — on / off toggle'},
109
110
  {value:'date', label:'Date'},
110
111
  {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
112
  {value:'number', label:'Number'},
116
- {value:'weekdays', label:'Weekdays'},
113
+ {value:'url', label:'URL'},
114
+ {value:'select', label:'Select — dropdown'},
115
+ {value:'array', label:'Array — tags, comma-separated'},
116
+ {value:'image', label:'Image — media picker'},
117
117
  {value:'relation', label:'Relation — link to collection'},
118
+ {value:'weekdays', label:'Weekdays'},
118
119
  ];
119
120
 
120
121
  let allColls = [];
@@ -272,7 +273,16 @@
272
273
  <label>Label</label>
273
274
  <input class="input" id="new-col-label" placeholder="e.g. Posts, Products" />
274
275
  </div>
276
+ <div class="field-group" style="grid-column:1/-1">
277
+ <label>Preview URL</label>
278
+ <input class="input" id="new-preview-url" placeholder="https://mysite.com/posts/{slug}" />
279
+ <div class="field-id-hint">{slug} is replaced with the entry slug when opening preview</div>
280
+ </div>
275
281
  </div>
282
+ <label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text);cursor:pointer;margin-bottom:12px;">
283
+ <input type="checkbox" id="new-col-singleton" />
284
+ <span>Singleton — single entry, opens directly from sidebar (for About pages, site config, etc.)</span>
285
+ </label>
276
286
  <div class="section-label">Fields</div>
277
287
  <div id="new-field-rows" class="field-rows"></div>
278
288
  <div class="editor-actions">
@@ -292,10 +302,14 @@
292
302
  const label=document.getElementById('new-col-label').value.trim();
293
303
  if (!id||!label) { showBannerIn('banner','banner-err','ID and label are required'); return; }
294
304
  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})});
305
+ const previewUrl=(document.getElementById('new-preview-url')?.value??'').trim();
306
+ const singleton=!!(document.getElementById('new-col-singleton')?.checked);
307
+ const res=await fetch('/api/collections',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,label,schema,singleton})});
296
308
  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');
309
+ if (res.ok) {
310
+ if (previewUrl) await fetch(`/api/meta/preview_url~${id}`,{method:'PUT',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({value:previewUrl})}).catch(()=>{});
311
+ selectedId=id; await loadColls(); const col=allColls.find(c=>c.id===id); if(col)renderEditor(col);
312
+ } else showBannerIn('banner','banner-err',json.error??'Failed');
299
313
  });
300
314
  }
301
315
 
@@ -319,7 +333,16 @@
319
333
  <label>Label</label>
320
334
  <input class="input" id="edit-label" value="${escHtml(col.label)}" />
321
335
  </div>
336
+ <div class="field-group" style="grid-column:1/-1">
337
+ <label>Preview URL</label>
338
+ <input class="input" id="edit-preview-url" placeholder="https://mysite.com/posts/{slug}" />
339
+ <div class="field-id-hint">{slug} is replaced with the entry slug when opening preview</div>
340
+ </div>
322
341
  </div>
342
+ <label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text);cursor:pointer;margin-bottom:12px;">
343
+ <input type="checkbox" id="edit-col-singleton"${col.singleton?' checked':''} />
344
+ <span>Singleton — single entry, opens directly from sidebar</span>
345
+ </label>
323
346
  <div class="section-label">Fields</div>
324
347
  ${total>0?'<div class="delete-warn">Adding fields is non-destructive. Removing fields hides existing data but does not delete it.</div>':''}
325
348
  <div id="edit-field-rows" class="field-rows"></div>
@@ -334,10 +357,19 @@
334
357
  const rowsEl=document.getElementById('edit-field-rows');
335
358
  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
359
  document.getElementById('btn-add-edit').addEventListener('click',()=>addFieldRow(rowsEl));
360
+ // Load existing preview URL for this collection
361
+ fetch(`/api/meta/preview_url~${col.id}`,{credentials:'include'}).then(r=>r.json()).then(d=>{
362
+ const inp=document.getElementById('edit-preview-url'); if(inp)inp.value=d.value??'';
363
+ }).catch(()=>{});
337
364
  document.getElementById('btn-save-edit').addEventListener('click',async()=>{
338
365
  const label=document.getElementById('edit-label').value.trim();
339
366
  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})});
367
+ const previewUrl=(document.getElementById('edit-preview-url')?.value??'').trim();
368
+ const singleton=!!(document.getElementById('edit-col-singleton')?.checked);
369
+ const [res] = await Promise.all([
370
+ fetch(`/api/collections/${col.id}`,{method:'PUT',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({label,schema,singleton})}),
371
+ fetch(`/api/meta/preview_url~${col.id}`,{method:'PUT',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({value:previewUrl})}),
372
+ ]);
341
373
  if (res.ok) { await loadColls(); showBannerIn('banner','banner-ok','Saved'); }
342
374
  else { const j=await res.json(); showBannerIn('banner','banner-err',j.error??'Failed'); }
343
375
  });
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
@@ -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();
@@ -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));