@a83/orbiter-admin 0.3.11 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/public/editor.html +122 -4
- package/public/settings.html +12 -0
- package/src/routes/comments.js +48 -0
- package/src/routes/entries.js +4 -3
- package/src/routes/media.js +31 -8
- package/src/routes/meta.js +1 -0
- package/src/server.js +12 -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.13",
|
|
4
4
|
"description": "Standalone admin server for Orbiter CMS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server.js",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@a83/orbiter-core": "^0.3.2",
|
|
30
30
|
"@hono/node-server": "^1.14.4",
|
|
31
|
-
"hono": "^4.7.11"
|
|
31
|
+
"hono": "^4.7.11",
|
|
32
|
+
"sharp": "^0.34.5"
|
|
32
33
|
}
|
|
33
34
|
}
|
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; }
|
|
@@ -133,6 +137,21 @@
|
|
|
133
137
|
.media-drop-zone img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
|
|
134
138
|
.media-drop-label { font-size:9px; color:var(--muted); letter-spacing:.08em; position:relative; z-index:1; }
|
|
135
139
|
.media-drop-zone.has-image .media-drop-label { display:none; }
|
|
140
|
+
/* Comments */
|
|
141
|
+
.comment-item { padding:8px 0; border-bottom:1px solid var(--line2); }
|
|
142
|
+
.comment-item:last-child { border-bottom:none; }
|
|
143
|
+
.comment-item.resolved { opacity:.45; }
|
|
144
|
+
.comment-meta { display:flex; align-items:center; gap:6px; margin-bottom:4px; }
|
|
145
|
+
.comment-user { font-size:10px; font-family:var(--mono); color:var(--accent); }
|
|
146
|
+
.comment-date { font-size:10px; color:var(--muted); margin-left:auto; }
|
|
147
|
+
.comment-body { font-size:12px; color:var(--text); line-height:1.5; word-break:break-word; }
|
|
148
|
+
.comment-actions { display:flex; gap:6px; margin-top:4px; }
|
|
149
|
+
.comment-actions button { font-size:9px; font-family:var(--mono); background:none; border:none; color:var(--muted); cursor:pointer; padding:0; }
|
|
150
|
+
.comment-actions button:hover { color:var(--text); }
|
|
151
|
+
.comment-input { 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; resize:vertical; transition:border-color .15s; border-radius:var(--radius); box-sizing:border-box; min-height:56px; }
|
|
152
|
+
.comment-input:focus { border-color:var(--accent); }
|
|
153
|
+
.comment-submit { margin-top:4px; width:100%; padding:6px; background:var(--accent-bg); border:1px solid var(--accent); color:var(--accent); font-family:var(--mono); font-size:10px; cursor:pointer; border-radius:var(--radius); transition:background .12s; }
|
|
154
|
+
.comment-submit:hover { background:var(--accent); color:var(--bg0); }
|
|
136
155
|
.weekday-grid { display:flex; gap:4px; flex-wrap:wrap; margin-top:4px; }
|
|
137
156
|
.wd-btn { height:26px; min-width:32px; padding:0 4px; background:var(--bg0); border:1px solid var(--line); color:var(--muted); font-family:var(--mono); font-size:9px; cursor:pointer; transition:all .1s; border-radius:2px; }
|
|
138
157
|
.wd-btn.active { background:var(--accent-bg); border-color:var(--accent); color:var(--accent); }
|
|
@@ -384,6 +403,7 @@
|
|
|
384
403
|
<button type="button" class="view-btn" id="vbtn-preview" onclick="setViewMode('preview')">Preview</button>
|
|
385
404
|
</div>
|
|
386
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>
|
|
387
407
|
<button class="scheme-toggle" id="scheme-toggle" title="Toggle scheme">◐</button>
|
|
388
408
|
<span class="user" id="topbar-user"></span>
|
|
389
409
|
<span class="logout" id="logout-btn">Sign out</span>
|
|
@@ -486,12 +506,13 @@
|
|
|
486
506
|
document.getElementById('collection-id-display').textContent = COLLECTION;
|
|
487
507
|
|
|
488
508
|
// Load collection schema + entry
|
|
489
|
-
const [colData, entryData, versionsData, activityData, mediaData] = await Promise.all([
|
|
509
|
+
const [colData, entryData, versionsData, activityData, mediaData, commentsData] = await Promise.all([
|
|
490
510
|
fetch(`/api/collections/${COLLECTION}`,{credentials:'include'}).then(r=>r.ok?r.json():null),
|
|
491
511
|
IS_NEW ? null : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}`,{credentials:'include'}).then(r=>r.ok?r.json():null),
|
|
492
512
|
IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/versions`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
|
|
493
513
|
IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/activity`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
|
|
494
514
|
fetch('/api/media',{credentials:'include'}).then(r=>r.json()).catch(()=>[]),
|
|
515
|
+
IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/comments`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
|
|
495
516
|
]);
|
|
496
517
|
|
|
497
518
|
if (!colData) { location.replace('/collections.html'); }
|
|
@@ -547,8 +568,10 @@
|
|
|
547
568
|
const panel = document.getElementById('meta-panel');
|
|
548
569
|
const status = IS_NEW ? 'draft' : (entryData?.status ?? 'draft');
|
|
549
570
|
const updatedAt = entryData?.updated_at;
|
|
550
|
-
const publishAt
|
|
551
|
-
const
|
|
571
|
+
const publishAt = entryData?.publish_at ?? null;
|
|
572
|
+
const unpublishAt = entryData?.unpublish_at ?? null;
|
|
573
|
+
const publishAtInput = publishAt ? publishAt.replace(' ', 'T').slice(0, 16) : '';
|
|
574
|
+
const unpublishAtInput = unpublishAt ? unpublishAt.replace(' ', 'T').slice(0, 16) : '';
|
|
552
575
|
|
|
553
576
|
let fieldsHtml = '';
|
|
554
577
|
const seoFields = {};
|
|
@@ -672,6 +695,13 @@
|
|
|
672
695
|
<button type="button" id="btn-schedule-confirm" style="padding:5px 10px;background:var(--accent);border:none;color:var(--bg0);font-family:var(--mono);font-size:10px;cursor:pointer;border-radius:var(--radius);white-space:nowrap;">Set</button>
|
|
673
696
|
</div>
|
|
674
697
|
<button type="button" class="btn-schedule" id="btn-schedule">${status==='scheduled'?'Reschedule':'Schedule'}</button>
|
|
698
|
+
${status==='published'?`
|
|
699
|
+
<div class="expiry-picker${unpublishAt?' open':''}" id="expiry-picker">
|
|
700
|
+
<input type="datetime-local" class="field-input" id="unpublish-at-input" value="${unpublishAtInput}" style="flex:1;font-size:10px;padding:5px 8px;" />
|
|
701
|
+
<button type="button" id="btn-expiry-confirm" style="padding:5px 10px;background:var(--bg3);border:1px solid var(--line);color:var(--text);font-family:var(--mono);font-size:10px;cursor:pointer;border-radius:var(--radius);white-space:nowrap;">Set</button>
|
|
702
|
+
</div>
|
|
703
|
+
<button type="button" class="btn-expiry" id="btn-expiry">${unpublishAt?'Change expiry':'Set expiry'}</button>
|
|
704
|
+
`:''}
|
|
675
705
|
</div>
|
|
676
706
|
<div class="meta-section">
|
|
677
707
|
<div class="meta-label">Details</div>
|
|
@@ -691,6 +721,24 @@
|
|
|
691
721
|
</div>
|
|
692
722
|
${versionsData.length ? `<div class="meta-section"><div class="meta-label">History</div>${versHtml}</div>` : ''}
|
|
693
723
|
${activityData.length ? `<div class="meta-section"><div class="meta-label">Activity</div>${actHtml}</div>` : ''}
|
|
724
|
+
${IS_NEW ? '' : `<div class="meta-section" id="comments-section">
|
|
725
|
+
<div class="meta-label">Comments <span id="comment-count" style="color:var(--muted)">${commentsData.length ? '(' + commentsData.length + ')' : ''}</span></div>
|
|
726
|
+
<div id="comments-list">${commentsData.map(cm=>`
|
|
727
|
+
<div class="comment-item${cm.resolved?` resolved`:''}" data-cid="${cm.id}">
|
|
728
|
+
<div class="comment-meta">
|
|
729
|
+
<span class="comment-user">${escHtml(cm.username)}</span>
|
|
730
|
+
<span class="comment-date">${new Date(cm.created_at).toLocaleDateString()}</span>
|
|
731
|
+
</div>
|
|
732
|
+
<div class="comment-body">${escHtml(cm.body)}</div>
|
|
733
|
+
<div class="comment-actions">
|
|
734
|
+
<button class="cm-resolve-btn" data-cid="${cm.id}" data-resolved="${cm.resolved}">${cm.resolved?'Unresolve':'Resolve'}</button>
|
|
735
|
+
<button class="cm-delete-btn" data-cid="${cm.id}">Delete</button>
|
|
736
|
+
</div>
|
|
737
|
+
</div>
|
|
738
|
+
`).join('')}</div>
|
|
739
|
+
<textarea class="comment-input" id="comment-input" placeholder="Add a comment…" rows="2"></textarea>
|
|
740
|
+
<button class="comment-submit" id="comment-submit">Post comment</button>
|
|
741
|
+
</div>`}
|
|
694
742
|
`;
|
|
695
743
|
|
|
696
744
|
// Wire up weekday toggles
|
|
@@ -756,6 +804,12 @@
|
|
|
756
804
|
if (!val) { alert('Please select a date and time.'); return; }
|
|
757
805
|
saveEntry('scheduled');
|
|
758
806
|
});
|
|
807
|
+
document.getElementById('btn-expiry')?.addEventListener('click', () => {
|
|
808
|
+
document.getElementById('expiry-picker').classList.toggle('open');
|
|
809
|
+
});
|
|
810
|
+
document.getElementById('btn-expiry-confirm')?.addEventListener('click', () => {
|
|
811
|
+
saveEntry('published');
|
|
812
|
+
});
|
|
759
813
|
|
|
760
814
|
// Version restore buttons
|
|
761
815
|
panel.querySelectorAll('.v-restore-btn').forEach(btn => {
|
|
@@ -770,6 +824,47 @@
|
|
|
770
824
|
});
|
|
771
825
|
});
|
|
772
826
|
|
|
827
|
+
// Comments
|
|
828
|
+
if (!IS_NEW) {
|
|
829
|
+
const targetSlug = currentSlug || SLUG;
|
|
830
|
+
|
|
831
|
+
document.getElementById('comment-submit')?.addEventListener('click', async () => {
|
|
832
|
+
const inp = document.getElementById('comment-input');
|
|
833
|
+
const body = inp.value.trim();
|
|
834
|
+
if (!body) return;
|
|
835
|
+
const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}/comments`, {
|
|
836
|
+
method: 'POST', credentials: 'include',
|
|
837
|
+
headers: { 'Content-Type': 'application/json' },
|
|
838
|
+
body: JSON.stringify({ body }),
|
|
839
|
+
});
|
|
840
|
+
if (res.ok) { inp.value = ''; location.reload(); }
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
panel.querySelectorAll('.cm-resolve-btn').forEach(btn => {
|
|
844
|
+
btn.addEventListener('click', async () => {
|
|
845
|
+
const resolved = btn.dataset.resolved === '0' || btn.dataset.resolved === 'false' ? true : false;
|
|
846
|
+
await fetch(`/api/comments/${btn.dataset.cid}/resolve`, {
|
|
847
|
+
method: 'PATCH', credentials: 'include',
|
|
848
|
+
headers: { 'Content-Type': 'application/json' },
|
|
849
|
+
body: JSON.stringify({ resolved }),
|
|
850
|
+
});
|
|
851
|
+
const item = btn.closest('.comment-item');
|
|
852
|
+
if (item) {
|
|
853
|
+
item.classList.toggle('resolved', resolved);
|
|
854
|
+
btn.textContent = resolved ? 'Unresolve' : 'Resolve';
|
|
855
|
+
btn.dataset.resolved = resolved ? '1' : '0';
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
panel.querySelectorAll('.cm-delete-btn').forEach(btn => {
|
|
861
|
+
btn.addEventListener('click', async () => {
|
|
862
|
+
await fetch(`/api/comments/${btn.dataset.cid}`, { method: 'DELETE', credentials: 'include' });
|
|
863
|
+
btn.closest('.comment-item')?.remove();
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
773
868
|
// Slug input → slug preview
|
|
774
869
|
document.getElementById('slug-input').addEventListener('input', e=>{
|
|
775
870
|
e.target.dataset.manual='1';
|
|
@@ -931,6 +1026,12 @@
|
|
|
931
1026
|
if (!raw) { if (!isAutosave) alert('Please select a date and time.'); return; }
|
|
932
1027
|
publish_at = raw.replace('T', ' ') + ':00';
|
|
933
1028
|
}
|
|
1029
|
+
// Resolve unpublish_at for published entries with expiry
|
|
1030
|
+
let unpublish_at = null;
|
|
1031
|
+
if (status === 'published') {
|
|
1032
|
+
const raw = document.getElementById('unpublish-at-input')?.value;
|
|
1033
|
+
unpublish_at = raw ? raw.replace('T', ' ') + ':00' : null;
|
|
1034
|
+
}
|
|
934
1035
|
|
|
935
1036
|
const title = document.getElementById('title-input').value;
|
|
936
1037
|
syncToHidden();
|
|
@@ -954,6 +1055,22 @@
|
|
|
954
1055
|
}
|
|
955
1056
|
}
|
|
956
1057
|
|
|
1058
|
+
// Required field validation (skip on autosave)
|
|
1059
|
+
if (!isAutosave) {
|
|
1060
|
+
const missing = extraFields
|
|
1061
|
+
.filter(([key, field]) => {
|
|
1062
|
+
if (!field.required) return false;
|
|
1063
|
+
const val = data[key];
|
|
1064
|
+
if (Array.isArray(val)) return val.length === 0;
|
|
1065
|
+
return val === '' || val === null || val === undefined;
|
|
1066
|
+
})
|
|
1067
|
+
.map(([key, field]) => field.label || key);
|
|
1068
|
+
if (missing.length) {
|
|
1069
|
+
alert(`Required field${missing.length > 1 ? 's' : ''} missing: ${missing.join(', ')}`);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
957
1074
|
if (IS_NEW && !currentSlug) {
|
|
958
1075
|
// Create new entry
|
|
959
1076
|
const res = await fetch(`/api/collections/${COLLECTION}/entries`,{
|
|
@@ -978,7 +1095,7 @@
|
|
|
978
1095
|
const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}`,{
|
|
979
1096
|
method:'PUT', credentials:'include',
|
|
980
1097
|
headers:{'Content-Type':'application/json'},
|
|
981
|
-
body: JSON.stringify({ slug, data, status, publish_at }),
|
|
1098
|
+
body: JSON.stringify({ slug, data, status, publish_at, unpublish_at }),
|
|
982
1099
|
});
|
|
983
1100
|
if (res.ok) {
|
|
984
1101
|
const json = await res.json();
|
|
@@ -1866,6 +1983,7 @@
|
|
|
1866
1983
|
<input type="file" id="img-file-inp" accept="image/*" style="display:none">
|
|
1867
1984
|
|
|
1868
1985
|
<script src="/sidebar.js"></script>
|
|
1986
|
+
<script src="/search.js"></script>
|
|
1869
1987
|
<script src="/router.js"></script>
|
|
1870
1988
|
</body>
|
|
1871
1989
|
</html>
|
package/public/settings.html
CHANGED
|
@@ -437,6 +437,18 @@
|
|
|
437
437
|
</div>
|
|
438
438
|
</div>
|
|
439
439
|
|
|
440
|
+
<div class="settings-group">
|
|
441
|
+
<div class="group-header">Image optimization</div>
|
|
442
|
+
<div class="setting-row">
|
|
443
|
+
<div><div class="setting-label">Max width</div><div class="setting-desc">Images wider than this are resized on upload (px). Leave blank to disable.</div></div>
|
|
444
|
+
<input class="input" name="media.img_max_width" type="number" min="400" max="8000" step="100" value="${get('media.img_max_width')||'2400'}" placeholder="2400" />
|
|
445
|
+
</div>
|
|
446
|
+
<div class="setting-row">
|
|
447
|
+
<div><div class="setting-label">Quality</div><div class="setting-desc">JPEG / WebP / AVIF compression quality (1–100)</div></div>
|
|
448
|
+
<input class="input" name="media.img_quality" type="number" min="1" max="100" value="${get('media.img_quality')||'85'}" placeholder="85" />
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
|
|
440
452
|
<div class="save-row">
|
|
441
453
|
<button type="submit" class="btn-save">Save settings</button>
|
|
442
454
|
</div>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { openPod } from '@a83/orbiter-core';
|
|
3
|
+
|
|
4
|
+
export const commentRoutes = new Hono();
|
|
5
|
+
|
|
6
|
+
// GET /api/collections/:id/entries/:slug/comments
|
|
7
|
+
commentRoutes.get('/:collectionId/entries/:slug/comments', (c) => {
|
|
8
|
+
const { collectionId, slug } = c.req.param();
|
|
9
|
+
const db = openPod(c.get('podPath'));
|
|
10
|
+
const entry = db.db.prepare('SELECT id FROM _entries WHERE collection_id = ? AND slug = ?').get(collectionId, slug);
|
|
11
|
+
if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
12
|
+
const comments = db.getComments(entry.id);
|
|
13
|
+
db.close();
|
|
14
|
+
return c.json(comments);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// POST /api/collections/:id/entries/:slug/comments
|
|
18
|
+
commentRoutes.post('/:collectionId/entries/:slug/comments', async (c) => {
|
|
19
|
+
const { collectionId, slug } = c.req.param();
|
|
20
|
+
const { body } = await c.req.json();
|
|
21
|
+
if (!body?.trim()) return c.json({ error: 'body required' }, 400);
|
|
22
|
+
const db = openPod(c.get('podPath'));
|
|
23
|
+
const entry = db.db.prepare('SELECT id FROM _entries WHERE collection_id = ? AND slug = ?').get(collectionId, slug);
|
|
24
|
+
if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
25
|
+
const username = c.get('user')?.username ?? 'unknown';
|
|
26
|
+
const id = db.createComment(entry.id, username, body.trim());
|
|
27
|
+
db.close();
|
|
28
|
+
return c.json({ ok: true, id }, 201);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// PATCH /api/comments/:id/resolve
|
|
32
|
+
commentRoutes.patch('/comments/:id/resolve', async (c) => {
|
|
33
|
+
const { id } = c.req.param();
|
|
34
|
+
const { resolved } = await c.req.json();
|
|
35
|
+
const db = openPod(c.get('podPath'));
|
|
36
|
+
db.resolveComment(id, resolved !== false);
|
|
37
|
+
db.close();
|
|
38
|
+
return c.json({ ok: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// DELETE /api/comments/:id
|
|
42
|
+
commentRoutes.delete('/comments/:id', (c) => {
|
|
43
|
+
const { id } = c.req.param();
|
|
44
|
+
const db = openPod(c.get('podPath'));
|
|
45
|
+
db.deleteComment(id);
|
|
46
|
+
db.close();
|
|
47
|
+
return c.json({ ok: true });
|
|
48
|
+
});
|
package/src/routes/entries.js
CHANGED
|
@@ -274,14 +274,15 @@ entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
|
|
|
274
274
|
// PATCH /api/collections/:id/entries/:slug/status
|
|
275
275
|
entryRoutes.patch('/:collectionId/entries/:slug/status', async (c) => {
|
|
276
276
|
const { collectionId, slug } = c.req.param();
|
|
277
|
-
const { status, publish_at } = await c.req.json();
|
|
277
|
+
const { status, publish_at, unpublish_at } = await c.req.json();
|
|
278
278
|
if (!['draft', 'published', 'scheduled'].includes(status)) return c.json({ error: 'Invalid status' }, 400);
|
|
279
279
|
if (status === 'scheduled' && !publish_at) return c.json({ error: 'publish_at required for scheduled status' }, 400);
|
|
280
280
|
const db = openPod(c.get('podPath'));
|
|
281
281
|
const entry = db.getEntry(collectionId, slug);
|
|
282
282
|
if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
283
|
-
const pa
|
|
284
|
-
|
|
283
|
+
const pa = status === 'scheduled' ? publish_at : null;
|
|
284
|
+
const ua = status === 'published' ? (unpublish_at ?? null) : null;
|
|
285
|
+
db.updateEntry(collectionId, slug, { slug, data: entry.data, status, publish_at: pa, unpublish_at: ua });
|
|
285
286
|
const username = c.get('user')?.username ?? 'unknown';
|
|
286
287
|
if (status === 'scheduled') db.logAudit(entry.id, username, 'schedule');
|
|
287
288
|
db.close();
|
package/src/routes/media.js
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { openPod, getMediaBackend } from '@a83/orbiter-core';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
|
|
6
|
+
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/tiff']);
|
|
7
|
+
|
|
8
|
+
async function optimizeImage(buffer, mimeType, db) {
|
|
9
|
+
if (!IMAGE_TYPES.has(mimeType)) return buffer;
|
|
10
|
+
const maxWidth = parseInt(db.getMeta('media.img_max_width') ?? '2400', 10);
|
|
11
|
+
const quality = parseInt(db.getMeta('media.img_quality') ?? '85', 10);
|
|
12
|
+
try {
|
|
13
|
+
const img = sharp(buffer);
|
|
14
|
+
const meta = await img.metadata();
|
|
15
|
+
if (meta.width && meta.width > maxWidth) img.resize({ width: maxWidth, withoutEnlargement: true });
|
|
16
|
+
if (mimeType === 'image/jpeg') return await img.jpeg({ quality, mozjpeg: true }).toBuffer();
|
|
17
|
+
if (mimeType === 'image/png') return await img.png({ quality }).toBuffer();
|
|
18
|
+
if (mimeType === 'image/webp') return await img.webp({ quality }).toBuffer();
|
|
19
|
+
if (mimeType === 'image/avif') return await img.avif({ quality }).toBuffer();
|
|
20
|
+
return await img.toBuffer();
|
|
21
|
+
} catch {
|
|
22
|
+
return buffer;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
4
25
|
|
|
5
26
|
export const mediaRoutes = new Hono();
|
|
6
27
|
|
|
@@ -58,11 +79,12 @@ mediaRoutes.post('/', async (c) => {
|
|
|
58
79
|
|
|
59
80
|
if (!file || typeof file === 'string') return c.json({ error: 'No file provided' }, 400);
|
|
60
81
|
|
|
61
|
-
const
|
|
62
|
-
const id
|
|
63
|
-
const db
|
|
82
|
+
const rawBuffer = Buffer.from(await file.arrayBuffer());
|
|
83
|
+
const id = randomUUID();
|
|
84
|
+
const db = openPod(c.get('podPath'));
|
|
64
85
|
|
|
65
86
|
try {
|
|
87
|
+
const buffer = await optimizeImage(rawBuffer, file.type, db);
|
|
66
88
|
const backend = getMediaBackend(db);
|
|
67
89
|
await backend.upload(id, file.name, file.type, buffer.byteLength, buffer, alt, folder);
|
|
68
90
|
const item = db.getMediaItem(id);
|
|
@@ -99,13 +121,14 @@ mediaRoutes.post('/import-url', async (c) => {
|
|
|
99
121
|
}
|
|
100
122
|
if (!resp.ok) return c.json({ error: `Remote returned ${resp.status}` }, 400);
|
|
101
123
|
|
|
102
|
-
const mime
|
|
103
|
-
const
|
|
104
|
-
const filename
|
|
105
|
-
const id
|
|
106
|
-
const db
|
|
124
|
+
const mime = (resp.headers.get('content-type') || 'application/octet-stream').split(';')[0].trim();
|
|
125
|
+
const rawBuffer = Buffer.from(await resp.arrayBuffer());
|
|
126
|
+
const filename = url.split('/').pop()?.split('?')[0] || 'imported';
|
|
127
|
+
const id = randomUUID();
|
|
128
|
+
const db = openPod(c.get('podPath'));
|
|
107
129
|
|
|
108
130
|
try {
|
|
131
|
+
const buffer = await optimizeImage(rawBuffer, mime, db);
|
|
109
132
|
const backend = getMediaBackend(db);
|
|
110
133
|
await backend.upload(id, filename, mime, buffer.byteLength, buffer, alt ?? null, folder ?? '');
|
|
111
134
|
const item = db.getMediaItem(id);
|
package/src/routes/meta.js
CHANGED
|
@@ -10,6 +10,7 @@ const ALLOWED_KEYS = [
|
|
|
10
10
|
'media.backend', 'media.local_path',
|
|
11
11
|
'media.github_token', 'media.github_repo', 'media.github_branch', 'media.github_dir',
|
|
12
12
|
'media.s3_bucket', 'media.s3_region', 'media.s3_endpoint', 'media.s3_access_key', 'media.s3_secret_key', 'media.s3_public_url',
|
|
13
|
+
'media.img_max_width', 'media.img_quality',
|
|
13
14
|
'api.enabled', 'api.token', 'preview.token',
|
|
14
15
|
'dashboard.notes', 'dashboard.todos',
|
|
15
16
|
'ui.theme',
|
package/src/server.js
CHANGED
|
@@ -22,6 +22,7 @@ import { searchRoutes } from './routes/search.js';
|
|
|
22
22
|
import { githubRoutes } from './routes/github.js';
|
|
23
23
|
import { infoRoutes } from './routes/info.js';
|
|
24
24
|
import { importRoutes } from './routes/import.js';
|
|
25
|
+
import { commentRoutes } from './routes/comments.js';
|
|
25
26
|
import { requireAuth } from './middleware/auth.js';
|
|
26
27
|
|
|
27
28
|
const { version: adminVersion } = JSON.parse(
|
|
@@ -71,6 +72,8 @@ export function createApp(podPath) {
|
|
|
71
72
|
api.route('/github', githubRoutes);
|
|
72
73
|
api.route('/info', infoRoutes);
|
|
73
74
|
api.route('/import', importRoutes);
|
|
75
|
+
api.route('/collections', commentRoutes);
|
|
76
|
+
api.route('/', commentRoutes);
|
|
74
77
|
|
|
75
78
|
app.route('/api', api);
|
|
76
79
|
|
|
@@ -98,17 +101,23 @@ serve({ fetch: createApp(POD_PATH).fetch, port: PORT }, () => {
|
|
|
98
101
|
setInterval(() => {
|
|
99
102
|
try {
|
|
100
103
|
const db = openPod(POD_PATH);
|
|
101
|
-
const due
|
|
102
|
-
|
|
104
|
+
const due = db.getScheduledDue();
|
|
105
|
+
const expired = db.getExpiredDue();
|
|
106
|
+
if (!due.length && !expired.length) { db.close(); return; }
|
|
103
107
|
const now = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
104
108
|
for (const entry of due) {
|
|
105
109
|
db.db.prepare("UPDATE _entries SET status = 'published', publish_at = NULL, updated_at = ? WHERE id = ?").run(now, entry.id);
|
|
106
110
|
db.logAudit(entry.id, 'scheduler', 'publish');
|
|
107
111
|
}
|
|
112
|
+
for (const entry of expired) {
|
|
113
|
+
db.db.prepare("UPDATE _entries SET status = 'draft', unpublish_at = NULL, updated_at = ? WHERE id = ?").run(now, entry.id);
|
|
114
|
+
db.logAudit(entry.id, 'scheduler', 'unpublish');
|
|
115
|
+
}
|
|
108
116
|
const webhookUrl = db.getMeta('build.webhook_url') ?? '';
|
|
109
117
|
if (webhookUrl) db.setMeta('build.last_triggered', new Date().toISOString());
|
|
110
118
|
db.close();
|
|
111
|
-
console.log(`[scheduler] Published ${due.length} scheduled entr${due.length === 1 ? 'y' : 'ies'}`);
|
|
119
|
+
if (due.length) console.log(`[scheduler] Published ${due.length} scheduled entr${due.length === 1 ? 'y' : 'ies'}`);
|
|
120
|
+
if (expired.length) console.log(`[scheduler] Unpublished ${expired.length} expired entr${expired.length === 1 ? 'y' : 'ies'}`);
|
|
112
121
|
if (webhookUrl) fetch(webhookUrl, { method: 'POST' }).catch(() => {});
|
|
113
122
|
} catch (e) {
|
|
114
123
|
console.warn('[scheduler]', e.message);
|