@a83/orbiter-admin 0.3.18 → 0.3.20

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.18",
3
+ "version": "0.3.20",
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';
package/public/router.js CHANGED
@@ -24,7 +24,10 @@
24
24
  function updateActiveNav(href) {
25
25
  var target;
26
26
  try { target = new URL(href, location.origin); } catch (e) { return; }
27
- document.querySelectorAll('.nav-item').forEach(function (a) {
27
+ var pg = target.pathname.split('/').pop().replace('.html', '');
28
+
29
+ // Update classic sidebar + xfce dock/HUD link items
30
+ document.querySelectorAll('.nav-item, a.xfce-dock-item, a.xfce-hud-nav-item, a.xfce-tools-item').forEach(function (a) {
28
31
  var raw = a.getAttribute('href');
29
32
  if (!raw) return;
30
33
  var itemUrl;
@@ -37,6 +40,12 @@
37
40
  }
38
41
  a.classList.toggle('active', match);
39
42
  });
43
+
44
+ // xfce tools button is a <button> (not an <a>), update separately
45
+ var toolsBtn = document.getElementById('xfce-tools-btn');
46
+ if (toolsBtn) {
47
+ toolsBtn.classList.toggle('active', ['schema', 'build', 'import'].indexOf(pg) !== -1);
48
+ }
40
49
  }
41
50
 
42
51
  function swapPageStyle(doc) {
@@ -10,14 +10,29 @@
10
10
  <link rel="stylesheet" href="/style.css" />
11
11
  <script src="/theme.js"></script>
12
12
  <style>
13
- .settings-wrap { max-width:720px; padding-bottom:40px; }
13
+ .settings-wrap { max-width:1100px; padding-bottom:40px; }
14
+
15
+ /* Two-column grid for settings groups */
16
+ .settings-grid {
17
+ display: grid;
18
+ grid-template-columns: 1fr 1fr;
19
+ gap: 12px;
20
+ align-items: start;
21
+ margin-bottom: 12px;
22
+ }
23
+ .settings-grid .settings-group { margin-bottom: 0; }
24
+ .settings-wrap > .settings-group { margin-bottom: 12px; }
25
+
26
+ @media (max-width: 720px) {
27
+ .settings-wrap { max-width: 720px; }
28
+ .settings-grid { grid-template-columns: 1fr; }
29
+ }
14
30
 
15
31
  /* Card — einheitliches bg2, eine Farbe, kein Alternieren */
16
32
  .settings-group {
17
33
  background: var(--bg2);
18
34
  border: 1px solid var(--line);
19
35
  border-radius: 10px;
20
- margin-bottom: 12px;
21
36
  overflow: hidden;
22
37
  }
23
38
 
@@ -250,8 +265,9 @@
250
265
  </div>
251
266
  </nav>
252
267
  <main class="main">
253
- <div class="page-header glass-card">
268
+ <div class="page-header glass-card" style="display:flex;align-items:center;justify-content:space-between;">
254
269
  <h1 class="page-title">Settings</h1>
270
+ <button type="submit" form="site-form" class="btn-save" id="header-save-btn">Save settings</button>
255
271
  </div>
256
272
  <div class="settings-wrap" id="settings-content">
257
273
  <div class="empty"><div class="spinner"></div></div>
@@ -301,6 +317,7 @@
301
317
 
302
318
  <!-- Site + Build + GitHub + API — one form -->
303
319
  <form id="site-form">
320
+ <div class="settings-grid">
304
321
  <div class="settings-group">
305
322
  <div class="group-header">Site</div>
306
323
  <div class="setting-row">
@@ -485,9 +502,7 @@
485
502
  </div>
486
503
  </div>
487
504
 
488
- <div class="save-row">
489
- <button type="submit" class="btn-save">Save settings</button>
490
- </div>
505
+ </div><!-- /settings-grid -->
491
506
  </form>
492
507
 
493
508
  <!-- GitHub Sync (conditional) -->
@@ -508,8 +523,9 @@
508
523
  </div>
509
524
  ` : ''}
510
525
 
511
- <!-- Account -->
526
+ <!-- Account + Interface language (two-column) -->
512
527
  <div id="account-banner" class="banner" style="display:none"></div>
528
+ <div class="settings-grid">
513
529
  <div class="settings-group">
514
530
  <div class="group-header">Account — ${me.user.username}</div>
515
531
  <form id="pw-form">
@@ -555,8 +571,9 @@
555
571
  </div>
556
572
  </div>
557
573
  </div>
574
+ </div><!-- /settings-grid account+lang -->
558
575
 
559
- <!-- Appearance -->
576
+ <!-- Appearance (full width) -->
560
577
  <div class="settings-group">
561
578
  <div class="group-header">Appearance</div>
562
579
  <div style="padding:16px 28px;border-bottom:1px solid var(--line2)">
@@ -654,7 +671,8 @@
654
671
  </div>
655
672
  </div>
656
673
 
657
- <!-- Data + Import -->
674
+ <!-- Data + Pod (two-column) -->
675
+ <div class="settings-grid">
658
676
  <div class="settings-group">
659
677
  <div class="group-header">Data</div>
660
678
  <div class="setting-row">
@@ -666,7 +684,6 @@
666
684
  </div>
667
685
  </div>
668
686
 
669
- <!-- Pod info -->
670
687
  <div class="settings-group" id="pod-info-group">
671
688
  <div class="group-header">Pod</div>
672
689
  <div class="pod-meta">
@@ -677,6 +694,11 @@
677
694
  <div class="pod-col-row"><span class="pod-col-name" style="color:var(--muted)">Loading…</span></div>
678
695
  </div>
679
696
  </div>
697
+ </div><!-- /settings-grid data+pod -->
698
+
699
+ <div class="save-row">
700
+ <button type="submit" form="site-form" class="btn-save">Save settings</button>
701
+ </div>
680
702
 
681
703
  <!-- Support widget -->
682
704
  <div class="support-widget">
package/public/style.css CHANGED
@@ -1040,7 +1040,7 @@ html[data-style="xfce"] {
1040
1040
  --glass-shadow: 0 8px 40px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.07);
1041
1041
  --sb-h: 26px;
1042
1042
  --dock-h: 76px;
1043
- --dock-item-base: 44px;
1043
+ --dock-item-base: 50px;
1044
1044
  }
1045
1045
 
1046
1046
  html[data-style="xfce"][data-scheme="light"],
@@ -1158,8 +1158,8 @@ html[data-style="xfce"] .editor-shell {
1158
1158
  flex-direction: column;
1159
1159
  align-items: center;
1160
1160
  justify-content: flex-end;
1161
- width: var(--dock-item-base, 44px);
1162
- padding: 5px 4px 4px;
1161
+ width: var(--dock-item-base, 50px);
1162
+ padding: 8px 7px 7px;
1163
1163
  background: transparent;
1164
1164
  border: none;
1165
1165
  border-radius: 12px;
@@ -1217,7 +1217,7 @@ html[data-style="xfce"] .editor-shell {
1217
1217
  font-size: 18px;
1218
1218
  line-height: 1;
1219
1219
  display: block;
1220
- opacity: .35;
1220
+ opacity: .58;
1221
1221
  transition: filter .12s, transform .12s cubic-bezier(.34,1.5,.64,1), opacity .12s, color .12s;
1222
1222
  }
1223
1223
  .xfce-dock-item:hover .xfce-dock-icon {
@@ -1239,7 +1239,7 @@ html[data-style="xfce"] .editor-shell {
1239
1239
  color: var(--mid);
1240
1240
  white-space: nowrap;
1241
1241
  transition: color .12s, opacity .12s;
1242
- opacity: .32;
1242
+ opacity: .48;
1243
1243
  }
1244
1244
  .xfce-dock-item:hover .xfce-dock-lbl {
1245
1245
  opacity: 1;
@@ -1637,7 +1637,13 @@ html[data-style="xfce"] .table-wrap {
1637
1637
  }
1638
1638
 
1639
1639
  /* Settings / Import content: center within padded main */
1640
- html[data-style="xfce"] .settings-wrap,
1640
+ html[data-style="xfce"] .settings-wrap {
1641
+ width: 100%;
1642
+ max-width: 1100px;
1643
+ margin: 0 auto;
1644
+ padding-left: 0;
1645
+ padding-right: 0;
1646
+ }
1641
1647
  html[data-style="xfce"] .import-wrap {
1642
1648
  width: 100%;
1643
1649
  max-width: 720px;
package/public/xfce.js CHANGED
@@ -564,7 +564,7 @@
564
564
  var r = item.getBoundingClientRect();
565
565
  var mid = r.left + r.width / 2;
566
566
  var d = Math.abs(cx - mid);
567
- var s = d < 96 ? 1 + (1 - d / 96) * 0.95 : 1;
567
+ var s = d < 80 ? 1 + (1 - d / 80) * 0.50 : 1;
568
568
  item.style.setProperty('--ds', s.toFixed(3));
569
569
  });
570
570
  }
@@ -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();