domma-cms 0.72.0 → 0.74.0

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.
@@ -12,7 +12,7 @@ import {resolveLocation, resolveMenuDecorations, resolveOverlaysForPage} from '.
12
12
  import {buildMenuNav} from './menuRender.js';
13
13
  import {getProjectForPage} from './projects.js';
14
14
  import {resolveContextMenusForPage} from './contextMenus.js';
15
- import {buildOverrideStyleTag, getThemeTokenMap, listThemeIds, loadThemeConfig} from './themeSettings.js';
15
+ import {buildCustomThemeStyleTag, buildOverrideStyleTag, getThemeTokenMap, listThemeIds, loadThemeConfig, resolveThemeClasses} from './themeSettings.js';
16
16
  import {getSearchSettings} from './search.js';
17
17
 
18
18
  const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
@@ -74,7 +74,7 @@ function buildSearchAssets() {
74
74
  };
75
75
  }
76
76
 
77
- const THEME_SWITCHER_ASSET_V = '20260922-ts';
77
+ const THEME_SWITCHER_ASSET_V = '20260925-ts';
78
78
  const ANALYTICS_ASSET_V = '20260922-analytics';
79
79
 
80
80
  /**
@@ -397,7 +397,7 @@ export async function renderPage(page, opts = {}) {
397
397
  // Theme overrides land BEFORE content/custom.css: the overrides are
398
398
  // generated, custom.css is hand-written, and the hand-written one stays
399
399
  // the last word.
400
- headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
400
+ headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.customThemeStyleTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
401
401
  .filter(Boolean).join('\n'),
402
402
  bodyEndInject: [
403
403
  // Draft banner - first, so it is the topmost fixed element and a
@@ -789,16 +789,28 @@ async function buildThemeView(page = {}) {
789
789
 
790
790
  // A page pinned to a theme opts out of the automatic pair - otherwise the
791
791
  // pin would silently expire at the day/night boundary.
792
- const autoTheme = (!page.theme && cfg.autoTheme?.enabled) ? cfg.autoTheme : null;
792
+ // A copy: `apply` is added below, and the loaded config must not carry it.
793
+ const autoTheme = (!page.theme && cfg.autoTheme?.enabled) ? {...cfg.autoTheme} : null;
793
794
  const activeTheme = page.theme
794
795
  || (autoTheme ? autoTheme.dayTheme : cfg.theme)
795
796
  || 'charcoal-dark';
796
797
 
797
- // A custom (Theme Roller) theme is not a theme Domma's engine knows, so it
798
- // receives the built-in base and the custom class rides along separately,
799
- // added after init so _applyTheme() cannot strip it.
800
- const dommaTheme = cfg.baseTheme || activeTheme;
801
- const customThemeClass = cfg.baseTheme ? `dm-theme-${activeTheme}` : '';
798
+ // A custom theme (a plugin's - the Theme Roller) is not one Domma's engine
799
+ // knows: Domma is given its built-in base, and the custom class rides
800
+ // beside it. It is `dm-custom-<id>`, not `dm-theme-<id>`, because Domma
801
+ // strips every dm-theme-* class it did not put there.
802
+ const {dommaTheme, customClass: customThemeClass} = resolveThemeClasses(activeTheme, cfg);
803
+
804
+ // The browser switches between the day and night themes itself, so it is
805
+ // told how to apply each (base + custom class) rather than just its id.
806
+ if (autoTheme) {
807
+ const day = resolveThemeClasses(autoTheme.dayTheme, cfg);
808
+ const night = resolveThemeClasses(autoTheme.nightTheme, cfg);
809
+ autoTheme.apply = {
810
+ [autoTheme.dayTheme]: {theme: day.dommaTheme, custom: day.customClass},
811
+ [autoTheme.nightTheme]: {theme: night.dommaTheme, custom: night.customClass}
812
+ };
813
+ }
802
814
 
803
815
  const {fontLink, fontOverride} = buildFontVars(cfg.font?.family, cfg.font?.size);
804
816
 
@@ -810,6 +822,8 @@ async function buildThemeView(page = {}) {
810
822
  customThemeClass,
811
823
  fontLink,
812
824
  fontStyleTag: fontOverride ? `<style>${fontOverride}</style>` : '',
825
+ // Only the custom themes this page can show: its own, and day/night's.
826
+ customThemeStyleTag: buildCustomThemeStyleTag([activeTheme, autoTheme?.dayTheme, autoTheme?.nightTheme]),
813
827
  overrideStyleTag: buildOverrideStyleTag(cfg)
814
828
  };
815
829
  }
@@ -1008,7 +1022,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
1008
1022
  // Theme overrides land BEFORE content/custom.css: the overrides are
1009
1023
  // generated, custom.css is hand-written, and the hand-written one stays
1010
1024
  // the last word.
1011
- headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
1025
+ headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.customThemeStyleTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
1012
1026
  .filter(Boolean).join('\n'),
1013
1027
  bodyEndInject: [
1014
1028
  overlayMenus,
@@ -77,6 +77,8 @@ export function defaultThemeConfig() {
77
77
  theme: DEFAULT_THEME,
78
78
  adminTheme: DEFAULT_THEME,
79
79
  baseTheme: null,
80
+ // Base of each custom theme the config uses - see rememberCustomBases().
81
+ customBases: {},
80
82
  autoTheme: {
81
83
  enabled: false,
82
84
  dayTheme: 'charcoal-light',
@@ -174,6 +176,15 @@ export function listThemeTokens() {
174
176
  */
