@a83/orbiter-admin 0.3.10 → 0.3.12
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 +91 -2
- package/public/entries.html +28 -1
- package/public/settings.html +12 -0
- package/src/routes/comments.js +48 -0
- package/src/routes/entries.js +86 -0
- package/src/routes/media.js +31 -8
- package/src/routes/meta.js +1 -0
- package/src/server.js +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a83/orbiter-admin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
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
|
@@ -133,6 +133,21 @@
|
|
|
133
133
|
.media-drop-zone img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
|
|
134
134
|
.media-drop-label { font-size:9px; color:var(--muted); letter-spacing:.08em; position:relative; z-index:1; }
|
|
135
135
|
.media-drop-zone.has-image .media-drop-label { display:none; }
|
|
136
|
+
/* Comments */
|
|
137
|
+
.comment-item { padding:8px 0; border-bottom:1px solid var(--line2); }
|
|
138
|
+
.comment-item:last-child { border-bottom:none; }
|
|
139
|
+
.comment-item.resolved { opacity:.45; }
|
|
140
|
+
.comment-meta { display:flex; align-items:center; gap:6px; margin-bottom:4px; }
|
|
141
|
+
.comment-user { font-size:10px; font-family:var(--mono); color:var(--accent); }
|
|
142
|
+
.comment-date { font-size:10px; color:var(--muted); margin-left:auto; }
|
|
143
|
+
.comment-body { font-size:12px; color:var(--text); line-height:1.5; word-break:break-word; }
|
|
144
|
+
.comment-actions { display:flex; gap:6px; margin-top:4px; }
|
|
145
|
+
.comment-actions button { font-size:9px; font-family:var(--mono); background:none; border:none; color:var(--muted); cursor:pointer; padding:0; }
|
|
146
|
+
.comment-actions button:hover { color:var(--text); }
|
|
147
|
+
.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; }
|
|
148
|
+
.comment-input:focus { border-color:var(--accent); }
|
|
149
|
+
.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; }
|
|
150
|
+
.comment-submit:hover { background:var(--accent); color:var(--bg0); }
|
|
136
151
|
.weekday-grid { display:flex; gap:4px; flex-wrap:wrap; margin-top:4px; }
|
|
137
152
|
.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
153
|
.wd-btn.active { background:var(--accent-bg); border-color:var(--accent); color:var(--accent); }
|
|
@@ -486,12 +501,13 @@
|
|
|
486
501
|
document.getElementById('collection-id-display').textContent = COLLECTION;
|
|
487
502
|
|
|
488
503
|
// Load collection schema + entry
|
|
489
|
-
const [colData, entryData, versionsData, activityData, mediaData] = await Promise.all([
|
|
504
|
+
const [colData, entryData, versionsData, activityData, mediaData, commentsData] = await Promise.all([
|
|
490
505
|
fetch(`/api/collections/${COLLECTION}`,{credentials:'include'}).then(r=>r.ok?r.json():null),
|
|
491
506
|
IS_NEW ? null : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}`,{credentials:'include'}).then(r=>r.ok?r.json():null),
|
|
492
507
|
IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/versions`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
|
|
493
508
|
IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/activity`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
|
|
494
509
|
fetch('/api/media',{credentials:'include'}).then(r=>r.json()).catch(()=>[]),
|
|
510
|
+
IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/comments`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
|
|
495
511
|
]);
|
|
496
512
|
|
|
497
513
|
if (!colData) { location.replace('/collections.html'); }
|
|
@@ -633,10 +649,11 @@
|
|
|
633
649
|
}
|
|
634
650
|
|
|
635
651
|
const versHtml = versionsData.slice(0,8).map((v,i)=>`
|
|
636
|
-
<div class="version-row">
|
|
652
|
+
<div class="version-row" data-vid="${v.id}">
|
|
637
653
|
<div class="v-dot${i===0?' cur':''}"></div>
|
|
638
654
|
<div class="v-hash">${v.id.slice(0,7)}</div>
|
|
639
655
|
<div class="v-time">${new Date(v.created_at).toLocaleDateString()}</div>
|
|
656
|
+
${i===0 ? '' : `<button class="btn-row v-restore-btn" data-vid="${v.id}" style="margin-left:auto;font-size:9px;padding:2px 6px">Restore</button>`}
|
|
640
657
|
</div>
|
|
641
658
|
`).join('');
|
|
642
659
|
|
|
@@ -690,6 +707,24 @@
|
|
|
690
707
|
</div>
|
|
691
708
|
${versionsData.length ? `<div class="meta-section"><div class="meta-label">History</div>${versHtml}</div>` : ''}
|
|
692
709
|
${activityData.length ? `<div class="meta-section"><div class="meta-label">Activity</div>${actHtml}</div>` : ''}
|
|
710
|
+
${IS_NEW ? '' : `<div class="meta-section" id="comments-section">
|
|
711
|
+
<div class="meta-label">Comments <span id="comment-count" style="color:var(--muted)">${commentsData.length ? '(' + commentsData.length + ')' : ''}</span></div>
|
|
712
|
+
<div id="comments-list">${commentsData.map(cm=>`
|
|
713
|
+
<div class="comment-item${cm.resolved?` resolved`:''}" data-cid="${cm.id}">
|
|
714
|
+
<div class="comment-meta">
|
|
715
|
+
<span class="comment-user">${escHtml(cm.username)}</span>
|
|
716
|
+
<span class="comment-date">${new Date(cm.created_at).toLocaleDateString()}</span>
|
|
717
|
+
</div>
|
|
718
|
+
<div class="comment-body">${escHtml(cm.body)}</div>
|
|
719
|
+
<div class="comment-actions">
|
|
720
|
+
<button class="cm-resolve-btn" data-cid="${cm.id}" data-resolved="${cm.resolved}">${cm.resolved?'Unresolve':'Resolve'}</button>
|
|
721
|
+
<button class="cm-delete-btn" data-cid="${cm.id}">Delete</button>
|
|
722
|
+
</div>
|
|
723
|
+
</div>
|
|
724
|
+
`).join('')}</div>
|
|
725
|
+
<textarea class="comment-input" id="comment-input" placeholder="Add a comment…" rows="2"></textarea>
|
|
726
|
+
<button class="comment-submit" id="comment-submit">Post comment</button>
|
|
727
|
+
</div>`}
|
|
693
728
|
`;
|
|
694
729
|
|
|
695
730
|
// Wire up weekday toggles
|
|
@@ -756,6 +791,60 @@
|
|
|
756
791
|
saveEntry('scheduled');
|
|
757
792
|
});
|
|
758
793
|
|
|
794
|
+
// Version restore buttons
|
|
795
|
+
panel.querySelectorAll('.v-restore-btn').forEach(btn => {
|
|
796
|
+
btn.addEventListener('click', async () => {
|
|
797
|
+
if (!confirm('Restore this version? Current content will be overwritten.')) return;
|
|
798
|
+
const vid = btn.dataset.vid;
|
|
799
|
+
const targetSlug = currentSlug || SLUG;
|
|
800
|
+
const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}/versions/${vid}/restore`, {
|
|
801
|
+
method: 'POST', credentials: 'include',
|
|
802
|
+
});
|
|
803
|
+
if (res.ok) { location.reload(); } else { alert('Restore failed.'); }
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// Comments
|
|
808
|
+
if (!IS_NEW) {
|
|
809
|
+
const targetSlug = currentSlug || SLUG;
|
|
810
|
+
|
|
811
|
+
document.getElementById('comment-submit')?.addEventListener('click', async () => {
|
|
812
|
+
const inp = document.getElementById('comment-input');
|
|
813
|
+
const body = inp.value.trim();
|
|
814
|
+
if (!body) return;
|
|
815
|
+
const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}/comments`, {
|
|
816
|
+
method: 'POST', credentials: 'include',
|
|
817
|
+
headers: { 'Content-Type': 'application/json' },
|
|
818
|
+
body: JSON.stringify({ body }),
|
|
819
|
+
});
|
|
820
|
+
if (res.ok) { inp.value = ''; location.reload(); }
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
panel.querySelectorAll('.cm-resolve-btn').forEach(btn => {
|
|
824
|
+
btn.addEventListener('click', async () => {
|
|
825
|
+
const resolved = btn.dataset.resolved === '0' || btn.dataset.resolved === 'false' ? true : false;
|
|
826
|
+
await fetch(`/api/comments/${btn.dataset.cid}/resolve`, {
|
|
827
|
+
method: 'PATCH', credentials: 'include',
|
|
828
|
+
headers: { 'Content-Type': 'application/json' },
|
|
829
|
+
body: JSON.stringify({ resolved }),
|
|
830
|
+
});
|
|
831
|
+
const item = btn.closest('.comment-item');
|
|
832
|
+
if (item) {
|
|
833
|
+
item.classList.toggle('resolved', resolved);
|
|
834
|
+
btn.textContent = resolved ? 'Unresolve' : 'Resolve';
|
|
835
|
+
btn.dataset.resolved = resolved ? '1' : '0';
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
panel.querySelectorAll('.cm-delete-btn').forEach(btn => {
|
|
841
|
+
btn.addEventListener('click', async () => {
|
|
842
|
+
await fetch(`/api/comments/${btn.dataset.cid}`, { method: 'DELETE', credentials: 'include' });
|
|
843
|
+
btn.closest('.comment-item')?.remove();
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
759
848
|
// Slug input → slug preview
|
|
760
849
|
document.getElementById('slug-input').addEventListener('input', e=>{
|
|
761
850
|
e.target.dataset.manual='1';
|
package/public/entries.html
CHANGED
|
@@ -90,7 +90,11 @@
|
|
|
90
90
|
<h1 class="page-title" id="page-title">Entries</h1>
|
|
91
91
|
<p class="page-sub" id="page-sub"></p>
|
|
92
92
|
</div>
|
|
93
|
-
<
|
|
93
|
+
<div style="display:flex;gap:6px;align-items:center;flex-shrink:0;margin-top:4px">
|
|
94
|
+
<a id="export-btn" href="#" class="btn btn-ghost btn-sm" title="Export as CSV">↓ CSV</a>
|
|
95
|
+
<label class="btn btn-ghost btn-sm" style="cursor:pointer" title="Import from CSV">↑ CSV<input type="file" id="import-file" accept=".csv" style="display:none" /></label>
|
|
96
|
+
<button class="btn btn-primary" id="new-btn">+ New entry</button>
|
|
97
|
+
</div>
|
|
94
98
|
</div>
|
|
95
99
|
|
|
96
100
|
<!-- Filter + table glass card -->
|
|
@@ -366,6 +370,29 @@
|
|
|
366
370
|
await bulkAction('permanent');
|
|
367
371
|
});
|
|
368
372
|
|
|
373
|
+
// CSV Export
|
|
374
|
+
document.getElementById('export-btn').href = `/api/collections/${colId}/entries/export.csv`;
|
|
375
|
+
|
|
376
|
+
// CSV Import
|
|
377
|
+
document.getElementById('import-file').addEventListener('change', async e => {
|
|
378
|
+
const file = e.target.files[0];
|
|
379
|
+
if (!file) return;
|
|
380
|
+
const text = await file.text();
|
|
381
|
+
const res = await fetch(`/api/collections/${colId}/entries/import.csv`, {
|
|
382
|
+
method: 'POST', credentials: 'include',
|
|
383
|
+
headers: { 'Content-Type': 'text/csv' },
|
|
384
|
+
body: text,
|
|
385
|
+
});
|
|
386
|
+
const json = await res.json();
|
|
387
|
+
if (res.ok) {
|
|
388
|
+
alert(`Import complete: ${json.created} created, ${json.updated} updated${json.skipped ? ', ' + json.skipped + ' skipped' : ''}.`);
|
|
389
|
+
renderEntries();
|
|
390
|
+
} else {
|
|
391
|
+
alert('Import failed: ' + (json.error ?? 'Unknown error'));
|
|
392
|
+
}
|
|
393
|
+
e.target.value = '';
|
|
394
|
+
});
|
|
395
|
+
|
|
369
396
|
// New entry modal
|
|
370
397
|
const overlay = document.getElementById('modal-overlay');
|
|
371
398
|
document.getElementById('new-btn').addEventListener('click', () => {
|
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
|
@@ -170,6 +170,92 @@ entryRoutes.get('/:collectionId/entries/:slug/versions', (c) => {
|
|
|
170
170
|
return c.json(versions);
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
+
// POST /api/collections/:id/entries/:slug/versions/:versionId/restore
|
|
174
|
+
entryRoutes.post('/:collectionId/entries/:slug/versions/:versionId/restore', (c) => {
|
|
175
|
+
const { collectionId, slug, versionId } = c.req.param();
|
|
176
|
+
const db = openPod(c.get('podPath'));
|
|
177
|
+
const entry = db.getEntry(collectionId, slug);
|
|
178
|
+
if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
179
|
+
const ok = db.restoreVersion(entry.id, versionId);
|
|
180
|
+
if (ok) db.logAudit(entry.id, c.get('user')?.username ?? 'unknown', 'restore_version');
|
|
181
|
+
db.close();
|
|
182
|
+
if (!ok) return c.json({ error: 'Version not found' }, 404);
|
|
183
|
+
return c.json({ ok: true });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// GET /api/collections/:id/entries/export.csv
|
|
187
|
+
entryRoutes.get('/:collectionId/entries/export.csv', (c) => {
|
|
188
|
+
const { collectionId } = c.req.param();
|
|
189
|
+
const db = openPod(c.get('podPath'));
|
|
190
|
+
const col = db.getCollection(collectionId);
|
|
191
|
+
if (!col) { db.close(); return c.json({ error: 'Collection not found' }, 404); }
|
|
192
|
+
const entries = db.getEntries(collectionId);
|
|
193
|
+
const schema = col.schema ? JSON.parse(col.schema) : {};
|
|
194
|
+
const fields = Object.keys(schema);
|
|
195
|
+
const headers = ['slug', 'status', ...fields];
|
|
196
|
+
const csvEsc = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
|
197
|
+
const rows = [headers.map(csvEsc).join(',')];
|
|
198
|
+
for (const e of entries) {
|
|
199
|
+
rows.push(headers.map(h => {
|
|
200
|
+
if (h === 'slug') return csvEsc(e.slug);
|
|
201
|
+
if (h === 'status') return csvEsc(e.status);
|
|
202
|
+
const v = e.data[h];
|
|
203
|
+
return csvEsc(Array.isArray(v) ? v.join(';') : v);
|
|
204
|
+
}).join(','));
|
|
205
|
+
}
|
|
206
|
+
db.close();
|
|
207
|
+
return new Response(rows.join('\n'), {
|
|
208
|
+
headers: {
|
|
209
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
210
|
+
'Content-Disposition': `attachment; filename="${collectionId}.csv"`,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// POST /api/collections/:id/entries/import.csv
|
|
216
|
+
entryRoutes.post('/:collectionId/entries/import.csv', async (c) => {
|
|
217
|
+
const { collectionId } = c.req.param();
|
|
218
|
+
const db = openPod(c.get('podPath'));
|
|
219
|
+
if (!db.getCollection(collectionId)) { db.close(); return c.json({ error: 'Collection not found' }, 404); }
|
|
220
|
+
const text = await c.req.text();
|
|
221
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
222
|
+
if (lines.length < 2) { db.close(); return c.json({ error: 'Empty CSV' }, 400); }
|
|
223
|
+
const parseCsv = line => {
|
|
224
|
+
const cols = []; let cur = ''; let inQ = false;
|
|
225
|
+
for (let i = 0; i < line.length; i++) {
|
|
226
|
+
const ch = line[i];
|
|
227
|
+
if (ch === '"' && !inQ) { inQ = true; continue; }
|
|
228
|
+
if (ch === '"' && inQ && line[i+1] === '"') { cur += '"'; i++; continue; }
|
|
229
|
+
if (ch === '"' && inQ) { inQ = false; continue; }
|
|
230
|
+
if (ch === ',' && !inQ) { cols.push(cur); cur = ''; continue; }
|
|
231
|
+
cur += ch;
|
|
232
|
+
}
|
|
233
|
+
cols.push(cur);
|
|
234
|
+
return cols;
|
|
235
|
+
};
|
|
236
|
+
const headers = parseCsv(lines[0]);
|
|
237
|
+
let created = 0, updated = 0, skipped = 0;
|
|
238
|
+
for (const line of lines.slice(1)) {
|
|
239
|
+
const vals = parseCsv(line);
|
|
240
|
+
const row = Object.fromEntries(headers.map((h, i) => [h, vals[i] ?? '']));
|
|
241
|
+
if (!row.slug) { skipped++; continue; }
|
|
242
|
+
const data = {};
|
|
243
|
+
for (const h of headers.filter(h => h !== 'slug' && h !== 'status')) {
|
|
244
|
+
data[h] = row[h];
|
|
245
|
+
}
|
|
246
|
+
const status = ['draft','published'].includes(row.status) ? row.status : 'draft';
|
|
247
|
+
if (db.getEntry(collectionId, row.slug)) {
|
|
248
|
+
db.updateEntry(collectionId, row.slug, { slug: row.slug, data, status });
|
|
249
|
+
updated++;
|
|
250
|
+
} else {
|
|
251
|
+
db.createEntry(collectionId, row.slug, data, status);
|
|
252
|
+
created++;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
db.close();
|
|
256
|
+
return c.json({ ok: true, created, updated, skipped });
|
|
257
|
+
});
|
|
258
|
+
|
|
173
259
|
// POST /api/collections/:id/entries/:slug/duplicate
|
|
174
260
|
entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
|
|
175
261
|
const { collectionId, slug } = c.req.param();
|
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
|
|