@a83/orbiter-admin 0.3.12 → 0.3.14
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 -1
- package/public/editor.html +78 -3
- package/public/schema.html +19 -1
- package/public/settings.html +49 -3
- package/src/email.js +50 -0
- package/src/routes/comments.js +2 -0
- package/src/routes/entries.js +6 -3
- package/src/routes/locks.js +48 -0
- package/src/routes/meta.js +2 -0
- package/src/server.js +11 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a83/orbiter-admin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"description": "Standalone admin server for Orbiter CMS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server.js",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"@a83/orbiter-core": "^0.3.2",
|
|
30
30
|
"@hono/node-server": "^1.14.4",
|
|
31
31
|
"hono": "^4.7.11",
|
|
32
|
+
"nodemailer": "^8.0.10",
|
|
32
33
|
"sharp": "^0.34.5"
|
|
33
34
|
}
|
|
34
35
|
}
|
package/public/editor.html
CHANGED
|
@@ -116,6 +116,10 @@
|
|
|
116
116
|
.btn-schedule:hover { background:var(--accent-bg); }
|
|
117
117
|
.schedule-picker { display:none; gap:4px; margin-bottom:5px; }
|
|
118
118
|
.schedule-picker.open { display:flex; }
|
|
119
|
+
.expiry-picker { display:none; gap:4px; margin-bottom:5px; }
|
|
120
|
+
.expiry-picker.open { display:flex; }
|
|
121
|
+
.btn-expiry { display:block; width:100%; padding:8px; background:transparent; border:1px solid var(--line); color:var(--muted); font-family:var(--mono); font-size:10px; cursor:pointer; transition:all .12s; border-radius:var(--radius); margin-bottom:5px; }
|
|
122
|
+
.btn-expiry:hover { border-color:var(--mid); color:var(--text); }
|
|
119
123
|
|
|
120
124
|
/* Schema fields in sidebar */
|
|
121
125
|
.field-input,.field-select { width:100%; background:var(--bg0); border:1px solid var(--line); padding:6px 8px; color:var(--heading); font-family:var(--mono); font-size:11px; outline:none; appearance:none; transition:border-color .15s; border-radius:var(--radius); box-sizing:border-box; }
|
|
@@ -399,11 +403,15 @@
|
|
|
399
403
|
<button type="button" class="view-btn" id="vbtn-preview" onclick="setViewMode('preview')">Preview</button>
|
|
400
404
|
</div>
|
|
401
405
|
<a id="btn-preview-link" href="#" target="_blank" rel="noopener" title="Open preview" style="display:none;font-size:11px;color:var(--muted);text-decoration:none;padding:4px 8px;border:1px solid var(--line);border-radius:var(--radius);">↗ Preview</a>
|
|
406
|
+
<button class="search-trigger" id="search-btn" title="Search (⌘K)" style="background:none;border:none;color:var(--muted);cursor:pointer;padding:4px 8px;font-size:13px;">⌘K</button>
|
|
402
407
|
<button class="scheme-toggle" id="scheme-toggle" title="Toggle scheme">◐</button>
|
|
403
408
|
<span class="user" id="topbar-user"></span>
|
|
404
409
|
<span class="logout" id="logout-btn">Sign out</span>
|
|
405
410
|
</div>
|
|
406
411
|
</header>
|
|
412
|
+
<div id="lock-banner" style="display:none;background:color-mix(in srgb,var(--gold) 12%,var(--bg2));border-bottom:1px solid color-mix(in srgb,var(--gold) 30%,transparent);padding:7px 20px;font-size:11px;color:var(--text);display:none;align-items:center;gap:8px;">
|
|
413
|
+
<span style="color:var(--gold);">⚠</span> <span id="lock-banner-msg"></span>
|
|
414
|
+
</div>
|
|
407
415
|
<div class="editor-shell" id="editor-shell">
|
|
408
416
|
<div class="editor-main" id="editor-main">
|
|
409
417
|
<div id="saved-flash" style="display:none;" class="saved-flash">Entry saved</div>
|
|
@@ -512,6 +520,35 @@
|
|
|
512
520
|
|
|
513
521
|
if (!colData) { location.replace('/collections.html'); }
|
|
514
522
|
|
|
523
|
+
// Entry locking — claim lock, warn if someone else is editing
|
|
524
|
+
let lockHeld = false;
|
|
525
|
+
let lockHeartbeat = null;
|
|
526
|
+
async function claimLock() {
|
|
527
|
+
if (IS_NEW) return;
|
|
528
|
+
const res = await fetch(`/api/locks/${COLLECTION}/${SLUG}`,{method:'POST',credentials:'include'}).catch(()=>null);
|
|
529
|
+
if (!res) return;
|
|
530
|
+
if (res.status === 409) {
|
|
531
|
+
const j = await res.json().catch(()=>({}));
|
|
532
|
+
const banner = document.getElementById('lock-banner');
|
|
533
|
+
document.getElementById('lock-banner-msg').textContent = `${j.by ?? 'Someone'} is currently editing this entry. Your changes may conflict.`;
|
|
534
|
+
banner.style.display = 'flex';
|
|
535
|
+
lockHeld = false;
|
|
536
|
+
} else {
|
|
537
|
+
lockHeld = true;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async function releaseLock() {
|
|
541
|
+
if (!lockHeld || IS_NEW) return;
|
|
542
|
+
lockHeld = false;
|
|
543
|
+
await fetch(`/api/locks/${COLLECTION}/${SLUG}`,{method:'DELETE',credentials:'include'}).catch(()=>{});
|
|
544
|
+
}
|
|
545
|
+
claimLock().then(()=>{
|
|
546
|
+
if (IS_NEW) return;
|
|
547
|
+
lockHeartbeat = setInterval(()=>fetch(`/api/locks/${COLLECTION}/${SLUG}`,{method:'POST',credentials:'include'}).catch(()=>{}), 60_000);
|
|
548
|
+
});
|
|
549
|
+
window.addEventListener('beforeunload', () => { if (lockHeld) releaseLock(); });
|
|
550
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden && lockHeld) releaseLock(); });
|
|
551
|
+
|
|
515
552
|
// Breadcrumb
|
|
516
553
|
document.getElementById('back-to-col').textContent = colData.label;
|
|
517
554
|
document.getElementById('back-to-col').href = `/entries.html?col=${COLLECTION}&label=${encodeURIComponent(colData.label)}`;
|
|
@@ -563,8 +600,10 @@
|
|
|
563
600
|
const panel = document.getElementById('meta-panel');
|
|
564
601
|
const status = IS_NEW ? 'draft' : (entryData?.status ?? 'draft');
|
|
565
602
|
const updatedAt = entryData?.updated_at;
|
|
566
|
-
const publishAt
|
|
567
|
-
const
|
|
603
|
+
const publishAt = entryData?.publish_at ?? null;
|
|
604
|
+
const unpublishAt = entryData?.unpublish_at ?? null;
|
|
605
|
+
const publishAtInput = publishAt ? publishAt.replace(' ', 'T').slice(0, 16) : '';
|
|
606
|
+
const unpublishAtInput = unpublishAt ? unpublishAt.replace(' ', 'T').slice(0, 16) : '';
|
|
568
607
|
|
|
569
608
|
let fieldsHtml = '';
|
|
570
609
|
const seoFields = {};
|
|
@@ -688,6 +727,13 @@
|
|
|
688
727
|
<button type="button" id="btn-schedule-confirm" style="padding:5px 10px;background:var(--accent);border:none;color:var(--bg0);font-family:var(--mono);font-size:10px;cursor:pointer;border-radius:var(--radius);white-space:nowrap;">Set</button>
|
|
689
728
|
</div>
|
|
690
729
|
<button type="button" class="btn-schedule" id="btn-schedule">${status==='scheduled'?'Reschedule':'Schedule'}</button>
|
|
730
|
+
${status==='published'?`
|
|
731
|
+
<div class="expiry-picker${unpublishAt?' open':''}" id="expiry-picker">
|
|
732
|
+
<input type="datetime-local" class="field-input" id="unpublish-at-input" value="${unpublishAtInput}" style="flex:1;font-size:10px;padding:5px 8px;" />
|
|
733
|
+
<button type="button" id="btn-expiry-confirm" style="padding:5px 10px;background:var(--bg3);border:1px solid var(--line);color:var(--text);font-family:var(--mono);font-size:10px;cursor:pointer;border-radius:var(--radius);white-space:nowrap;">Set</button>
|
|
734
|
+
</div>
|
|
735
|
+
<button type="button" class="btn-expiry" id="btn-expiry">${unpublishAt?'Change expiry':'Set expiry'}</button>
|
|
736
|
+
`:''}
|
|
691
737
|
</div>
|
|
692
738
|
<div class="meta-section">
|
|
693
739
|
<div class="meta-label">Details</div>
|
|
@@ -790,6 +836,12 @@
|
|
|
790
836
|
if (!val) { alert('Please select a date and time.'); return; }
|
|
791
837
|
saveEntry('scheduled');
|
|
792
838
|
});
|
|
839
|
+
document.getElementById('btn-expiry')?.addEventListener('click', () => {
|
|
840
|
+
document.getElementById('expiry-picker').classList.toggle('open');
|
|
841
|
+
});
|
|
842
|
+
document.getElementById('btn-expiry-confirm')?.addEventListener('click', () => {
|
|
843
|
+
saveEntry('published');
|
|
844
|
+
});
|
|
793
845
|
|
|
794
846
|
// Version restore buttons
|
|
795
847
|
panel.querySelectorAll('.v-restore-btn').forEach(btn => {
|
|
@@ -1006,6 +1058,12 @@
|
|
|
1006
1058
|
if (!raw) { if (!isAutosave) alert('Please select a date and time.'); return; }
|
|
1007
1059
|
publish_at = raw.replace('T', ' ') + ':00';
|
|
1008
1060
|
}
|
|
1061
|
+
// Resolve unpublish_at for published entries with expiry
|
|
1062
|
+
let unpublish_at = null;
|
|
1063
|
+
if (status === 'published') {
|
|
1064
|
+
const raw = document.getElementById('unpublish-at-input')?.value;
|
|
1065
|
+
unpublish_at = raw ? raw.replace('T', ' ') + ':00' : null;
|
|
1066
|
+
}
|
|
1009
1067
|
|
|
1010
1068
|
const title = document.getElementById('title-input').value;
|
|
1011
1069
|
syncToHidden();
|
|
@@ -1029,6 +1087,22 @@
|
|
|
1029
1087
|
}
|
|
1030
1088
|
}
|
|
1031
1089
|
|
|
1090
|
+
// Required field validation (skip on autosave)
|
|
1091
|
+
if (!isAutosave) {
|
|
1092
|
+
const missing = extraFields
|
|
1093
|
+
.filter(([key, field]) => {
|
|
1094
|
+
if (!field.required) return false;
|
|
1095
|
+
const val = data[key];
|
|
1096
|
+
if (Array.isArray(val)) return val.length === 0;
|
|
1097
|
+
return val === '' || val === null || val === undefined;
|
|
1098
|
+
})
|
|
1099
|
+
.map(([key, field]) => field.label || key);
|
|
1100
|
+
if (missing.length) {
|
|
1101
|
+
alert(`Required field${missing.length > 1 ? 's' : ''} missing: ${missing.join(', ')}`);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1032
1106
|
if (IS_NEW && !currentSlug) {
|
|
1033
1107
|
// Create new entry
|
|
1034
1108
|
const res = await fetch(`/api/collections/${COLLECTION}/entries`,{
|
|
@@ -1053,7 +1127,7 @@
|
|
|
1053
1127
|
const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}`,{
|
|
1054
1128
|
method:'PUT', credentials:'include',
|
|
1055
1129
|
headers:{'Content-Type':'application/json'},
|
|
1056
|
-
body: JSON.stringify({ slug, data, status, publish_at }),
|
|
1130
|
+
body: JSON.stringify({ slug, data, status, publish_at, unpublish_at }),
|
|
1057
1131
|
});
|
|
1058
1132
|
if (res.ok) {
|
|
1059
1133
|
const json = await res.json();
|
|
@@ -1941,6 +2015,7 @@
|
|
|
1941
2015
|
<input type="file" id="img-file-inp" accept="image/*" style="display:none">
|
|
1942
2016
|
|
|
1943
2017
|
<script src="/sidebar.js"></script>
|
|
2018
|
+
<script src="/search.js"></script>
|
|
1944
2019
|
<script src="/router.js"></script>
|
|
1945
2020
|
</body>
|
|
1946
2021
|
</html>
|
package/public/schema.html
CHANGED
|
@@ -390,9 +390,11 @@
|
|
|
390
390
|
${total>0?'<div class="delete-warn">Adding fields is non-destructive. Removing fields hides existing data but does not delete it.</div>':''}
|
|
391
391
|
<div id="edit-field-rows" class="field-rows"></div>
|
|
392
392
|
<div class="editor-actions">
|
|
393
|
-
<div style="display:flex;gap:10px;">
|
|
393
|
+
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
|
394
394
|
<button class="btn btn-primary" id="btn-save-edit">Save</button>
|
|
395
395
|
<button class="btn" id="btn-add-edit">+ Add field</button>
|
|
396
|
+
<button class="btn" id="btn-export-schema">↓ Export schema</button>
|
|
397
|
+
<label class="btn" style="cursor:pointer;">↑ Import schema<input type="file" id="import-schema-file" accept=".json" style="display:none"></label>
|
|
396
398
|
</div>
|
|
397
399
|
<button class="btn" id="btn-del-col" style="color:var(--red);border-color:var(--red);">Delete collection</button>
|
|
398
400
|
</div>
|
|
@@ -401,6 +403,22 @@
|
|
|
401
403
|
enableFieldDrag(rowsEl);
|
|
402
404
|
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));
|
|
403
405
|
document.getElementById('btn-add-edit').addEventListener('click',()=>addFieldRow(rowsEl));
|
|
406
|
+
document.getElementById('btn-export-schema').addEventListener('click',()=>{
|
|
407
|
+
const blob=new Blob([JSON.stringify(serializeSchema(rowsEl),null,2)],{type:'application/json'});
|
|
408
|
+
const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`${col.id}-schema.json`; a.click(); URL.revokeObjectURL(a.href);
|
|
409
|
+
});
|
|
410
|
+
document.getElementById('import-schema-file').addEventListener('change',e=>{
|
|
411
|
+
const file=e.target.files[0]; if (!file) return;
|
|
412
|
+
const reader=new FileReader();
|
|
413
|
+
reader.onload=ev=>{
|
|
414
|
+
try {
|
|
415
|
+
const s=JSON.parse(ev.target.result);
|
|
416
|
+
rowsEl.innerHTML='';
|
|
417
|
+
Object.entries(s).forEach(([k,f])=>addFieldRow(rowsEl,k,f.label??k,f.type??'string',!!f.required,(f.options??[]).join(', '),f.collection??'',f.multiple!==false));
|
|
418
|
+
} catch { alert('Invalid schema JSON'); }
|
|
419
|
+
};
|
|
420
|
+
reader.readAsText(file); e.target.value='';
|
|
421
|
+
});
|
|
404
422
|
// Load existing preview URL for this collection
|
|
405
423
|
fetch(`/api/meta/preview_url~${col.id}`,{credentials:'include'}).then(r=>r.json()).then(d=>{
|
|
406
424
|
const inp=document.getElementById('edit-preview-url'); if(inp)inp.value=d.value??'';
|
package/public/settings.html
CHANGED
|
@@ -449,6 +449,42 @@
|
|
|
449
449
|
</div>
|
|
450
450
|
</div>
|
|
451
451
|
|
|
452
|
+
<div class="settings-group">
|
|
453
|
+
<div class="group-header">Email notifications</div>
|
|
454
|
+
<div class="setting-row">
|
|
455
|
+
<div><div class="setting-label">SMTP host</div><div class="setting-desc">e.g. smtp.fastmail.com, smtp.gmail.com</div></div>
|
|
456
|
+
<input class="input" name="email.smtp_host" value="${get('email.smtp_host')||''}" placeholder="smtp.example.com" />
|
|
457
|
+
</div>
|
|
458
|
+
<div class="setting-row">
|
|
459
|
+
<div><div class="setting-label">SMTP port</div><div class="setting-desc">Usually 587 (STARTTLS) or 465 (SSL)</div></div>
|
|
460
|
+
<input class="input" name="email.smtp_port" type="number" value="${get('email.smtp_port')||'587'}" placeholder="587" style="max-width:100px" />
|
|
461
|
+
</div>
|
|
462
|
+
<div class="setting-row">
|
|
463
|
+
<div><div class="setting-label">SMTP username</div></div>
|
|
464
|
+
<input class="input" name="email.smtp_user" value="${get('email.smtp_user')||''}" placeholder="you@example.com" />
|
|
465
|
+
</div>
|
|
466
|
+
<div class="setting-row">
|
|
467
|
+
<div><div class="setting-label">SMTP password</div></div>
|
|
468
|
+
<input class="input" type="password" name="email.smtp_pass" value="${get('email.smtp_pass')||''}" placeholder="••••••••" />
|
|
469
|
+
</div>
|
|
470
|
+
<div class="setting-row">
|
|
471
|
+
<div><div class="setting-label">From address</div><div class="setting-desc">Defaults to SMTP username if blank</div></div>
|
|
472
|
+
<input class="input" name="email.smtp_from" value="${get('email.smtp_from')||''}" placeholder="Orbiter <noreply@example.com>" />
|
|
473
|
+
</div>
|
|
474
|
+
<div class="setting-row">
|
|
475
|
+
<div><div class="setting-label">Notify to</div><div class="setting-desc">Recipient email address for notifications</div></div>
|
|
476
|
+
<input class="input" name="email.notify_to" value="${get('email.notify_to')||''}" placeholder="admin@example.com" />
|
|
477
|
+
</div>
|
|
478
|
+
<div class="setting-row">
|
|
479
|
+
<div><div class="setting-label">Notify on publish</div><div class="setting-desc">Send email when an entry is published</div></div>
|
|
480
|
+
<input type="checkbox" name="email.notify_publish" value="1" ${get('email.notify_publish')==='1'?'checked':''} style="accent-color:var(--accent);width:14px;height:14px;" />
|
|
481
|
+
</div>
|
|
482
|
+
<div class="setting-row">
|
|
483
|
+
<div><div class="setting-label">Notify on comment</div><div class="setting-desc">Send email when a new comment is posted</div></div>
|
|
484
|
+
<input type="checkbox" name="email.notify_comment" value="1" ${get('email.notify_comment')==='1'?'checked':''} style="accent-color:var(--accent);width:14px;height:14px;" />
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
|
|
452
488
|
<div class="save-row">
|
|
453
489
|
<button type="submit" class="btn-save">Save settings</button>
|
|
454
490
|
</div>
|
|
@@ -668,9 +704,19 @@
|
|
|
668
704
|
['media.s3_access_key', fd.get('media.s3_access_key')],
|
|
669
705
|
['media.s3_secret_key', fd.get('media.s3_secret_key')],
|
|
670
706
|
['media.s3_public_url', fd.get('media.s3_public_url')],
|
|
671
|
-
['api.enabled',
|
|
672
|
-
['api.token',
|
|
673
|
-
['preview.token',
|
|
707
|
+
['api.enabled', fd.get('api.enabled') ? '1' : '0'],
|
|
708
|
+
['api.token', fd.get('api.token')],
|
|
709
|
+
['preview.token', document.getElementById('preview-token-display').value || null],
|
|
710
|
+
['media.img_max_width', fd.get('media.img_max_width')],
|
|
711
|
+
['media.img_quality', fd.get('media.img_quality')],
|
|
712
|
+
['email.smtp_host', fd.get('email.smtp_host')],
|
|
713
|
+
['email.smtp_port', fd.get('email.smtp_port')],
|
|
714
|
+
['email.smtp_user', fd.get('email.smtp_user')],
|
|
715
|
+
['email.smtp_pass', fd.get('email.smtp_pass')],
|
|
716
|
+
['email.smtp_from', fd.get('email.smtp_from')],
|
|
717
|
+
['email.notify_to', fd.get('email.notify_to')],
|
|
718
|
+
['email.notify_publish', fd.get('email.notify_publish') ? '1' : '0'],
|
|
719
|
+
['email.notify_comment', fd.get('email.notify_comment') ? '1' : '0'],
|
|
674
720
|
]);
|
|
675
721
|
showBanner('site-banner','banner-ok','Settings saved');
|
|
676
722
|
});
|
package/src/email.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
|
+
import { openPod } from '@a83/orbiter-core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Send a notification email if SMTP is configured.
|
|
6
|
+
* @param {string} podPath
|
|
7
|
+
* @param {string} event — 'publish' | 'comment'
|
|
8
|
+
* @param {object} ctx — { collection, slug, username, body? }
|
|
9
|
+
*/
|
|
10
|
+
export async function sendNotification(podPath, event, ctx = {}) {
|
|
11
|
+
let db;
|
|
12
|
+
try {
|
|
13
|
+
db = openPod(podPath);
|
|
14
|
+
const shouldSend = event === 'publish'
|
|
15
|
+
? db.getMeta('email.notify_publish') === '1'
|
|
16
|
+
: db.getMeta('email.notify_comment') === '1';
|
|
17
|
+
if (!shouldSend) { db.close(); return; }
|
|
18
|
+
|
|
19
|
+
const host = db.getMeta('email.smtp_host') ?? '';
|
|
20
|
+
const port = parseInt(db.getMeta('email.smtp_port') ?? '587', 10);
|
|
21
|
+
const user = db.getMeta('email.smtp_user') ?? '';
|
|
22
|
+
const pass = db.getMeta('email.smtp_pass') ?? '';
|
|
23
|
+
const from = db.getMeta('email.smtp_from') || user;
|
|
24
|
+
const to = db.getMeta('email.notify_to') ?? '';
|
|
25
|
+
const site = db.getMeta('site.name') ?? 'Orbiter';
|
|
26
|
+
db.close();
|
|
27
|
+
|
|
28
|
+
if (!host || !to) return;
|
|
29
|
+
|
|
30
|
+
const transport = nodemailer.createTransport({
|
|
31
|
+
host, port,
|
|
32
|
+
secure: port === 465,
|
|
33
|
+
auth: user ? { user, pass } : undefined,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
let subject, text;
|
|
37
|
+
if (event === 'publish') {
|
|
38
|
+
subject = `[${site}] Entry published: ${ctx.collection}/${ctx.slug}`;
|
|
39
|
+
text = `${ctx.username ?? 'Someone'} just published "${ctx.slug}" in the "${ctx.collection}" collection.`;
|
|
40
|
+
} else {
|
|
41
|
+
subject = `[${site}] New comment on ${ctx.collection}/${ctx.slug}`;
|
|
42
|
+
text = `${ctx.username ?? 'Someone'} commented on "${ctx.slug}":\n\n${ctx.body ?? ''}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await transport.sendMail({ from, to, subject, text });
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.warn('[email]', e.message);
|
|
48
|
+
db?.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/routes/comments.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { openPod } from '@a83/orbiter-core';
|
|
3
|
+
import { sendNotification } from '../email.js';
|
|
3
4
|
|
|
4
5
|
export const commentRoutes = new Hono();
|
|
5
6
|
|
|
@@ -25,6 +26,7 @@ commentRoutes.post('/:collectionId/entries/:slug/comments', async (c) => {
|
|
|
25
26
|
const username = c.get('user')?.username ?? 'unknown';
|
|
26
27
|
const id = db.createComment(entry.id, username, body.trim());
|
|
27
28
|
db.close();
|
|
29
|
+
sendNotification(c.get('podPath'), 'comment', { collection: collectionId, slug, username, body: body.trim() }).catch(()=>{});
|
|
28
30
|
return c.json({ ok: true, id }, 201);
|
|
29
31
|
});
|
|
30
32
|
|
package/src/routes/entries.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { openPod } from '@a83/orbiter-core';
|
|
3
|
+
import { sendNotification } from '../email.js';
|
|
3
4
|
|
|
4
5
|
export const entryRoutes = new Hono();
|
|
5
6
|
|
|
@@ -108,6 +109,7 @@ entryRoutes.put('/:collectionId/entries/:slug', async (c) => {
|
|
|
108
109
|
|
|
109
110
|
if (body.status === 'published' && before?.status !== 'published') {
|
|
110
111
|
fireWebhook(c.get('podPath'));
|
|
112
|
+
sendNotification(c.get('podPath'), 'publish', { collection: collectionId, slug: body.slug ?? slug, username }).catch(()=>{});
|
|
111
113
|
}
|
|
112
114
|
return c.json(updated);
|
|
113
115
|
});
|
|
@@ -274,14 +276,15 @@ entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
|
|
|
274
276
|
// PATCH /api/collections/:id/entries/:slug/status
|
|
275
277
|
entryRoutes.patch('/:collectionId/entries/:slug/status', async (c) => {
|
|
276
278
|
const { collectionId, slug } = c.req.param();
|
|
277
|
-
const { status, publish_at } = await c.req.json();
|
|
279
|
+
const { status, publish_at, unpublish_at } = await c.req.json();
|
|
278
280
|
if (!['draft', 'published', 'scheduled'].includes(status)) return c.json({ error: 'Invalid status' }, 400);
|
|
279
281
|
if (status === 'scheduled' && !publish_at) return c.json({ error: 'publish_at required for scheduled status' }, 400);
|
|
280
282
|
const db = openPod(c.get('podPath'));
|
|
281
283
|
const entry = db.getEntry(collectionId, slug);
|
|
282
284
|
if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
283
|
-
const pa
|
|
284
|
-
|
|
285
|
+
const pa = status === 'scheduled' ? publish_at : null;
|
|
286
|
+
const ua = status === 'published' ? (unpublish_at ?? null) : null;
|
|
287
|
+
db.updateEntry(collectionId, slug, { slug, data: entry.data, status, publish_at: pa, unpublish_at: ua });
|
|
285
288
|
const username = c.get('user')?.username ?? 'unknown';
|
|
286
289
|
if (status === 'scheduled') db.logAudit(entry.id, username, 'schedule');
|
|
287
290
|
db.close();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { openPod } from '@a83/orbiter-core';
|
|
3
|
+
|
|
4
|
+
export const lockRoutes = new Hono();
|
|
5
|
+
|
|
6
|
+
const STALE_MS = 90_000; // lock expires after 90 s without refresh
|
|
7
|
+
|
|
8
|
+
function lockKey(collection, slug) {
|
|
9
|
+
return `lock.${collection}.${slug}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseLock(val) {
|
|
13
|
+
if (!val) return null;
|
|
14
|
+
const [username, ts] = val.split('|');
|
|
15
|
+
const age = Date.now() - new Date(ts).getTime();
|
|
16
|
+
if (age > STALE_MS) return null; // stale — treat as free
|
|
17
|
+
return { username, ts };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// POST /api/locks/:collection/:slug — claim or refresh lock
|
|
21
|
+
lockRoutes.post('/:collection/:slug', (c) => {
|
|
22
|
+
const { collection, slug } = c.req.param();
|
|
23
|
+
const username = c.get('user')?.username ?? 'unknown';
|
|
24
|
+
const db = openPod(c.get('podPath'));
|
|
25
|
+
const key = lockKey(collection, slug);
|
|
26
|
+
const existing = parseLock(db.getMeta(key));
|
|
27
|
+
|
|
28
|
+
if (existing && existing.username !== username) {
|
|
29
|
+
db.close();
|
|
30
|
+
return c.json({ locked: true, by: existing.username }, 409);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
db.setMeta(key, `${username}|${new Date().toISOString()}`);
|
|
34
|
+
db.close();
|
|
35
|
+
return c.json({ locked: false });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// DELETE /api/locks/:collection/:slug — release lock
|
|
39
|
+
lockRoutes.delete('/:collection/:slug', (c) => {
|
|
40
|
+
const { collection, slug } = c.req.param();
|
|
41
|
+
const username = c.get('user')?.username ?? 'unknown';
|
|
42
|
+
const db = openPod(c.get('podPath'));
|
|
43
|
+
const key = lockKey(collection, slug);
|
|
44
|
+
const existing = parseLock(db.getMeta(key));
|
|
45
|
+
if (existing && existing.username === username) db.setMeta(key, '');
|
|
46
|
+
db.close();
|
|
47
|
+
return c.json({ ok: true });
|
|
48
|
+
});
|
package/src/routes/meta.js
CHANGED
|
@@ -15,6 +15,8 @@ const ALLOWED_KEYS = [
|
|
|
15
15
|
'dashboard.notes', 'dashboard.todos',
|
|
16
16
|
'ui.theme',
|
|
17
17
|
'format_version',
|
|
18
|
+
'email.smtp_host', 'email.smtp_port', 'email.smtp_user', 'email.smtp_pass',
|
|
19
|
+
'email.smtp_from', 'email.notify_publish', 'email.notify_comment', 'email.notify_to',
|
|
18
20
|
];
|
|
19
21
|
|
|
20
22
|
const PREVIEW_URL_RE = /^preview_url\.[a-z0-9_-]+$/;
|
package/src/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { githubRoutes } from './routes/github.js';
|
|
|
23
23
|
import { infoRoutes } from './routes/info.js';
|
|
24
24
|
import { importRoutes } from './routes/import.js';
|
|
25
25
|
import { commentRoutes } from './routes/comments.js';
|
|
26
|
+
import { lockRoutes } from './routes/locks.js';
|
|
26
27
|
import { requireAuth } from './middleware/auth.js';
|
|
27
28
|
|
|
28
29
|
const { version: adminVersion } = JSON.parse(
|
|
@@ -74,6 +75,7 @@ export function createApp(podPath) {
|
|
|
74
75
|
api.route('/import', importRoutes);
|
|
75
76
|
api.route('/collections', commentRoutes);
|
|
76
77
|
api.route('/', commentRoutes);
|
|
78
|
+
api.route('/locks', lockRoutes);
|
|
77
79
|
|
|
78
80
|
app.route('/api', api);
|
|
79
81
|
|
|
@@ -101,17 +103,23 @@ serve({ fetch: createApp(POD_PATH).fetch, port: PORT }, () => {
|
|
|
101
103
|
setInterval(() => {
|
|
102
104
|
try {
|
|
103
105
|
const db = openPod(POD_PATH);
|
|
104
|
-
const due
|
|
105
|
-
|
|
106
|
+
const due = db.getScheduledDue();
|
|
107
|
+
const expired = db.getExpiredDue();
|
|
108
|
+
if (!due.length && !expired.length) { db.close(); return; }
|
|
106
109
|
const now = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
107
110
|
for (const entry of due) {
|
|
108
111
|
db.db.prepare("UPDATE _entries SET status = 'published', publish_at = NULL, updated_at = ? WHERE id = ?").run(now, entry.id);
|
|
109
112
|
db.logAudit(entry.id, 'scheduler', 'publish');
|
|
110
113
|
}
|
|
114
|
+
for (const entry of expired) {
|
|
115
|
+
db.db.prepare("UPDATE _entries SET status = 'draft', unpublish_at = NULL, updated_at = ? WHERE id = ?").run(now, entry.id);
|
|
116
|
+
db.logAudit(entry.id, 'scheduler', 'unpublish');
|
|
117
|
+
}
|
|
111
118
|
const webhookUrl = db.getMeta('build.webhook_url') ?? '';
|
|
112
119
|
if (webhookUrl) db.setMeta('build.last_triggered', new Date().toISOString());
|
|
113
120
|
db.close();
|
|
114
|
-
console.log(`[scheduler] Published ${due.length} scheduled entr${due.length === 1 ? 'y' : 'ies'}`);
|
|
121
|
+
if (due.length) console.log(`[scheduler] Published ${due.length} scheduled entr${due.length === 1 ? 'y' : 'ies'}`);
|
|
122
|
+
if (expired.length) console.log(`[scheduler] Unpublished ${expired.length} expired entr${expired.length === 1 ? 'y' : 'ies'}`);
|
|
115
123
|
if (webhookUrl) fetch(webhookUrl, { method: 'POST' }).catch(() => {});
|
|
116
124
|
} catch (e) {
|
|
117
125
|
console.warn('[scheduler]', e.message);
|