@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 +2 -2
- package/public/editor.html +193 -8
- package/public/entries.html +51 -1
- package/public/schema.html +41 -9
- package/public/sidebar.js +9 -4
- package/src/routes/collections.js +24 -9
- package/src/routes/entries.js +16 -1
- package/src/routes/meta.js +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a83/orbiter-admin",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
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/editor.html
CHANGED
|
@@ -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
|
|
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==='
|
|
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==='
|
|
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:  or {.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();
|
package/public/entries.html
CHANGED
|
@@ -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 () => {
|
package/public/schema.html
CHANGED
|
@@ -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:'
|
|
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
|
|
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) {
|
|
298
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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"
|
|
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:
|
|
13
|
-
|
|
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)
|
package/src/routes/entries.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/routes/meta.js
CHANGED
|
@@ -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));
|