@a83/orbiter-admin 0.3.19 → 0.3.21

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.19",
3
+ "version": "0.3.21",
4
4
  "description": "Standalone admin server for Orbiter CMS",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
@@ -381,6 +381,14 @@
381
381
  .serp-url { font-size:9px; color:var(--jade); margin-bottom:3px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
382
382
  .serp-title { font-size:13px; color:#8ab4f8; margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
383
383
  .serp-desc { font-size:10px; color:var(--mid); line-height:1.5; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
384
+ /* Locale switcher bar */
385
+ .locale-bar { display:flex; align-items:center; gap:6px; padding:6px 20px; border-bottom:1px solid var(--line); background:var(--bg1); flex-shrink:0; }
386
+ .locale-bar-label { font-size:9px; color:var(--muted); text-transform:uppercase; letter-spacing:0.12em; margin-right:2px; }
387
+ .locale-tab { font-size:10px; font-family:var(--mono); letter-spacing:0.08em; text-transform:uppercase; padding:3px 10px; border:1px solid var(--line); border-radius:12px; cursor:pointer; background:none; color:var(--muted); text-decoration:none; transition:all .12s; }
388
+ .locale-tab:hover { border-color:var(--accent); color:var(--accent); }
389
+ .locale-tab.active { background:var(--accent-bg); border-color:var(--accent); color:var(--accent); font-weight:500; }
390
+ .locale-tab.missing { border-style:dashed; opacity:0.55; }
391
+ .locale-tab.missing:hover { opacity:1; border-style:solid; }
384
392
  </style>
385
393
  </head>
386
394
  <body>
@@ -440,6 +448,10 @@
440
448
  </button>
441
449
  </div>
442
450
  </div>
451
+ <div id="locale-bar" class="locale-bar" style="display:none">
452
+ <span class="locale-bar-label">Locale</span>
453
+ <div id="locale-tabs" style="display:flex;gap:5px;"></div>
454
+ </div>
443
455
  <div class="editor-scroll">
444
456
  <div class="editor-page">
445
457
  <textarea id="title-input" class="editor-title-input" placeholder="Untitled" rows="1"
@@ -488,10 +500,11 @@
488
500
  location.replace('/login.html');
489
501
  });
490
502
 
491
- // Parse ?collection=X&slug=Y&singleton=1 from URL
503
+ // Parse ?collection=X&slug=Y&singleton=1&locale=de from URL
492
504
  const params = new URLSearchParams(location.search);
493
505
  const COLLECTION = params.get('collection');
494
506
  const IS_SINGLETON = params.get('singleton') === '1';
507
+ const LOCALE = params.get('locale') ?? '';
495
508
 
496
509
  if (!COLLECTION) { location.replace('/collections.html'); }
497
510
 
@@ -505,21 +518,46 @@
505
518
  }
506
519
  }
507
520
  const IS_NEW = SLUG === 'new';
521
+ const localeQ = LOCALE ? `?locale=${encodeURIComponent(LOCALE)}` : '';
508
522
 
509
523
  document.getElementById('collection-id-display').textContent = COLLECTION;
510
524
 
