domma-cms 1.8.32 → 1.8.34

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -0,0 +1,639 @@
1
+ /**
2
+ * Theme Roller - admin screen.
3
+ *
4
+ * Roller: Domma's ThemeRoller (from the tools bundle every admin loads) edits a
5
+ * theme live against the admin itself; Save stores it through this plugin's API.
6
+ * Saved themes: one row each, with where it is in use; click to edit,
7
+ * right-click / ⋮ / Shift+F10 for everything else. Custom CSS: content/custom.css.
8
+ *
9
+ * Built on the Tool upgrade path: domma-reactive state bound with
10
+ * M.applyBindings, context menus, help popovers, actions in the plugin banner;
11
+ * the banner itself (version, licence, expiry) is the CMS's plugin chrome.
12
+ *
13
+ * Three things about the ThemeRoller component shape this file:
14
+ * - It re-themes the WHOLE admin while it runs (Domma.theme.set + a preview
15
+ * <style> on body/:root). Leaving the screen destroys it and puts the admin's
16
+ * own theme back - custom class and all (the CMS's admin-theme.js).
17
+ * - It has no getBaseTheme(), and the base is NOT necessarily Domma.theme.get():
18
+ * the tools bundle carries its own copy of the theme engine, so the roller's
19
+ * dropdown and Domma can disagree. The base is what the roller's own dropdown
20
+ * says (currentBase). The 1.0 screen called roller.getBaseTheme() on Save and
21
+ * threw before sending anything, which is why saving never worked.
22
+ * - loadPreset() and _refreshInputs() wipe the changes. A saved theme is loaded
23
+ * by setting the base, building a fresh roller, then putting the changes in
24
+ * place and re-rendering its inputs from them (loadInto below).
25
+ */
26
+ import {themeCss, USAGE_LABEL} from '../lib/themes.js';
27
+ import {OLD_DRAFT_KEY, RESTORE_ANSWERS, changesKey, draftKeyFor, filterThemes, listCounts, restoreQuestion, staleNote, stillDirty} from '../lib/view.js';
28
+ import {promptWith} from '../lib/prompt.js';
29
+ import {fmtDate} from '../../../_lib/admin/ui/dates.js';
30
+
31
+ const API = '/plugins/theme-roller';
32
+ const ROUTE = /^#\/plugins\/theme-roller\b/;
33
+
34
+ const ROW = `
35
+ <div class="trx-row" data-id="{{id}}" tabindex="0" data-bind-class="$root.editingId.value === id && 'is-editing'">
36
+ <span class="trx-swatch" aria-hidden="true"><span data-bind-style-background="c1"></span><span data-bind-style-background="c2"></span><span data-bind-style-background="c3"></span></span>
37
+ <span class="trx-row-main"><strong>{{label}}</strong><small>{{meta}}</small></span>
38
+ <span class="trx-chips" data-bind-hidden="!usageText"><span class="trx-chip">{{usageText}}</span></span>
39
+ <button type="button" class="trx-kebab" aria-label="Actions for {{label}}" title="Actions">⋮</button>
40
+ </div>`;
41
+
42
+ /** Filled by loadKit() on first mount - a plugin must not import admin/ statically. */
43
+ let apiRequest, addBannerAction, helpPopovers, openMenuAt, viewLifetime, adminTheme, loadingLib;
44
+ let lifetime;
45
+ /** The last mount's admin-theme restore, still running when a quick remount starts. */
46
+ let restoring = Promise.resolve();
47
+ /**
48
+ * Unsaved work kept when the screen is left - per signed-in person
49
+ * (theme-roller.drafts.<uid>); no one signed in keeps nothing. The old shared
50
+ * key is removed when found, never read.
51
+ */
52
+ function draftStore() {
53
+ let uid = '';
54
+ try { uid = S.get('auth_user')?.id || ''; } catch { /* storage unavailable */ }
55
+ const key = draftKeyFor(uid);
56
+ const drop = (k) => { try { S.remove ? S.remove(k) : S.set(k, null); } catch { /* ignore */ } };
57
+ try { if (S.get(OLD_DRAFT_KEY) != null) drop(OLD_DRAFT_KEY); } catch { /* storage unavailable */ }
58
+ return {
59
+ get() { if (!key) return null; try { return S.get(key); } catch { return null; } },
60
+ set(v) { if (key) { try { S.set(key, v); } catch { /* storage unavailable - nothing more to do */ } } },
61
+ remove() { if (key) drop(key); }
62
+ };
63
+ }
64
+
65
+ /** Run a menu item's action a task later: the Enter that picked it must not land in the confirm it opens. */
66
+ const later = (fn) => (...args) => { setTimeout(() => fn(...args), 0); };
67
+
68
+ async function loadKit() {
69
+ if (lifetime) return;
70
+ const [api, chrome, help, kit] = await Promise.all([
71
+ import('/admin/js/api.js'),
72
+ import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
73
+ import('/admin/js/lib/help-popover.js').catch(() => ({})),
74
+ import('/admin/js/lib/tool-kit.js')
75
+ ]);
76
+ ({apiRequest} = api);
77
+ addBannerAction = chrome.addBannerAction || (() => null);
78
+ helpPopovers = help.helpPopovers || (() => () => {});
79
+ ({openMenuAt, viewLifetime} = kit);
80
+ // CMS 0.73: applies a custom admin theme (base + class + CSS). Older: plain Domma.
81
+ adminTheme = await import('/admin/js/lib/admin-theme.js').catch(() => null);
82
+ // A skeleton while the list first loads (core 1.x; older cores show the slot's text).
83
+ loadingLib = await import('/admin/js/lib/loading.js').catch(() => null);
84
+ lifetime = viewLifetime(ROUTE);
85
+ }
86
+
87
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
88
+ /** The site theme showing now: the day or night theme when switching is on. */
89
+ function siteThemeNow(inUse = {}) {
90
+ const auto = inUse.autoTheme;
91
+ if (!auto) return inUse.theme || '';
92
+ const mins = (hm) => { const [h, m] = String(hm || '').split(':'); return (+h || 0) * 60 + (+m || 0); };
93
+ const now = new Date();
94
+ const t = now.getHours() * 60 + now.getMinutes();
95
+ return t >= mins(auto.dayStart || '07:00') && t < mins(auto.nightStart || '19:00') ? auto.dayTheme : auto.nightTheme;
96
+ }
97
+
98
+ /** Ask the sidebar to re-read this Tool's badge (the core's "Live counts"). */
99
+ function refreshBadge() {
100
+ try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: '#/plugins/theme-roller'}})); } catch { /* no sidebar */ }
101
+ }
102
+
103
+ const usageText = (usage) => (usage || []).map(u => USAGE_LABEL[u] || u).join(' · ');
104
+
105
+ export const themeRollerView = {
106
+ templateUrl: '/plugins/theme-roller/admin/templates/theme-roller.html',
107
+
108
+ async onMount($container) {
109
+ await loadKit();
110
+ // Left while the helpers loaded: nothing to set up, and nothing would tear it down.
111
+ if (!ROUTE.test(location.hash)) return;
112
+ lifetime.reset();
113
+ // A quick remount must not read the admin theme while the last mount is
114
+ // still putting it back - that restore would land under the new roller.
115
+ await restoring;
116
+ if (!ROUTE.test(location.hash)) return;
117
+ const root = $container.get(0).querySelector('.trx') || $container.get(0);
118
+ const here = () => ROUTE.test(location.hash) && root.isConnected;
119
+ const drafts = draftStore();
120
+
121
+ // ---- State ----
122
+ const themes = M.observable([]);
123
+ const loading = M.observable(true);
124
+ const supported = M.observable(true);
125
+ const editingId = M.observable(''); // '' = a new, unsaved theme
126
+ const editingLabel = M.observable('New theme');
127
+ const editingBase = M.observable('');
128
+ const dirty = M.observable(false);
129
+ const saving = M.observable(false);
130
+ const savingCss = M.observable(false);
131
+ const baseTokens = M.observable({}); // base id -> its token map, for swatches
132
+ const show = M.observable('all'); // Saved themes: all | used | unused
133
+ const query = M.observable('');
134
+ const counts = M.computed(() => listCounts(themes.value));
135
+ let editingWas = ''; // the edited theme's updatedAt when editing began
136
+ let loadSeq = 0; // bumped by every loadInto: a save finishing later checks it
137
+ let inFlight = ''; // what a running save sent (base|changes), '' when none
138
+ let cssWas = null; // content/custom.css as loaded
139
+ let status = {}; // GET /status: what the site and admin use
140
+
141
+ const editing = M.computed(() => themes.value.find(t => t.id === editingId.value) || null);
142
+ const editingUsage = M.computed(() => editing.value?.usage || []);
143
+ const rows = M.observable([]);
144
+
145
+ scope(M.effect(() => {
146
+ const bases = baseTokens.value;
147
+ rows.value = filterThemes(themes.value, show.value, query.value).map((t) => {
148
+ const base = bases[t.baseTheme] || {};
149
+ const pick = (k) => t.changes[k] || base[k] || 'transparent';
150
+ return {
151
+ id: t.id,
152
+ label: t.label,
153
+ meta: `On ${t.baseTheme} · ${Object.keys(t.changes).length} change${Object.keys(t.changes).length === 1 ? '' : 's'} · ${fmtDate(t.updatedAt)}`,
154
+ usageText: usageText(t.usage),
155
+ c1: pick('--dm-primary'), c2: pick('--dm-surface'), c3: pick('--dm-background')
156
+ };
157
+ });
158
+ }));
159
+
160
+ const data = {
161
+ themes, loading, supported, editingId, rows, dirty, saving, savingCss, show, query, counts,
162
+ emptyText: M.computed(() => (!themes.value.length ? 'No saved themes yet - make one in the Roller tab and Save it.'
163
+ : query.value.trim() ? 'No theme matches.' : show.value === 'used' ? 'No saved theme is in use.' : 'Every saved theme is in use.')),
164
+ editingLabel,
165
+ editingMeta: M.computed(() => (editingBase.value ? `on ${editingBase.value}` : '')),
166
+ editingUsage,
167
+ editingUsageText: M.computed(() => usageText(editingUsage.value)),
168
+ saveText: M.computed(() => (editingId.value ? 'Save' : 'Save theme'))
169
+ };
170
+
171
+ function scope(disposer) { lifetime.add(disposer); return disposer; }
172
+
173
+ // ---- The roller ----
174
+ const rollerEl = root.querySelector('.trx-roller');
175
+ const makeRoller = window.DommaTools?.themeRoller ?? E.themeRoller;
176
+ let roller = null;
177
+
178
+ // The admin's own theme, taken off <body> while the roller runs: a
179
+ // custom admin theme's class would otherwise keep ITS tokens under the
180
+ // preview, and the roller would read and preview the wrong values.
181
+ const ownTheme = Domma.theme.get();
182
+ const ownCustom = [...document.body.classList].filter(k => k.startsWith('dm-custom-'));
183
+ ownCustom.forEach(k => document.body.classList.remove(k));
184
+ // A kept CSS draft that could not be put back (custom.css failed to load): carried into any later write.
185
+ let pendingCss = null;
186
+ scope(() => {
187
+ // Unsaved work is kept, not lost: offered back on the next visit.
188
+ if ((dirty.peek() && roller) || cssDirty) {
189
+ try {
190
+ drafts.set({
191
+ ...(dirty.peek() && roller && {theme: {id: editingId.peek(), label: editingLabel.peek(), base: currentBase(), changes: roller.getAll(),
192
+ // A save of exactly these changes is still running: the theme WILL be newer, by the user's own hand.
193
+ was: inFlight && inFlight === `${currentBase() || 'charcoal-dark'}|${changesKey(roller.getAll())}` ? '' : editingWas}}),
194
+ ...(cssDirty ? {css: cssArea.value, cssWas} : pendingCss && {css: pendingCss.css, cssWas: pendingCss.cssWas}),
195
+ at: new Date().toISOString()
196
+ });
197
+ } catch { /* the roller is gone - nothing more to do */ }
198
+ }
199
+ try { roller?.destroy(); } catch { /* gone */ }
200
+ roller = null;
201
+ if (adminTheme?.refreshAdminTheme) {
202
+ restoring = adminTheme.refreshAdminTheme({theme: {admin: () => apiRequest('/theme/admin')}}).catch(() => {});
203
+ } else {
204
+ Domma.theme.set(ownTheme);
205
+ ownCustom.forEach(k => document.body.classList.add(k));
206
+ }
207
+ });
208
+ // Closing the tab with unsaved work asks first.
209
+ const onUnload = (e) => {
210
+ if (dirty.peek() || cssDirty) { e.preventDefault(); e.returnValue = ''; }
211
+ };
212
+ window.addEventListener('beforeunload', onUnload);
213
+ scope(() => window.removeEventListener('beforeunload', onUnload));
214
+
215
+ if (typeof makeRoller !== 'function') {
216
+ rollerEl.textContent = 'The Theme Roller needs Domma\'s tools bundle, which this admin does not load.';
217
+ }
218
+
219
+ /** The base the roller is previewing - its own dropdown, see the header. */
220
+ const currentBase = () => rollerEl.querySelector('#theme-select')?.value || editingBase.peek() || Domma.theme.get();
221
+
222
+ /**
223
+ * Show `theme` (or a blank theme on `base`) in a fresh roller. See the
224
+ * header: loadPreset() and _refreshInputs() would wipe the changes.
225
+ */
226
+ function loadInto(base, changes = {}) {
227
+ if (typeof makeRoller !== 'function' || !here()) return;
228
+ loadSeq++;
229
+ try { roller?.destroy(); } catch { /* gone */ }
230
+ roller = null;
231
+ Domma.theme.set(base);
232
+ try {
233
+ roller = makeRoller(rollerEl, {
234
+ baseTheme: base,
235
+ showPreviewPanel: true,
236
+ livePreview: true,
237
+ onChange: () => { dirty.value = true; },
238
+ onReset: () => { dirty.value = true; }
239
+ });
240
+ } catch (err) {
241
+ rollerEl.textContent = `The roller could not start: ${err.message}`;
242
+ return;
243
+ }
244
+ // Its dropdown is drawn before it applies the base: bring it level.
245
+ roller._updateThemeDropdown?.();
246
+ if (Object.keys(changes).length) {
247
+ if (typeof roller._render === 'function' && typeof roller._bindEvents === 'function') {
248
+ roller._changes = {...changes};
249
+ roller._updatePreview?.();
250
+ roller._render();
251
+ try { roller._accordionInstance?.destroy(); } catch { /* gone */ }
252
+ roller._accordionInstance = Domma.elements.accordion('#theme-roller-accordion', {multiExpand: true, activeIndex: 0});
253
+ roller._bindEvents();
254
+ } else {
255
+ roller.setAll(changes);
256
+ }
257
+ }
258
+ editingBase.value = base;
259
+ dirty.value = false;
260
+ }
261
+
262
+ // Changing the base in the roller's own dropdown starts that base afresh.
263
+ const onBaseChange = (e) => {
264
+ if (e.target?.id !== 'theme-select') return;
265
+ editingBase.value = e.target.value;
266
+ dirty.value = true;
267
+ };
268
+ rollerEl.addEventListener('change', onBaseChange);
269
+ scope(() => rollerEl.removeEventListener('change', onBaseChange));
270
+
271
+ async function discardOk() {
272
+ return !dirty.value || await E.confirm('Discard the changes you have not saved?');
273
+ }
274
+
275
+ async function edit(id) {
276
+ const t = themes.peek().find(x => x.id === id);
277
+ if (!t || !await discardOk()) return;
278
+ editingId.value = t.id;
279
+ editingLabel.value = t.label;
280
+ editingWas = t.updatedAt || '';
281
+ loadInto(t.baseTheme, t.changes);
282
+ tabs.activate(0);
283
+ }
284
+
285
+ async function newTheme() {
286
+ if (!await discardOk()) return;
287
+ editingId.value = '';
288
+ editingLabel.value = 'New theme';
289
+ editingWas = '';
290
+ loadInto(currentBase() || 'charcoal-dark');
291
+ }
292
+
293
+ // ---- Server ----
294
+ async function load() {
295
+ try {
296
+ const both = Promise.all([
297
+ apiRequest(`${API}/themes`),
298
+ apiRequest(`${API}/status`).catch(() => ({supported: true}))
299
+ ]);
300
+ const slot = loading.peek() ? root.querySelector('.trx-loading') : null;
301
+ const [list, fetched] = slot && loadingLib?.whileLoading
302
+ ? await loadingLib.whileLoading(slot, both, loadingLib.SHAPES?.iconRows ?? loadingLib.SHAPES?.rows) : await both;
303
+ if (!here()) return;
304
+ themes.value = Array.isArray(list) ? list : [];
305
+ status = fetched || {};
306
+ supported.value = status.supported !== false;
307
+ // The swatches show the base's colours where a theme did not change them.
308
+ const need = [...new Set(themes.peek().map(t => t.baseTheme))].filter(b => !baseTokens.peek()[b]);
309
+ if (need.length) {
310
+ const got = await Promise.all(need.map(b => apiRequest(`/theme/themes/${encodeURIComponent(b)}`).catch(() => null)));
311
+ if (!here()) return;
312
+ const next = {...baseTokens.peek()};
313
+ need.forEach((b, i) => { next[b] = got[i]?.tokens || {}; });
314
+ baseTokens.value = next;
315
+ }
316
+ refreshBadge(); // every change reloads the list - the badge follows it
317
+ } catch (err) {
318
+ if (here()) E.toast(`Could not load the themes: ${err.message}`, {type: 'error'});
319
+ } finally {
320
+ loading.value = false;
321
+ }
322
+ }
323
+
324
+ const byId = (id) => themes.peek().find(t => t.id === id);
325
+
326
+ async function save() {
327
+ if (!roller || saving.peek()) return;
328
+ const changes = roller.getAll();
329
+ const baseTheme = currentBase() || 'charcoal-dark';
330
+ // What was sent: anything changed while the save is in flight stays unsaved.
331
+ const sent = `${baseTheme}|${changesKey(changes)}`;
332
+ const nowKey = () => (roller ? `${currentBase() || 'charcoal-dark'}|${changesKey(roller.getAll())}` : sent);
333
+ const seq = loadSeq;
334
+ saving.value = true;
335
+ inFlight = sent;
336
+ try {
337
+ if (editingId.peek()) {
338
+ const saved = await apiRequest(`${API}/themes/${encodeURIComponent(editingId.peek())}`, {
339
+ method: 'PUT', body: JSON.stringify({baseTheme, changes})
340
+ });
341
+ // Another theme was opened while it saved: report, refresh the list, touch nothing else.
342
+ if (seq !== loadSeq || !here()) { if (here()) { E.toast(`Saved "${saved.label}".`, {type: 'success'}); await load(); } return; }
343
+ editingBase.value = saved.baseTheme;
344
+ editingWas = saved.updatedAt || editingWas;
345
+ E.toast(`Saved "${saved.label}"${saved.usage?.length ? ` - in use: ${usageText(saved.usage)}` : ''}.`, {type: 'success'});
346
+ } else {
347
+ const label = await E.prompt('Name this theme', {inputPlaceholder: 'e.g. Brand, Ocean custom…'});
348
+ if (!label || !label.trim()) return;
349
+ const saved = await apiRequest(`${API}/themes`, {method: 'POST', body: JSON.stringify({label, baseTheme, changes})});
350
+ if (seq !== loadSeq || !here()) { if (here()) { E.toast(`Saved "${saved.label}".`, {type: 'success'}); await load(); } return; }
351
+ editingId.value = saved.id;
352
+ editingLabel.value = saved.label;
353
+ editingBase.value = saved.baseTheme;
354
+ editingWas = saved.updatedAt || '';
355
+ E.toast(`Saved "${saved.label}".`, {type: 'success'});
356
+ }
357
+ dirty.value = stillDirty(sent, nowKey());
358
+ if (dirty.peek()) E.toast('Saved - the changes made while it saved are not saved yet.', {type: 'info'});
359
+ await load();
360
+ } catch (err) {
361
+ E.toast(`Not saved: ${err.message}`, {type: 'error'});
362
+ } finally {
363
+ saving.value = false;
364
+ inFlight = '';
365
+ }
366
+ }
367
+
368
+ async function saveAs() {
369
+ if (!roller) return;
370
+ const label = await promptWith('Save as a new theme called', {}, `${editingLabel.peek()} copy`);
371
+ if (!label || !label.trim() || !roller || saving.peek()) return; // the screen may have gone meanwhile
372
+ const baseTheme = currentBase() || 'charcoal-dark';
373
+ const changes = roller.getAll();
374
+ const sent = `${baseTheme}|${changesKey(changes)}`;
375
+ const seq = loadSeq;
376
+ saving.value = true;
377
+ try {
378
+ const saved = await apiRequest(`${API}/themes`, {
379
+ method: 'POST', body: JSON.stringify({label, baseTheme, changes})
380
+ });
381
+ if (seq !== loadSeq || !here()) { if (here()) { E.toast(`Saved as "${saved.label}".`, {type: 'success'}); await load(); } return; }
382
+ editingId.value = saved.id;
383
+ editingLabel.value = saved.label;
384
+ editingWas = saved.updatedAt || '';
385
+ dirty.value = stillDirty(sent, roller ? `${currentBase() || 'charcoal-dark'}|${changesKey(roller.getAll())}` : sent);
386
+ E.toast(dirty.peek() ? `Saved as "${saved.label}" - the changes made while it saved are not saved yet.` : `Saved as "${saved.label}".`, {type: dirty.peek() ? 'info' : 'success'});
387
+ await load();
388
+ } catch (err) {
389
+ E.toast(`Not saved: ${err.message}`, {type: 'error'});
390
+ } finally {
391
+ saving.value = false;
392
+ }
393
+ }
394
+
395
+ async function use(t, where) {
396
+ if (dirty.peek() && editingId.peek() === t.id) {
397
+ E.toast('Using the saved version - Save first to include your latest changes.', {type: 'info', duration: 6000});
398
+ }
399
+ if (where === 'site' && !t.usage.includes('site')) {
400
+ const status = await apiRequest(`${API}/status`).catch(() => ({}));
401
+ if (status.inUse?.autoTheme && !await E.confirm(
402
+ 'Day/night switching is on, so the site shows the day and night themes, not a single one. Use this theme on the site and turn switching off?')) return;
403
+ }
404
+ try {
405
+ await apiRequest(`${API}/themes/${encodeURIComponent(t.id)}/use`, {method: 'POST', body: JSON.stringify({where})});
406
+ E.toast(`"${t.label}" is now the ${where === 'site' ? 'site' : 'admin'} theme.`, {type: 'success'});
407
+ // The admin shows it once you leave the roller, which is previewing over it.
408
+ await load();
409
+ } catch (err) {
410
+ E.toast(`Could not use it: ${err.message}`, {type: 'error'});
411
+ }
412
+ }
413
+
414
+ async function unuse(t, where) {
415
+ try {
416
+ await apiRequest(`${API}/themes/${encodeURIComponent(t.id)}/unuse`, {method: 'POST', body: JSON.stringify({where})});
417
+ E.toast(`The ${where} is back on ${t.baseTheme}.`, {type: 'success'});
418
+ await load();
419
+ } catch (err) {
420
+ E.toast(`Could not change it: ${err.message}`, {type: 'error'});
421
+ }
422
+ }
423
+
424
+ async function duplicate(t) {
425
+ const label = await promptWith('Name the copy', {}, `${t.label} copy`);
426
+ if (!label || !label.trim()) return;
427
+ try {
428
+ const copy = await apiRequest(`${API}/themes/${encodeURIComponent(t.id)}/duplicate`, {method: 'POST', body: JSON.stringify({label})});
429
+ E.toast(`Made "${copy.label}".`, {type: 'success'});
430
+ await load();
431
+ } catch (err) {
432
+ E.toast(`Could not duplicate it: ${err.message}`, {type: 'error'});
433
+ }
434
+ }
435
+
436
+ async function rename(t) {
437
+ const label = await promptWith('Rename this theme', {}, t.label);
438
+ if (!label || !label.trim() || label.trim() === t.label) return;
439
+ try {
440
+ const saved = await apiRequest(`${API}/themes/${encodeURIComponent(t.id)}`, {method: 'PUT', body: JSON.stringify({label})});
441
+ if (editingId.peek() === t.id) { editingLabel.value = saved.label; editingWas = saved.updatedAt || editingWas; }
442
+ await load();
443
+ } catch (err) {
444
+ E.toast(`Could not rename it: ${err.message}`, {type: 'error'});
445
+ }
446
+ }
447
+
448
+ function exportCss(t) {
449
+ const blob = new Blob([themeCss(t)], {type: 'text/css'});
450
+ const a = document.createElement('a');
451
+ a.href = URL.createObjectURL(blob);
452
+ a.download = `${t.id}.css`;
453
+ a.click();
454
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
455
+ }
456
+
457
+ async function remove(t) {
458
+ const inUse = t.usage.length ? ` It is in use (${usageText(t.usage)}); those go back to ${esc(t.baseTheme)}.` : '';
459
+ // E.confirm renders its message as (sanitised) HTML: a theme's name goes in escaped.
460
+ if (!await E.confirm(`Delete "${esc(t.label)}"?${inUse}`)) return;
461
+ try {
462
+ await apiRequest(`${API}/themes/${encodeURIComponent(t.id)}`, {method: 'DELETE'});
463
+ if (editingId.peek() === t.id) { editingId.value = ''; editingLabel.value = 'New theme'; }
464
+ E.toast(`Deleted "${t.label}".`, {type: 'success'});
465
+ await load();
466
+ } catch (err) {
467
+ E.toast(`Could not delete it: ${err.message}`, {type: 'error'});
468
+ }
469
+ }
470
+
471
+ function items(t) {
472
+ if (!t) return [];
473
+ const on = (w) => t.usage.includes(w);
474
+ return [
475
+ {header: t.label},
476
+ {label: 'Edit in the roller', icon: 'edit', action: later(() => edit(t.id))},
477
+ {type: 'divider'},
478
+ on('site')
479
+ ? {label: 'Stop using on the site', icon: 'x-circle', action: later(() => unuse(t, 'site'))}
480
+ : {label: 'Use on the site', icon: 'globe', disabled: !supported.peek(), action: later(() => use(t, 'site'))},
481
+ on('admin')
482
+ ? {label: 'Stop using in the admin', icon: 'x-circle', action: later(() => unuse(t, 'admin'))}
483
+ : {label: 'Use in the admin', icon: 'layout', disabled: !supported.peek(), action: later(() => use(t, 'admin'))},
484
+ {type: 'divider'},
485
+ {label: 'Duplicate…', icon: 'copy', action: later(() => duplicate(t))},
486
+ {label: 'Rename…', icon: 'type', action: later(() => rename(t))},
487
+ {label: 'Export CSS', icon: 'download', action: later(() => exportCss(t))},
488
+ {type: 'divider'},
489
+ {label: 'Delete…', icon: 'trash', danger: true, action: later(() => remove(t))}
490
+ ];
491
+ }
492
+
493
+ // ---- Custom CSS ----
494
+ const cssArea = root.querySelector('.trx-css');
495
+ let cssDirty = false;
496
+ // Locked until the file has loaded: saving an empty or half-typed box
497
+ // over content/custom.css would replace the whole file.
498
+ let cssLoaded = false;
499
+ cssArea.disabled = true;
500
+ const onCssInput = () => { cssDirty = true; };
501
+ cssArea.addEventListener('input', onCssInput);
502
+ scope(() => cssArea.removeEventListener('input', onCssInput));
503
+ async function saveCss() {
504
+ if (!cssLoaded || savingCss.peek()) return;
505
+ savingCss.value = true;
506
+ const sent = cssArea.value;
507
+ try {
508
+ await apiRequest('/settings/custom-css', {method: 'PUT', body: JSON.stringify({css: sent})});
509
+ cssWas = sent;
510
+ // Typed while it saved: still to save.
511
+ cssDirty = cssArea.value !== sent;
512
+ E.toast(cssDirty ? 'Custom CSS saved - what you typed since is not saved yet.' : 'Custom CSS saved.', {type: cssDirty ? 'info' : 'success'});
513
+ } catch (err) {
514
+ E.toast(`Custom CSS not saved: ${err.message}`, {type: 'error'});
515
+ } finally {
516
+ savingCss.value = false;
517
+ }
518
+ }
519
+ const onCssKey = (e) => {
520
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveCss(); }
521
+ };
522
+ cssArea.addEventListener('keydown', onCssKey);
523
+ scope(() => cssArea.removeEventListener('keydown', onCssKey));
524
+ const cssLoad = apiRequest('/settings/custom-css').then((r) => {
525
+ cssArea.value = r?.css || '';
526
+ cssWas = cssArea.value;
527
+ cssLoaded = true;
528
+ cssArea.disabled = false;
529
+ }).catch((err) => {
530
+ cssArea.placeholder = /403|forbidden|permission/i.test(String(err?.message || err))
531
+ ? 'Custom CSS needs the Site Settings permission.'
532
+ : `Could not load content/custom.css (${err?.message || err}) - reload to try again.`;
533
+ });
534
+
535
+ // ---- Bindings ----
536
+ root.querySelector('.trx-list').innerHTML = ROW;
537
+ const tabs = E.tabs(root.querySelector('.trx-tabs'), {tabSelector: '.tab-item', panelSelector: '.tab-panel'});
538
+ const bindings = M.applyBindings(data, root, {methods: {newTheme, save, saveAs, saveCss,
539
+ s_all: () => { show.value = 'all'; }, s_used: () => { show.value = 'used'; }, s_unused: () => { show.value = 'unused'; }}});
540
+ scope(() => bindings.dispose());
541
+ scope(helpPopovers(root, {position: 'bottom'}));
542
+
543
+ // Saved themes: click edits; right-click, ⋮ or Shift+F10 for the rest.
544
+ const list = root.querySelector('.trx-list');
545
+ const rowMenu = E.contextMenu(list, {
546
+ cursor: 'glyph', match: '.trx-row', inherit: false, density: 'compact',
547
+ items: (row) => items(byId(row.dataset.id))
548
+ });
549
+ scope(() => rowMenu?.destroy?.());
550
+ const onListClick = (e) => {
551
+ const row = e.target.closest('.trx-row');
552
+ if (!row) return;
553
+ if (e.target.closest('.trx-kebab')) { openMenuAt(e.target.closest('.trx-kebab')); return; }
554
+ edit(row.dataset.id);
555
+ };
556
+ const onListKey = (e) => {
557
+ const row = e.target.closest('.trx-row');
558
+ if (!row) return;
559
+ if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); openMenuAt(row); }
560
+ else if (e.key === 'Enter' && e.target === row) {
561
+ // A task later: this Enter must not answer the "discard?" confirm edit() may open.
562
+ e.preventDefault(); e.stopPropagation();
563
+ const id = row.dataset.id;
564
+ setTimeout(() => edit(id), 0);
565
+ }
566
+ };
567
+ list.addEventListener('click', onListClick);
568
+ list.addEventListener('keydown', onListKey);
569
+ scope(() => { list.removeEventListener('click', onListClick); list.removeEventListener('keydown', onListKey); });
570
+
571
+ // The theme being edited: same menu, from the ⋮ beside Save.
572
+ const more = root.querySelector('.trx-more');
573
+ const moreMenu = E.contextMenu(more.parentElement, {
574
+ cursor: 'glyph', match: '.trx-more', inherit: false, density: 'compact',
575
+ items: () => items(byId(editingId.peek()))
576
+ });
577
+ scope(() => moreMenu?.destroy?.());
578
+ const onMore = () => openMenuAt(more);
579
+ more.addEventListener('click', onMore);
580
+ scope(() => more.removeEventListener('click', onMore));
581
+
582
+ // Banner: the screen's own actions, beside the version and licence.
583
+ addBannerAction($container, {id: 'theme-roller-new', icon: 'plus', title: 'New theme', onClick: () => newTheme()});
584
+ addBannerAction($container, {id: 'theme-roller-save', icon: 'save', title: 'Save',
585
+ onClick: () => (tabs.getActive() === 2 ? saveCss() : save())});
586
+
587
+ I.scan(root);
588
+ await load();
589
+ if (!here()) return; // left while loading - do not start a roller over another screen
590
+ // Start on the site's current theme - the one showing now, the day or
591
+ // night theme when switching is on. A saved custom theme opens for
592
+ // editing; a built-in one starts a new theme on it. (1.0 always started
593
+ // on the component's default, charcoal-dark.)
594
+ const siteNow = siteThemeNow(status.inUse);
595
+ const saved = themes.peek().find(t => t.id === siteNow)
596
+ || themes.peek().find(t => t.usage.includes('admin'));
597
+ if (saved) {
598
+ editingId.value = saved.id;
599
+ editingLabel.value = saved.label;
600
+ editingWas = saved.updatedAt || '';
601
+ loadInto(saved.baseTheme, saved.changes);
602
+ } else {
603
+ loadInto(status.builtins?.includes(siteNow) ? siteNow : (ownTheme || 'charcoal-dark'));
604
+ }
605
+ I.scan(root);
606
+
607
+ // Unsaved work from the last visit: OK throws it away, Cancel / Escape keeps it (restores).
608
+ const draft = drafts.get();
609
+ if (draft && (draft.theme || draft.css != null)) {
610
+ // Older than what is saved now? Say so before they choose.
611
+ if (draft.css != null) await cssLoad;
612
+ const note = staleNote(draft, themes.peek(), cssLoaded ? cssWas : null);
613
+ if (!here()) return;
614
+ const throwAway = Boolean(await E.confirm(esc(restoreQuestion(draft, note)), {...RESTORE_ANSWERS}));
615
+ // Removed only once they have answered: leaving while asked keeps it for next time.
616
+ if (!here()) return;
617
+ drafts.remove();
618
+ if (!throwAway) {
619
+ if (draft.theme) {
620
+ const kept = themes.peek().find(t => t.id === draft.theme.id);
621
+ editingId.value = kept ? draft.theme.id : '';
622
+ editingLabel.value = editingId.peek() ? draft.theme.label : 'New theme';
623
+ editingWas = kept?.updatedAt || '';
624
+ loadInto(draft.theme.base, draft.theme.changes || {});
625
+ dirty.value = true;
626
+ }
627
+ if (draft.css != null) {
628
+ if (cssLoaded) { cssArea.value = draft.css; cssDirty = true; }
629
+ else {
630
+ // custom.css could not be loaded, so there is nothing to put it over: keep the CSS for next time.
631
+ pendingCss = {css: draft.css, cssWas: draft.cssWas};
632
+ drafts.set({...pendingCss, at: draft.at});
633
+ E.toast('Custom CSS could not be loaded - your unsaved CSS is kept for next time.', {type: 'warning'});
634
+ }
635
+ }
636
+ }
637
+ }
638
+ }
639
+ };
@@ -0,0 +1 @@
1
+ export default {};