@a83/orbiter-admin 0.3.11 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a83/orbiter-admin",
3
- "version": "0.3.11",
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
  }
@@ -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'); }
@@ -691,6 +707,24 @@
691
707
  </div>
692
708
  ${versionsData.length ? `<div class="meta-section"><div class="meta-label">History</div>${versHtml}</div>` : ''}
693
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>`}
694
728
  `;
695
729
 
696
730
  // Wire up weekday toggles
@@ -770,6 +804,47 @@
770
804
  });
771
805
  });
772
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
+
773
848
  // Slug input → slug preview
774
849
  document.getElementById('slug-input').addEventListener('input', e=>{
775
850
  e.target.dataset.manual='1';
@@ -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
+ });
@@ -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 buffer = Buffer.from(await file.arrayBuffer());
62
- const id = randomUUID();
63
- const db = openPod(c.get('podPath'));
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 = (resp.headers.get('content-type') || 'application/octet-stream').split(';')[0].trim();
103
- const buffer = Buffer.from(await resp.arrayBuffer());
104
- const filename = url.split('/').pop()?.split('?')[0] || 'imported';
105
- const id = randomUUID();
106
- const db = openPod(c.get('podPath'));
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);
@@ -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