511
- // Load collection schema + entry
512
- const [colData, entryData, versionsData, activityData, mediaData, commentsData] = await Promise.all([
525
+ // Load collection schema + entry + locale meta
526
+ const [colData, entryData, versionsData, activityData, mediaData, commentsData, siteLocalesMeta, entryLocalesData] = await Promise.all([
513
527
  fetch(`/api/collections/${COLLECTION}`,{credentials:'include'}).then(r=>r.ok?r.json():null),
514
- IS_NEW ? null : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}`,{credentials:'include'}).then(r=>r.ok?r.json():null),
528
+ IS_NEW ? null : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}${localeQ}`,{credentials:'include'}).then(r=>r.ok?r.json():null),
515
529
  IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/versions`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
516
530
  IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/activity`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
517
531
  fetch('/api/media',{credentials:'include'}).then(r=>r.json()).catch(()=>[]),
518
532
  IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/comments`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
533
+ fetch('/api/meta/site~locales',{credentials:'include'}).then(r=>r.ok?r.json():null).catch(()=>null),
534
+ IS_NEW ? [] : fetch(`/api/collections/${COLLECTION}/entries/${SLUG}/locales`,{credentials:'include'}).then(r=>r.ok?r.json():[]).catch(()=>[]),
519
535
  ]);
520
536
 
537
+ // IS_TRANSLATION_NEW: slug exists but no entry for this locale yet
538
+ const IS_TRANSLATION_NEW = !IS_NEW && !entryData && LOCALE !== '';
539
+
521
540
  if (!colData) { location.replace('/collections.html'); }
522
541
 
542
+ // ── Locale switcher ───────────────────────────────────────────────
543
+ const siteLocales = (siteLocalesMeta?.value ?? '').split(',').map(s => s.trim()).filter(Boolean);
544
+ if (siteLocales.length > 1 && !IS_NEW) {
545
+ const existingLocales = new Set(entryLocalesData.map(l => l.locale));
546
+ const bar = document.getElementById('locale-bar');
547
+ const tabsEl = document.getElementById('locale-tabs');
548
+ tabsEl.innerHTML = siteLocales.map((loc, i) => {
549
+ // First locale in the list maps to the default (locale='')
550
+ const dbLocale = i === 0 ? '' : loc;
551
+ const isActive = dbLocale === LOCALE;
552
+ const exists = existingLocales.has(dbLocale);
553
+ const localeParam = dbLocale ? `&locale=${encodeURIComponent(dbLocale)}` : '';
554
+ const href = `/editor.html?collection=${encodeURIComponent(COLLECTION)}&slug=${encodeURIComponent(SLUG)}${localeParam}`;
555
+ const title = exists ? loc.toUpperCase() : `${loc.toUpperCase()} — click to create`;
556
+ return `<a href="${href}" class="locale-tab${isActive?' active':''}${!exists?' missing':''}" title="${title}">${loc.toUpperCase()}</a>`;
557
+ }).join('');
558
+ bar.style.display = 'flex';
559
+ }
560
+
523
561
  // Entry locking — claim lock, warn if someone else is editing
524
562
  let lockHeld = false;
525
563
  let lockHeartbeat = null;
@@ -552,7 +590,8 @@
552
590
  // Breadcrumb
553
591
  document.getElementById('back-to-col').textContent = colData.label;
554
592
  document.getElementById('back-to-col').href = `/entries.html?col=${COLLECTION}&label=${encodeURIComponent(colData.label)}`;
555
- document.getElementById('breadcrumb-slug').textContent = IS_NEW ? 'New entry' : SLUG;
593
+ const breadcrumbSlug = IS_NEW ? 'New entry' : IS_TRANSLATION_NEW ? `${SLUG} [${LOCALE}] — new translation` : LOCALE ? `${SLUG} [${LOCALE}]` : SLUG;
594
+ document.getElementById('breadcrumb-slug').textContent = breadcrumbSlug;
556
595
  document.title = `${IS_NEW ? 'New entry' : SLUG} — ${colData.label} — Orbiter`;
557
596
 
558
597
  const schema = colData.schema ? (typeof colData.schema==='string'?JSON.parse(colData.schema):colData.schema) : {};
@@ -1103,19 +1142,20 @@
1103
1142
  }
1104
1143
  }
1105
1144
 
1106
- if (IS_NEW && !currentSlug) {
1107
- // Create new entry
1145
+ const localeParam = LOCALE ? `&locale=${encodeURIComponent(LOCALE)}` : '';
1146
+ if ((IS_NEW || IS_TRANSLATION_NEW) && !currentSlug) {
1147
+ // Create new entry (or new translation of existing slug)
1108
1148
  const res = await fetch(`/api/collections/${COLLECTION}/entries`,{
1109
1149
  method:'POST', credentials:'include',
1110
1150
  headers:{'Content-Type':'application/json'},
1111
- body: JSON.stringify({ slug, data, status, publish_at }),
1151
+ body: JSON.stringify({ slug, data, status, publish_at, locale: LOCALE }),
1112
1152
  });
1113
1153
  const json = await res.json();
1114
1154
  if (json.slug || json.id) {
1115
1155
  currentSlug = json.slug ?? slug;
1116
1156
  document.getElementById('slug-preview').textContent = currentSlug;
1117
1157
  slugInput.value = currentSlug;
1118
- const newUrl = `/editor.html?collection=${COLLECTION}&slug=${currentSlug}`;
1158
+ const newUrl = `/editor.html?collection=${COLLECTION}&slug=${currentSlug}${localeParam}`;
1119
1159
  history.replaceState(null,'',newUrl);
1120
1160
  currentPath = newUrl;
1121
1161
  setIndicator('saved');
@@ -1127,13 +1167,13 @@
1127
1167
  const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}`,{
1128
1168
  method:'PUT', credentials:'include',
1129
1169
  headers:{'Content-Type':'application/json'},
1130
- body: JSON.stringify({ slug, data, status, publish_at, unpublish_at }),
1170
+ body: JSON.stringify({ slug, data, status, publish_at, unpublish_at, locale: LOCALE }),
1131
1171
  });
1132
1172
  if (res.ok) {
1133
1173
  const json = await res.json();
1134
1174
  if (json.slug && json.slug!==currentSlug) {
1135
1175
  currentSlug = json.slug;
1136
- const newUrl = `/editor.html?collection=${COLLECTION}&slug=${currentSlug}`;
1176
+ const newUrl = `/editor.html?collection=${COLLECTION}&slug=${currentSlug}${localeParam}`;
1137
1177
  history.replaceState(null,'',newUrl);
1138
1178
  document.getElementById('slug-preview').textContent = currentSlug;
1139
1179
  slugInput.value = currentSlug;
@@ -143,9 +143,38 @@
143
143
  document.getElementById('col-label').textContent = colLabel;
144
144
  document.getElementById('page-title').textContent = colLabel;
145
145
 
146
- let allEntries = [];
146
+ // Load site locales to build locale filter tabs
147
+ const siteLocalesMeta = await fetch('/api/meta/site~locales',{credentials:'include'}).then(r=>r.ok?r.json():null).catch(()=>null);
148
+ const siteLocales = (siteLocalesMeta?.value ?? '').split(',').map(s => s.trim()).filter(Boolean);
149
+
150
+ let allEntries = [];
147
151
  let activeFilter = '';
148
- let selected = new Set();
152
+ let activeLocale = ''; // '' = default/primary locale
153
+ let selected = new Set();
154
+
155
+ // Render locale tabs if multiple locales configured
156
+ if (siteLocales.length > 1) {
157
+ const filterBar = document.querySelector('.entries-filter-bar');
158
+ const sep = document.createElement('div');
159
+ sep.style.cssText = 'width:1px;background:var(--line);margin:0 4px;';
160
+ filterBar.insertBefore(sep, filterBar.querySelector('[data-status="trash"]'));
161
+ siteLocales.forEach((loc, i) => {
162
+ const dbLocale = i === 0 ? '' : loc;
163
+ const btn = document.createElement('button');
164
+ btn.className = 'filter-tab' + (dbLocale === '' ? ' active' : '');
165
+ btn.dataset.locale = dbLocale;
166
+ btn.textContent = loc.toUpperCase();
167
+ btn.title = i === 0 ? `${loc.toUpperCase()} (default)` : loc.toUpperCase();
168
+ btn.style.cssText = 'margin-left:' + (i === 0 ? '4px' : '0');
169
+ btn.addEventListener('click', () => {
170
+ document.querySelectorAll('.filter-tab[data-locale]').forEach(b => b.classList.remove('active'));
171
+ btn.classList.add('active');
172
+ activeLocale = dbLocale;
173
+ loadEntries();
174
+ });
175
+ filterBar.insertBefore(btn, filterBar.querySelector('[data-status="trash"]'));
176
+ });
177
+ }
149
178
 
150
179
  function updateBulkBar() {
151
180
  const bar = document.getElementById('bulk-bar');
@@ -163,7 +192,10 @@
163
192
  async function loadEntries() {
164
193
  selected.clear();
165
194
  updateBulkBar();
166
- const url = `/api/collections/${colId}/entries` + (activeFilter ? `?status=${activeFilter}` : '');
195
+ const qp = new URLSearchParams();
196
+ if (activeFilter) qp.set('status', activeFilter);
197
+ if (activeLocale !== undefined) qp.set('locale', activeLocale);
198
+ const url = `/api/collections/${colId}/entries` + (qp.toString() ? '?' + qp.toString() : '');
167
199
  allEntries = await fetch(url, { credentials: 'include' }).then(r => r.json());
168
200
  renderEntries(allEntries);
169
201
  }
@@ -195,11 +227,13 @@
195
227
  const nextStatus = e.status === 'published' ? 'draft' : 'published';
196
228
  const toggleLabel = e.status === 'published' ? 'Unpublish' : e.status === 'scheduled' ? 'Publish now' : 'Publish';
197
229
  const schedInfo = e.status === 'scheduled' && e.publish_at ? ` · ${e.publish_at.split(' ')[0]}` : '';
198
- return `<tr data-slug="${e.slug}"${canSort ? ' draggable="true"' : ''}>
230
+ const localeParam = e.locale ? `&locale=${encodeURIComponent(e.locale)}` : '';
231
+ const localeBadge = e.locale ? `<span style="font-family:var(--mono);font-size:9px;color:var(--muted);background:var(--bg3);border:1px solid var(--line);border-radius:4px;padding:1px 5px;margin-left:5px;vertical-align:middle">${e.locale}</span>` : '';
232
+ return `<tr data-slug="${e.slug}" data-locale="${e.locale ?? ''}"${canSort ? ' draggable="true"' : ''}>
199
233
  ${canSort ? `<td class="drag-col"><span class="drag-handle" title="Drag to reorder">⠿</span></td>` : ''}
200
- <td class="cb-col"><input type="checkbox" class="row-cb" data-slug="${e.slug}" ${selected.has(e.slug) ? 'checked' : ''} /></td>
234
+ <td class="cb-col"><input type="checkbox" class="row-cb" data-slug="${e.slug}" data-locale="${e.locale ?? ''}" ${selected.has(e.slug) ? 'checked' : ''} /></td>
201
235
  <td>
202
- <div style="color:var(--heading);font-weight:500">${title}</div>
236
+ <div style="color:var(--heading);font-weight:500">${title}${localeBadge}</div>
203
237
  <div style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-top:2px">${e.slug}</div>
204
238
  </td>
205
239
  <td>${isTrash ? `<span style="font-family:var(--mono);font-size:11px;color:var(--muted)">${deleted}</span>` : `<span class="badge badge-${e.status}">${e.status}${schedInfo}</span>`}</td>
@@ -207,12 +241,12 @@
207
241
  <td style="width:1%;white-space:nowrap">
208
242
  <div class="row-actions">
209
243
  ${isTrash
210
- ? `<button class="btn-row restore-btn" data-slug="${e.slug}">Restore</button>
211
- <button class="btn-row btn-row-danger perm-del-btn" data-slug="${e.slug}">Delete forever</button>`
212
- : `<a class="btn-row" href="/editor.html?collection=${colId}&slug=${e.slug}">Edit</a>
213
- <button class="btn-row btn-row-toggle status-toggle" data-slug="${e.slug}" data-next="${nextStatus}">${toggleLabel}</button>
214
- <button class="btn-row btn-row-icon dup-btn" data-slug="${e.slug}" title="Duplicate">⧉</button>
215
- <button class="btn-row btn-row-danger delete-btn" data-slug="${e.slug}">Trash</button>`
244
+ ? `<button class="btn-row restore-btn" data-slug="${e.slug}" data-locale="${e.locale ?? ''}">Restore</button>
245
+ <button class="btn-row btn-row-danger perm-del-btn" data-slug="${e.slug}" data-locale="${e.locale ?? ''}">Delete forever</button>`
246
+ : `<a class="btn-row" href="/editor.html?collection=${colId}&slug=${e.slug}${localeParam}">Edit</a>
247
+ <button class="btn-row btn-row-toggle status-toggle" data-slug="${e.slug}" data-locale="${e.locale ?? ''}" data-next="${nextStatus}">${toggleLabel}</button>
248
+ <button class="btn-row btn-row-icon dup-btn" data-slug="${e.slug}" data-locale="${e.locale ?? ''}" title="Duplicate">⧉</button>
249
+ <button class="btn-row btn-row-danger delete-btn" data-slug="${e.slug}" data-locale="${e.locale ?? ''}">Trash</button>`
216
250
  }
217
251
  </div>
218
252
  </td>
@@ -288,7 +322,7 @@
288
322
  await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}/status`, {
289
323
  method: 'PATCH', credentials: 'include',
290
324
  headers: { 'Content-Type': 'application/json' },
291
- body: JSON.stringify({ status: btn.dataset.next }),
325
+ body: JSON.stringify({ status: btn.dataset.next, locale: btn.dataset.locale ?? '' }),
292
326
  });
293
327
  loadEntries();
294
328
  });
@@ -298,7 +332,8 @@
298
332
  wrap.querySelectorAll('.dup-btn').forEach(btn => {
299
333
  btn.addEventListener('click', async () => {
300
334
  btn.disabled = true;
301
- const res = await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}/duplicate`, {
335
+ const loc = btn.dataset.locale ?? '';
336
+ const res = await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}/duplicate${loc ? '?locale=' + encodeURIComponent(loc) : ''}`, {
302
337
  method: 'POST', credentials: 'include',
303
338
  });
304
339
  if (res.ok) loadEntries();
@@ -309,7 +344,8 @@
309
344
  // Delete → Trash
310
345
  wrap.querySelectorAll('.delete-btn').forEach(btn => {
311
346
  btn.addEventListener('click', async () => {
312
- await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}`, {
347
+ const loc = btn.dataset.locale ?? '';
348
+ await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}${loc ? '?locale=' + encodeURIComponent(loc) : ''}`, {
313
349
  method: 'DELETE', credentials: 'include',
314
350
  });
315
351
  loadEntries();
@@ -319,7 +355,8 @@
319
355
  // Restore from Trash
320
356
  wrap.querySelectorAll('.restore-btn').forEach(btn => {
321
357
  btn.addEventListener('click', async () => {
322
- await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}/restore`, {
358
+ const loc = btn.dataset.locale ?? '';
359
+ await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}/restore${loc ? '?locale=' + encodeURIComponent(loc) : ''}`, {
323
360
  method: 'POST', credentials: 'include',
324
361
  });
325
362
  loadEntries();
@@ -330,7 +367,8 @@
330
367
  wrap.querySelectorAll('.perm-del-btn').forEach(btn => {
331
368
  btn.addEventListener('click', async () => {
332
369
  if (!confirm(`Permanently delete "${btn.dataset.slug}"? This cannot be undone.`)) return;
333
- await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}/permanent`, {
370
+ const loc = btn.dataset.locale ?? '';
371
+ await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}/permanent${loc ? '?locale=' + encodeURIComponent(loc) : ''}`, {
334
372
  method: 'DELETE', credentials: 'include',
335
373
  });
336
374
  loadEntries();
@@ -356,7 +394,7 @@
356
394
  await fetch(`/api/collections/${colId}/entries/bulk`, {
357
395
  method: 'POST', credentials: 'include',
358
396
  headers: { 'Content-Type': 'application/json' },
359
- body: JSON.stringify({ action, slugs }),
397
+ body: JSON.stringify({ action, slugs, locale: activeLocale }),
360
398
  });
361
399
  loadEntries();
362
400
  }
@@ -424,11 +462,12 @@
424
462
  const res = await fetch(`/api/collections/${colId}/entries`, {
425
463
  method: 'POST', credentials: 'include',
426
464
  headers: { 'Content-Type': 'application/json' },
427
- body: JSON.stringify({ slug, data: { title }, status: 'draft' }),
465
+ body: JSON.stringify({ slug, data: { title }, status: 'draft', locale: activeLocale }),
428
466
  });
429
467
  if (res.ok) {
430
468
  overlay.style.display = 'none';
431
- location.href = `/editor.html?collection=${colId}&slug=${slug}`;
469
+ const localeParam = activeLocale ? `&locale=${encodeURIComponent(activeLocale)}` : '';
470
+ location.href = `/editor.html?collection=${colId}&slug=${slug}${localeParam}`;
432
471
  } else {
433
472
  const d = await res.json();
434
473
  errEl.textContent = d.error ?? 'Could not create entry';
@@ -0,0 +1,37 @@
1
+ /**
2
+ * CSRF protection via Origin / Referer header validation.
3
+ *
4
+ * sameSite: 'Strict' on the session cookie already blocks most CSRF vectors.
5
+ * This middleware adds a second layer: mutating requests (POST/PUT/PATCH/DELETE)
6
+ * must carry an Origin (or Referer) header that matches ALLOWED_ORIGINS.
7
+ *
8
+ * Browser fetch/XHR always sends Origin on cross-origin requests; same-origin
9
+ * requests send it on POST but not on GET. We only check mutating methods, so
10
+ * the case where Origin is absent on a same-site POST is fine — a same-site
11
+ * request is already allowed.
12
+ *
13
+ * Requests with no Origin AND no Referer (e.g. curl without headers, Postman)
14
+ * are allowed — they can't carry the session cookie cross-site anyway.
15
+ */
16
+
17
+ const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
18
+
19
+ export function csrfMiddleware(allowedOrigins) {
20
+ return async (c, next) => {
21
+ if (!MUTATING.has(c.req.method)) return next();
22
+
23
+ const origin = c.req.header('origin');
24
+ const referer = c.req.header('referer');
25
+
26
+ // No origin/referer — non-browser client; skip check
27
+ if (!origin && !referer) return next();
28
+
29
+ const candidate = origin ?? new URL(referer).origin;
30
+
31
+ if (!allowedOrigins.includes(candidate)) {
32
+ return c.json({ error: 'CSRF check failed' }, 403);
33
+ }
34
+
35
+ return next();
36
+ };
37
+ }
@@ -15,18 +15,18 @@ function fireWebhook(podPath) {
15
15
  // POST /api/collections/:id/entries/bulk — bulk publish or delete
16
16
  entryRoutes.post('/:collectionId/entries/bulk', async (c) => {
17
17
  const { collectionId } = c.req.param();
18
- const { action, slugs } = await c.req.json();
18
+ const { action, slugs, locale = '' } = await c.req.json();
19
19
  if (!Array.isArray(slugs) || !slugs.length) return c.json({ error: 'slugs required' }, 400);
20
20
  if (!['publish', 'draft', 'delete', 'restore', 'permanent'].includes(action)) return c.json({ error: 'Invalid action' }, 400);
21
21
  const db = openPod(c.get('podPath'));
22
- if (action === 'delete') { slugs.forEach(slug => db.deleteEntry(collectionId, slug)); }
23
- else if (action === 'restore') { slugs.forEach(slug => db.restoreEntry(collectionId, slug)); }
24
- else if (action === 'permanent') { slugs.forEach(slug => db.permanentDeleteEntry(collectionId, slug)); }
22
+ if (action === 'delete') { slugs.forEach(slug => db.deleteEntry(collectionId, slug, locale)); }
23
+ else if (action === 'restore') { slugs.forEach(slug => db.restoreEntry(collectionId, slug, locale)); }
24
+ else if (action === 'permanent') { slugs.forEach(slug => db.permanentDeleteEntry(collectionId, slug, locale)); }
25
25
  else {
26
26
  const status = action === 'publish' ? 'published' : 'draft';
27
27
  slugs.forEach(slug => {
28
- const entry = db.getEntry(collectionId, slug);
29
- if (entry) db.updateEntry(collectionId, slug, { slug, data: entry.data, status });
28
+ const entry = db.getEntry(collectionId, slug, locale);
29
+ if (entry) db.updateEntry(collectionId, slug, { slug, data: entry.data, status, locale });
30
30
  });
31
31
  }
32
32
  db.close();
@@ -47,39 +47,50 @@ entryRoutes.patch('/:collectionId/entries/reorder', async (c) => {
47
47
  return c.json({ ok: true });
48
48
  });
49
49
 
50
- // GET /api/collections/:id/entries?status=draft|published
50
+ // GET /api/collections/:id/entries?status=draft|published&locale=
51
51
  entryRoutes.get('/:collectionId/entries', (c) => {
52
52
  const { collectionId } = c.req.param();
53
- const status = c.req.query('status') || undefined;
54
- const db = openPod(c.get('podPath'));
53
+ const status = c.req.query('status') || undefined;
54
+ const locale = c.req.query('locale'); // undefined = all locales
55
+ const db = openPod(c.get('podPath'));
55
56
  if (!db.getCollection(collectionId)) { db.close(); return c.json({ error: 'Collection not found' }, 404); }
56
- const entries = db.getEntries(collectionId, { status });
57
+ const entries = db.getEntries(collectionId, { status, locale });
57
58
  db.close();
58
59
  return c.json(entries);
59
60
  });
60
61
 
61
- // GET /api/collections/:id/entries/:slug
62
+ // GET /api/collections/:id/entries/:slug?locale=
62
63
  entryRoutes.get('/:collectionId/entries/:slug', (c) => {
63
64
  const { collectionId, slug } = c.req.param();
64
- const db = openPod(c.get('podPath'));
65
- const entry = db.getEntry(collectionId, slug);
65
+ const locale = c.req.query('locale') ?? '';
66
+ const db = openPod(c.get('podPath'));
67
+ const entry = db.getEntry(collectionId, slug, locale);
66
68
  db.close();
67
69
  if (!entry) return c.json({ error: 'Not found' }, 404);
68
70
  return c.json(entry);
69
71
  });
70
72
 
73
+ // GET /api/collections/:id/entries/:slug/locales — list all locale versions
74
+ entryRoutes.get('/:collectionId/entries/:slug/locales', (c) => {
75
+ const { collectionId, slug } = c.req.param();
76
+ const db = openPod(c.get('podPath'));
77
+ const locales = db.getEntryLocales(collectionId, slug);
78
+ db.close();
79
+ return c.json(locales);
80
+ });
81
+
71
82
  // POST /api/collections/:id/entries
72
83
  entryRoutes.post('/:collectionId/entries', async (c) => {
73
84
  const { collectionId } = c.req.param();
74
- const { slug, data = {}, status = 'draft' } = await c.req.json();
85
+ const { slug, data = {}, status = 'draft', locale = '' } = await c.req.json();
75
86
  if (!slug) return c.json({ error: 'slug is required' }, 400);
76
87
 
77
88
  const db = openPod(c.get('podPath'));
78
89
  if (!db.getCollection(collectionId)) { db.close(); return c.json({ error: 'Collection not found' }, 404); }
79
- if (db.getEntry(collectionId, slug)) { db.close(); return c.json({ error: `Entry "${slug}" already exists` }, 409); }
90
+ if (db.getEntry(collectionId, slug, locale)) { db.close(); return c.json({ error: `Entry "${slug}" (${locale || 'default'}) already exists` }, 409); }
80
91
 
81
- const id = db.createEntry(collectionId, slug, data, status);
82
- const entry = db.getEntry(collectionId, slug);
92
+ const id = db.createEntry(collectionId, slug, data, status, locale);
93
+ const entry = db.getEntry(collectionId, slug, locale);
83
94
  db.logAudit(id, c.get('user')?.username ?? 'unknown', 'create');
84
95
  db.close();
85
96
  return c.json({ ...entry, id }, 201);
@@ -88,13 +99,14 @@ entryRoutes.post('/:collectionId/entries', async (c) => {
88
99
  // PUT /api/collections/:id/entries/:slug
89
100
  entryRoutes.put('/:collectionId/entries/:slug', async (c) => {
90
101
  const { collectionId, slug } = c.req.param();
91
- const body = await c.req.json();
102
+ const body = await c.req.json();
103
+ const locale = body.locale ?? c.req.query('locale') ?? '';
92
104
 
93
105
  const db = openPod(c.get('podPath'));
94
- const before = db.getEntry(collectionId, slug);
95
- const ok = db.updateEntry(collectionId, slug, body);
106
+ const before = db.getEntry(collectionId, slug, locale);
107
+ const ok = db.updateEntry(collectionId, slug, { ...body, locale });
96
108
  if (!ok) { db.close(); return c.json({ error: 'Not found' }, 404); }
97
- const updated = db.getEntry(collectionId, body.slug ?? slug);
109
+ const updated = db.getEntry(collectionId, body.slug ?? slug, locale);
98
110
  const username = c.get('user')?.username ?? 'unknown';
99
111
  if (body.status === 'published' && before?.status !== 'published') {
100
112
  db.logAudit(updated.id, username, 'publish');
@@ -114,35 +126,38 @@ entryRoutes.put('/:collectionId/entries/:slug', async (c) => {
114
126
  return c.json(updated);
115
127
  });
116
128
 
117
- // DELETE /api/collections/:id/entries/:slug → soft delete (trash)
129
+ // DELETE /api/collections/:id/entries/:slug?locale= → soft delete (trash)
118
130
  entryRoutes.delete('/:collectionId/entries/:slug', (c) => {
119
131
  const { collectionId, slug } = c.req.param();
120
- const db = openPod(c.get('podPath'));
121
- const entry = db.getEntry(collectionId, slug);
132
+ const locale = c.req.query('locale') ?? '';
133
+ const db = openPod(c.get('podPath'));
134
+ const entry = db.getEntry(collectionId, slug, locale);
122
135
  if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
123
- db.deleteEntry(collectionId, slug);
136
+ db.deleteEntry(collectionId, slug, locale);
124
137
  db.logAudit(entry.id, c.get('user')?.username ?? 'unknown', 'delete');
125
138
  db.close();
126
139
  return c.json({ ok: true });
127
140
  });
128
141
 
129
- // POST /api/collections/:id/entries/:slug/restore — move from trash back to draft
142
+ // POST /api/collections/:id/entries/:slug/restore?locale=
130
143
  entryRoutes.post('/:collectionId/entries/:slug/restore', (c) => {
131
144
  const { collectionId, slug } = c.req.param();
132
- const db = openPod(c.get('podPath'));
133
- const row = db.db.prepare('SELECT * FROM _entries WHERE collection_id = ? AND slug = ? AND deleted_at IS NOT NULL').get(collectionId, slug);
134
- const ok = db.restoreEntry(collectionId, slug);
145
+ const locale = c.req.query('locale') ?? '';
146
+ const db = openPod(c.get('podPath'));
147
+ const row = db.db.prepare('SELECT * FROM _entries WHERE collection_id = ? AND slug = ? AND locale = ? AND deleted_at IS NOT NULL').get(collectionId, slug, locale);
148
+ const ok = db.restoreEntry(collectionId, slug, locale);
135
149
  if (row) db.logAudit(row.id, c.get('user')?.username ?? 'unknown', 'restore');
136
150
  db.close();
137
151
  if (!ok) return c.json({ error: 'Not found in trash' }, 404);
138
152
  return c.json({ ok: true });
139
153
  });
140
154
 
141
- // DELETE /api/collections/:id/entries/:slug/permanent — hard delete from trash
155
+ // DELETE /api/collections/:id/entries/:slug/permanent?locale=
142
156
  entryRoutes.delete('/:collectionId/entries/:slug/permanent', (c) => {
143
157
  const { collectionId, slug } = c.req.param();
144
- const db = openPod(c.get('podPath'));
145
- const ok = db.permanentDeleteEntry(collectionId, slug);
158
+ const locale = c.req.query('locale') ?? '';
159
+ const db = openPod(c.get('podPath'));
160
+ const ok = db.permanentDeleteEntry(collectionId, slug, locale);
146
161
  db.close();
147
162
  if (!ok) return c.json({ error: 'Not found' }, 404);
148
163
  return c.json({ ok: true });
@@ -258,17 +273,18 @@ entryRoutes.post('/:collectionId/entries/import.csv', async (c) => {
258
273
  return c.json({ ok: true, created, updated, skipped });
259
274
  });
260
275
 
261
- // POST /api/collections/:id/entries/:slug/duplicate
276
+ // POST /api/collections/:id/entries/:slug/duplicate?locale=
262
277
  entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
263
278
  const { collectionId, slug } = c.req.param();
264
- const db = openPod(c.get('podPath'));
265
- const entry = db.getEntry(collectionId, slug);
279
+ const locale = c.req.query('locale') ?? '';
280
+ const db = openPod(c.get('podPath'));
281
+ const entry = db.getEntry(collectionId, slug, locale);
266
282
  if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
267
283
  let newSlug = slug + '-copy';
268
284
  let i = 2;
269
- while (db.getEntry(collectionId, newSlug)) newSlug = `${slug}-copy-${i++}`;
270
- db.createEntry(collectionId, newSlug, entry.data, 'draft');
271
- const created = db.getEntry(collectionId, newSlug);
285
+ while (db.getEntry(collectionId, newSlug, locale)) newSlug = `${slug}-copy-${i++}`;
286
+ db.createEntry(collectionId, newSlug, entry.data, 'draft', locale);
287
+ const created = db.getEntry(collectionId, newSlug, locale);
272
288
  db.close();
273
289
  return c.json(created, 201);
274
290
  });
@@ -276,15 +292,15 @@ entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
276
292
  // PATCH /api/collections/:id/entries/:slug/status
277
293
  entryRoutes.patch('/:collectionId/entries/:slug/status', async (c) => {
278
294
  const { collectionId, slug } = c.req.param();
279
- const { status, publish_at, unpublish_at } = await c.req.json();
295
+ const { status, publish_at, unpublish_at, locale = '' } = await c.req.json();
280
296
  if (!['draft', 'published', 'scheduled'].includes(status)) return c.json({ error: 'Invalid status' }, 400);
281
297
  if (status === 'scheduled' && !publish_at) return c.json({ error: 'publish_at required for scheduled status' }, 400);
282
298
  const db = openPod(c.get('podPath'));
283
- const entry = db.getEntry(collectionId, slug);
299
+ const entry = db.getEntry(collectionId, slug, locale);
284
300
  if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
285
301
  const pa = status === 'scheduled' ? publish_at : null;
286
302
  const ua = status === 'published' ? (unpublish_at ?? null) : null;
287
- db.updateEntry(collectionId, slug, { slug, data: entry.data, status, publish_at: pa, unpublish_at: ua });
303
+ db.updateEntry(collectionId, slug, { slug, data: entry.data, status, publish_at: pa, unpublish_at: ua, locale });
288
304
  const username = c.get('user')?.username ?? 'unknown';
289
305
  if (status === 'scheduled') db.logAudit(entry.id, username, 'schedule');
290
306
  db.close();
package/src/server.js CHANGED
@@ -25,6 +25,7 @@ import { importRoutes } from './routes/import.js';
25
25
  import { commentRoutes } from './routes/comments.js';
26
26
  import { lockRoutes } from './routes/locks.js';
27
27
  import { requireAuth } from './middleware/auth.js';
28
+ import { csrfMiddleware } from './middleware/csrf.js';
28
29
 
29
30
  const { version: adminVersion } = JSON.parse(
30
31
  readFileSync(join(__dirname, '../package.json'), 'utf8')
@@ -56,6 +57,8 @@ export function createApp(podPath) {
56
57
  credentials: true,
57
58
  }));
58
59
 
60
+ app.use('/api/*', csrfMiddleware(ALLOWED_ORIGINS));
61
+
59
62
  // Public routes
60
63
  app.route('/api/auth', authRoutes);
61
64