175
177
  export function getThemeDeclarations(themeId) {
176
178
  if (!themeId || !/^[a-z0-9-]+$/.test(themeId)) return null;
179
+ // A custom theme is its base with some tokens changed - adopted onto a
180
+ // region, it has to carry the whole set, not just the changes.
181
+ if (!listThemeIds().includes(themeId)) {
182
+ const custom = getCustomTheme(themeId);
183
+ if (!custom) return null;
184
+ const base = getThemeDeclarations(custom.baseTheme) || '';
185
+ const changed = Object.entries(custom.tokens).map(([k, v]) => `${k}: ${v};`).join('\n');
186
+ return [base, changed].filter(Boolean).join('\n');
187
+ }
177
188
  const css = readThemeStylesheet();
178
189
  // Anchored so `.dm-theme-x .child {}` descendant rules cannot be mistaken
179
190
  // for the theme's own block, and tolerant of the whitespace a rebuild or a
@@ -214,6 +225,144 @@ export function getThemeTokenMap(themeId) {
214
225
  return tokens;
215
226
  }
216
227
 
228
+ // ---------------------------------------------------------------------------
229
+ // Custom themes (plugins - the Theme Roller)
230
+ // ---------------------------------------------------------------------------
231
+ //
232
+ // A custom theme is a built-in base plus the tokens it changes. It is not a
233
+ // class Domma's engine knows - `Domma.theme.set()` refuses an unknown id and
234
+ // strips every `dm-theme-*` class it did not put there - so it is applied as
235
+ // TWO classes: the base's `dm-theme-<base>`, which Domma owns, and
236
+ // `dm-custom-<id>`, which it leaves alone. The custom class's rule carries only
237
+ // the changed tokens and is emitted after Domma's theme stylesheet, so it wins
238
+ // on source order at equal specificity.
239
+ //
240
+ // Providers are functions, asked on every read: a plugin's themes change while
241
+ // the site runs (save, rename, delete), and a registry snapshot would go stale.
242
+ // A provider must be cheap and synchronous - the render path calls it.
243
+
244
+ const CUSTOM_ID_RE = /^[a-z0-9][a-z0-9-]{0,59}$/;
245
+ const _themeProviders = new Map();
246
+
247
+ /**
248
+ * Register a source of custom themes. One per owner (a plugin's name); a second
249
+ * registration replaces the first, so a plugin that re-registers on reload does
250
+ * not double its themes.
251
+ *
252
+ * @param {string} owner
253
+ * @param {() => Array<{id: string, label?: string, baseTheme: string, tokens: Object<string,string>}>} provider
254
+ */
255
+ export function registerThemeProvider(owner, provider) {
256
+ if (typeof owner !== 'string' || !owner) throw new Error('registerThemeProvider: owner is required');
257
+ if (typeof provider !== 'function') throw new Error('registerThemeProvider: provider must be a function');
258
+ _themeProviders.set(owner, provider);
259
+ }
260
+
261
+ /** @param {string} owner */
262
+ export function unregisterThemeProvider(owner) {
263
+ _themeProviders.delete(owner);
264
+ }
265
+
266
+ /** The class a custom theme adds beside its base's `dm-theme-<base>`. */
267
+ export function customThemeClass(id) {
268
+ return `dm-custom-${id}`;
269
+ }
270
+
271
+ /**
272
+ * Every custom theme the providers currently offer, cleaned: an id that is not
273
+ * a slug, that shadows a built-in theme or that another provider already took
274
+ * is dropped; an unknown base falls back to the default; only real `--dm-*`
275
+ * tokens with plain values survive.
276
+ *
277
+ * @returns {Array<{id: string, label: string, baseTheme: string, tokens: Object<string,string>, owner: string}>}
278
+ */
279
+ export function listCustomThemes() {
280
+ const builtins = new Set(listThemeIds());
281
+ const out = [];
282
+ const seen = new Set();
283
+ for (const [owner, provider] of _themeProviders) {
284
+ let list;
285
+ try { list = provider(); } catch { continue; }
286
+ for (const t of Array.isArray(list) ? list : []) {
287
+ const id = typeof t?.id === 'string' ? t.id.trim().toLowerCase() : '';
288
+ if (!CUSTOM_ID_RE.test(id) || builtins.has(id) || seen.has(id)) continue;
289
+ const tokens = {};
290
+ for (const [k, v] of Object.entries(t.tokens || {})) {
291
+ if (TOKEN_RE.test(k) && typeof v === 'string' && v.trim() && isSafeTokenValue(v.trim())) tokens[k] = v.trim();
292
+ }
293
+ seen.add(id);
294
+ out.push({
295
+ id,
296
+ label: String(t.label || id).slice(0, 80),
297
+ baseTheme: builtins.has(t.baseTheme) ? t.baseTheme : DEFAULT_THEME,
298
+ tokens,
299
+ owner
300
+ });
301
+ }
302
+ }
303
+ return out;
304
+ }
305
+
306
+ /** @param {string} id @returns {object|null} */
307
+ export function getCustomTheme(id) {
308
+ return listCustomThemes().find((t) => t.id === id) || null;
309
+ }
310
+
311
+ /**
312
+ * How to apply a theme id: the built-in Domma should be told, and the extra
313
+ * class a custom theme adds. A custom theme whose provider has gone (plugin
314
+ * disabled, licence lapsed) falls back to the base remembered when it was
315
+ * chosen - the page keeps its base look rather than breaking.
316
+ *
317
+ * @param {string} id
318
+ * @param {object} [cfg] - a loaded theme config, for `customBases`
319
+ * @returns {{dommaTheme: string, customClass: string, custom: boolean}}
320
+ */
321
+ export function resolveThemeClasses(id, cfg = {}) {
322
+ if (!id) return {dommaTheme: DEFAULT_THEME, customClass: '', custom: false};
323
+ const builtins = listThemeIds();
324
+ if (builtins.includes(id)) return {dommaTheme: id, customClass: '', custom: false};
325
+ const custom = getCustomTheme(id);
326
+ if (custom) return {dommaTheme: custom.baseTheme, customClass: customThemeClass(id), custom: true};
327
+ const remembered = cfg?.customBases?.[id];
328
+ if (remembered && builtins.includes(remembered)) return {dommaTheme: remembered, customClass: customThemeClass(id), custom: true};
329
+ // The pre-provider Theme Roller stored its base as `baseTheme` beside a custom `theme`.
330
+ if (id === cfg?.theme && cfg?.baseTheme && builtins.includes(cfg.baseTheme)) {
331
+ return {dommaTheme: cfg.baseTheme, customClass: customThemeClass(id), custom: true};
332
+ }
333
+ // Anything else goes to Domma as it always did - it knows themes the
334
+ // stylesheet does not declare (core-light), and falls back on its own.
335
+ return {dommaTheme: id, customClass: '', custom: false};
336
+ }
337
+
338
+ /**
339
+ * The stylesheet for custom themes: one `.dm-custom-<id>` rule of the tokens
340
+ * each changes. Emitted after Domma's theme stylesheet (headInjectLate).
341
+ *
342
+ * @param {string[]|null} [ids] - only these (the ones in use); all when null
343
+ * @returns {string} CSS, or '' when there are none
344
+ */
345
+ export function compileCustomThemeCss(ids = null) {
346
+ const wanted = ids ? new Set(ids.filter(Boolean)) : null;
347
+ return listCustomThemes()
348
+ .filter((t) => Object.keys(t.tokens).length && (!wanted || wanted.has(t.id)))
349
+ .map((t) => `/* ${t.label.replace(/[*/<>]/g, '')} (${t.baseTheme}) */\n.${customThemeClass(t.id)} {\n`
350
+ + Object.entries(t.tokens).map(([k, v]) => `${k}: ${v};`).join('\n') + '\n}')
351
+ .join('\n\n');
352
+ }
353
+
354
+ /**
355
+ * The page-head `<style>` for the custom themes a page can show, or ''. Only
356
+ * those: every theme a Roller keeps is ~250 tokens, and an unused draft's
357
+ * name has no business in public page source.
358
+ *
359
+ * @param {string[]} [ids] - the page's theme, day and night; all when omitted
360
+ */
361
+ export function buildCustomThemeStyleTag(ids = null) {
362
+ const css = compileCustomThemeCss(ids);
363
+ return css ? `<style id="dm-custom-themes">\n${css.replace(/<\/style>/gi, '<\\/style>')}\n</style>` : '';
364
+ }
365
+
217
366
  // ---------------------------------------------------------------------------
218
367
  // Load / save / migrate
219
368
  // ---------------------------------------------------------------------------
@@ -300,14 +449,15 @@ export function loadThemeConfigSync() {
300
449
  * @returns {Promise<object>} The stored config.
301
450
  */
302
451
  export async function saveThemeConfig(input) {
303
- const errors = validateThemeConfig(input);
452
+ const stored = await loadThemeConfig();
453
+ const errors = validateThemeConfig(input, {remembered: rememberedIds(stored)});
304
454
  if (errors.length) {
305
455
  const err = new Error(errors.join('; '));
306
456
  err.validation = errors;
307
457
  throw err;
308
458
  }
309
459
 
310
- const current = await loadThemeConfig();
460
+ const current = stored;
311
461
  const cfg = mergeDefaults({
312
462
  ...current,
313
463
  ...input,
@@ -319,6 +469,7 @@ export async function saveThemeConfig(input) {
319
469
  overrides: Array.isArray(input.overrides) ? input.overrides.map(normaliseOverride) : current.overrides
320
470
  });
321
471
  cfg.version = 1;
472
+ rememberCustomBases(cfg);
322
473
 
323
474
  await fs.mkdir(CONFIG_DIR, {recursive: true});
324
475
  await fs.writeFile(THEME_FILE, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
@@ -327,6 +478,43 @@ export async function saveThemeConfig(input) {
327
478
  return cfg;
328
479
  }
329
480
 
481
+ /**
482
+ * Record the base of every custom theme the config uses, so a page keeps that
483
+ * base if the theme's plugin goes away (see resolveThemeClasses). `baseTheme`
484
+ * keeps its old meaning - the base of the site theme when that is custom - for
485
+ * the site.json mirror's readers.
486
+ *
487
+ * @param {object} cfg - mutated
488
+ */
489
+ function rememberCustomBases(cfg) {
490
+ const custom = new Map(listCustomThemes().map((t) => [t.id, t.baseTheme]));
491
+ const builtins = listThemeIds();
492
+ // A pre-provider Theme Roller config: `baseTheme` beside a custom `theme`,
493
+ // no customBases yet. Keep it, or the first save flips the site to default.
494
+ const legacy = {};
495
+ if (cfg.theme && cfg.baseTheme && !builtins.includes(cfg.theme) && builtins.includes(cfg.baseTheme)) {
496
+ legacy[cfg.theme] = cfg.baseTheme;
497
+ }
498
+ const used = [cfg.theme, cfg.adminTheme, cfg.autoTheme?.dayTheme, cfg.autoTheme?.nightTheme,
499
+ ...(cfg.overrides || []).map((o) => o?.theme)];
500
+ const bases = {};
501
+ for (const id of used) {
502
+ if (!id) continue;
503
+ if (custom.has(id)) bases[id] = custom.get(id);
504
+ else if (cfg.customBases?.[id]) bases[id] = cfg.customBases[id]; // provider away: keep what we knew
505
+ else if (legacy[id]) bases[id] = legacy[id];
506
+ }
507
+ cfg.customBases = bases;
508
+ cfg.baseTheme = bases[cfg.theme] || null;
509
+ }
510
+
511
+ /** Custom theme ids a stored config already uses and remembers the base of. */
512
+ export function rememberedIds(cfg) {
513
+ const ids = Object.keys(cfg?.customBases || {});
514
+ if (cfg?.theme && cfg?.baseTheme && !listThemeIds().includes(cfg.theme)) ids.push(cfg.theme);
515
+ return ids;
516
+ }
517
+
330
518
  /**
331
519
  * Rewrite the legacy site.json keys so third parties that still read them see
332
520
  * the current values. One-way: nothing reads these back.
@@ -415,7 +603,9 @@ function normaliseOverride(o) {
415
603
  */
416
604
  function isSafeTokenValue(value) {
417
605
  if (value.length > 200) return false;
418
- if (/[{}<>;]/.test(value)) return false;
606
+ // A backslash can spell `url(` as `u\72l(`, and `/*` opens a comment that
607
+ // swallows the next declaration - neither has a legitimate use in a token.
608
+ if (/[{}<>;\\]|\/\*|\*\//.test(value)) return false;
419
609
  return !/@import|javascript:|expression\s*\(|url\s*\(/i.test(value);
420
610
  }
421
611
 
@@ -426,23 +616,33 @@ function isSafeTokenValue(value) {
426
616
  * @param {object} input
427
617
  * @returns {string[]}
428
618
  */
429
- export function validateThemeConfig(input) {
619
+ export function validateThemeConfig(input, {remembered = []} = {}) {
430
620
  const errors = [];
431
621
  if (!input || typeof input !== 'object') return ['Theme config must be an object'];
432
622
 
433
- const known = listThemeIds();
434
- const checkTheme = (value, label) => {
623
+ const builtins = listThemeIds();
624
+ // Custom themes (a plugin's) are chosen like any other - except by the
625
+ // public switcher below, which switches through Domma's engine. One the
626
+ // config already uses stays valid after its plugin goes (`remembered` -
627
+ // its customBases): otherwise nothing else on the Theme screen could be
628
+ // saved until every use of it had been re-picked.
629
+ const known = builtins.length
630
+ ? [...builtins, ...listCustomThemes().map((t) => t.id), ...remembered]
631
+ : [];
632
+ const checkTheme = (value, label, allowed = known) => {
435
633
  if (value === undefined || value === null || value === '') return;
436
634
  if (typeof value !== 'string' || !/^[a-z0-9-]+$/.test(value)) {
437
635
  errors.push(`${label} is not a valid theme id`);
438
- } else if (known.length && !known.includes(value)) {
439
- errors.push(`${label}: unknown theme "${value}"`);
636
+ } else if (allowed.length && !allowed.includes(value)) {
637
+ errors.push(allowed === builtins && known.includes(value)
638
+ ? `${label}: "${value}" is a custom theme, which the public theme switcher cannot offer`
639
+ : `${label}: unknown theme "${value}"`);
440
640
  }
441
641
  };
442
642
 
443
643
  checkTheme(input.theme, 'Front-end theme');
444
644
  checkTheme(input.adminTheme, 'Admin theme');
445
- if (input.baseTheme) checkTheme(input.baseTheme, 'Base theme');
645
+ if (input.baseTheme) checkTheme(input.baseTheme, 'Base theme', builtins);
446
646
  if (input.autoTheme) {
447
647
  checkTheme(input.autoTheme.dayTheme, 'Day theme');
448
648
  checkTheme(input.autoTheme.nightTheme, 'Night theme');
@@ -470,7 +670,7 @@ export function validateThemeConfig(input) {
470
670
  if (!Array.isArray(sw.themes)) {
471
671
  errors.push('switcher.themes must be an array');
472
672
  } else {
473
- for (const id of sw.themes) checkTheme(id, 'switcher.themes');
673
+ for (const id of sw.themes) checkTheme(id, 'switcher.themes', builtins);
474
674
  }
475
675
  }
476
676
  }
@@ -39,7 +39,7 @@
39
39
  <!-- Late head injection - custom CSS always loads last so it can override everything -->
40
40
  {{headInjectLate}}
41
41
  </head>
42
- <body class="dm-cloaked dm-theme-{{theme}} {{layoutBodyClass}}" data-layout="{{layout}}">
42
+ <body class="dm-cloaked dm-theme-{{dommaTheme}} {{customThemeClass}} {{layoutBodyClass}}" data-layout="{{layout}}">
43
43
 
44
44
  {{#if showNavbar}}
45
45
  <nav id="site-navbar" class="{{navbarClass}}"></nav>
@@ -109,7 +109,9 @@
109
109
  window.Domma.init();
110
110
  }
111
111
  if (window.Domma && window.Domma.theme) {
112
- window.Domma.theme.init({ theme: '{{theme}}', persist: false });
112
+ // The built-in theme Domma knows; a custom theme's own class
113
+ // (dm-custom-<id>) is on <body> already and Domma leaves it alone.
114
+ window.Domma.theme.init({ theme: '{{dommaTheme}}', persist: false });
113
115
  }
114
116
  window.__CMS_NAV__ = {{navJson}};
115
117
  window.__CMS_SITE__ = {{siteJson}};
@@ -121,13 +123,20 @@
121
123
  var n = new Date(), m = n.getHours() * 60 + n.getMinutes(), ds = (c.dayStart || "07:00").split(":"),
122
124
  ns = (c.nightStart || "19:00").split(":"), d = +ds[0] * 60 + (+ds[1] || 0),
123
125
  e = +ns[0] * 60 + (+ns[1] || 0), t = (m >= d && m < e) ? c.dayTheme : c.nightTheme;
124
- if (window.Domma && window.Domma.theme) window.Domma.theme.set(t);
126
+ // A custom theme is a base plus its own class (see resolveThemeClasses).
127
+ var a = (c.apply && c.apply[t]) || { theme: t, custom: '' };
128
+ if (window.Domma && window.Domma.theme) window.Domma.theme.set(a.theme);
129
+ var b = document.body;
130
+ if (b) {
131
+ b.className = b.className.split(' ').filter(function (k) { return k.indexOf('dm-custom-') !== 0; }).join(' ');
132
+ if (a.custom) b.classList.add(a.custom);
133
+ }
125
134
  }());
126
135
  {{dconfigScript}}
127
136
  </script>
128
137
 
129
138
  <!-- Site initialisation -->
130
- <script src="/public/js/site.js?v=20260829-motion-toggle" type="module"></script>
139
+ <script src="/public/js/site.js?v=20260925-custom-themes" type="module"></script>
131
140
  <script src="/public/js/collection-context.js?v=20260919-ctx-tokens" type="module"></script>
132
141
  {{ctxMenusModule}}
133
142
 
@@ -1,45 +0,0 @@
1
- <div class="page-header">
2
- <h2><span data-icon="folder"></span> <span id="pe-title">Edit project</span></h2>
3
- <div class="header-actions">
4
- <a class="btn btn-secondary" href="#/projects"><span data-icon="arrow-left"></span> Back</a>
5
- <button class="btn btn-primary" id="pe-save"><span data-icon="check"></span> Save</button>
6
- </div>
7
- </div>
8
-
9
- <div class="card">
10
- <div class="card-header">
11
- <span data-icon="info"></span> Project details
12
- </div>
13
- <div class="card-body">
14
- <div class="grid grid-cols-2 dm-pe-grid">
15
- <div class="form-group">
16
- <label for="pe-slug">Slug <span class="required">*</span></label>
17
- <input type="text" id="pe-slug" name="slug" class="form-input" required>
18
- <small class="form-hint">Lowercase alphanumeric + hyphen; immutable after create</small>
19
- </div>
20
- <div class="form-group">
21
- <label for="pe-name">Name <span class="required">*</span></label>
22
- <input type="text" id="pe-name" name="name" class="form-input" required>
23
- </div>
24
- <div class="form-group col-span-2">
25
- <label for="pe-description">Description</label>
26
- <textarea id="pe-description" name="description" class="form-input" rows="2"></textarea>
27
- </div>
28
- <div class="form-group">
29
- <label for="pe-icon">Icon</label>
30
- <div id="pe-icon-mount"></div>
31
- <small class="form-hint">Click <code>⊞</code> to browse Domma icons; defaults to <code>folder</code></small>
32
- </div>
33
- <div class="form-group">
34
- <label for="pe-rootUrl">Root URL</label>
35
- <input type="text" id="pe-rootUrl" name="rootUrl" class="form-input" placeholder="/members">
36
- <small class="form-hint">URL prefix for auto-inheritance (must start with /)</small>
37
- </div>
38
- <div class="form-group">
39
- <label for="pe-sortOrder">Sort order</label>
40
- <input type="number" id="pe-sortOrder" name="sortOrder" class="form-input" value="0">
41
- <small class="form-hint">Lower = higher in the sidebar</small>
42
- </div>
43
- </div>
44
- </div>
45
- </div>
@@ -1 +0,0 @@
1
- import{api as d}from"../api.js";import{makeIconInput as h}from"../lib/shortcode-modal.js";export const projectEditorView={templateUrl:"/admin/js/templates/project-editor.html",async onMount(t){const p=location.hash.match(/^#\/projects\/edit\/(.+)$/),i=p?decodeURIComponent(p[1]):null,s=!i;let e={slug:"",name:"",description:"",icon:"folder",rootUrl:"",sortOrder:0};if(!s){const o=E.loader(t.get(0),{type:"dots"});try{e=await d.projects.get(i)}catch(r){E.toast(`Failed to load project: ${r.message||r}`,{type:"error"}),location.hash="#/projects";return}finally{o.destroy()}}t.find("#pe-title").text(s?"New project":`Edit "${e.slug}"`);const l=t.find("#pe-slug"),n=t.find("#pe-name"),m=t.find("#pe-description"),u=t.find("#pe-rootUrl"),f=t.find("#pe-sortOrder");l.val(e.slug||""),n.val(e.name||""),m.val(e.description||""),u.val(e.rootUrl||""),f.val(e.sortOrder??0),s||l.prop("disabled",!0);const g=t.find("#pe-icon-mount").get(0),a=h("e.g. folder, users, box",e.icon||"folder");a.input.id="pe-icon",a.input.classList.add("form-input"),g.appendChild(a.el),Domma.icons.scan(t.get(0)),t.find("#pe-save").on("click",async()=>{const o={slug:l.val().trim(),name:n.val().trim(),description:m.val().trim(),icon:a.input.value.trim()||"folder",sortOrder:Number.parseInt(f.val(),10)||0},r=u.val().trim();r&&(o.rootUrl=r);try{if(s)await d.projects.create(o);else{const{slug:c,...v}=o;await d.projects.update(i,v)}E.toast("Saved.",{type:"success"}),location.hash="#/projects"}catch(c){E.toast(`Save failed: ${c.message||c}`,{type:"error"})}})}};