@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a83/orbiter-admin",
3
- "version": "0.3.11",
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
  }
@@ -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 = entryData?.publish_at ?? null;
551
- const publishAtInput = publishAt ? publishAt.replace(' ', 'T').slice(0, 16) : '';
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>
@@ -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
+ });
@@ -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 = status === 'scheduled' ? publish_at : null;
284
- db.updateEntry(collectionId, slug, { slug, data: entry.data, status, publish_at: pa });
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();
@@ -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
 
@@ -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 = db.getScheduledDue();
102
- if (!due.length) { db.close(); return; }
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);