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,43 @@
1
+ /* Theme Roller admin screen - see admin/views/theme-roller-view.js. */
2
+ .trx { display: flex; flex-direction: column; gap: .75rem; }
3
+ .trx [hidden] { display: none !important; }
4
+ .trx-alert { margin: 0; display: flex; gap: .5rem; align-items: center; padding: .6rem .8rem; border-radius: var(--dm-radius-md);
5
+ background: color-mix(in srgb, var(--dm-warning) 16%, transparent); color: var(--dm-text); }
6
+ .trx-bar { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; justify-content: space-between; margin-bottom: .75rem; }
7
+ .trx-editing { display: flex; flex-wrap: wrap; gap: .5rem; align-items: baseline; min-width: 0; }
8
+ .trx-editing small { color: var(--dm-text-muted); }
9
+ .trx-actions { display: flex; gap: .4rem; align-items: center; flex-wrap: wrap; }
10
+ .trx-actions .btn { display: inline-flex; align-items: center; gap: .3rem; }
11
+ .trx-more { font-size: var(--dm-text-lg); line-height: 1; }
12
+ .trx-chips { display: inline-flex; gap: .3rem; }
13
+ .trx-chip { display: inline-flex; align-items: center; font-size: var(--dm-text-xs); font-weight: 600; padding: .1rem .5rem; border-radius: 999px;
14
+ background: color-mix(in srgb, var(--dm-success) 16%, transparent); color: var(--dm-success); white-space: nowrap; }
15
+ .trx-chip.is-dirty { background: color-mix(in srgb, var(--dm-warning) 18%, transparent); color: var(--dm-warning); }
16
+ .trx-count { opacity: .65; font-size: var(--dm-text-xs); margin-left: .2rem; }
17
+ .trx-card { border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .75rem .9rem; display: flex; flex-direction: column; gap: .6rem; }
18
+ .trx-card-title { display: flex; align-items: center; gap: .3rem; font-weight: 600; }
19
+ .trx-note { margin: 0; color: var(--dm-text-muted); font-size: var(--dm-text-sm); }
20
+ .trx-list { display: flex; flex-direction: column; }
21
+ .trx-row { display: flex; align-items: center; gap: .75rem; padding: .55rem .25rem; border-bottom: 1px solid var(--dm-border); cursor: pointer; }
22
+ .trx-row:last-child { border-bottom: 0; }
23
+ .trx-row:hover, .trx-row:focus-visible { background: var(--dm-surface-hover, rgba(127,127,127,.08)); outline: none; }
24
+ .trx-row.is-editing { box-shadow: inset 3px 0 0 var(--dm-primary); }
25
+ .trx-swatch { display: inline-flex; flex-shrink: 0; width: 2.2rem; height: 1.4rem; border-radius: var(--dm-radius-sm); overflow: hidden; border: 1px solid var(--dm-border); }
26
+ .trx-swatch span { flex: 1; }
27
+ .trx-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
28
+ .trx-row-main strong, .trx-row-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
29
+ .trx-row-main small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
30
+ .trx-kebab { border: 0; background: none; cursor: pointer; font-size: var(--dm-text-lg); color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .3rem; }
31
+ .trx-kebab:hover { color: var(--dm-primary); }
32
+ .trx-css { width: 100%; min-height: 420px; font-family: var(--dm-font-mono, monospace); font-size: var(--dm-text-sm); }
33
+ .trx-css-actions { display: flex; justify-content: flex-end; }
34
+ @media (max-width: 640px) { .trx-row-main small { display: none; } }
35
+
36
+ /* Saved themes: filter and search (1.2) */
37
+ .trx-toolbar { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
38
+ .trx-seg { display: inline-flex; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); overflow: hidden; }
39
+ .trx-seg button { border: 0; background: none; color: var(--dm-text); padding: .3rem .7rem; cursor: pointer; font: inherit; font-size: var(--dm-text-sm); }
40
+ .trx-seg button + button { border-left: 1px solid var(--dm-border); }
41
+ .trx-seg button.is-on { background: var(--dm-primary); color: var(--dm-primary-text); }
42
+ .trx-seg small { font-size: var(--dm-text-xs); opacity: .75; margin-left: .2rem; }
43
+ .trx-search { width: auto; flex: 0 1 14rem; padding: .25rem .5rem; font-size: var(--dm-text-sm); }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * E.prompt with a starting value that survives any character. Domma's
3
+ * `inputValue` is sanitised into a value="…" attribute, so a " in it cuts the
4
+ * text short (escaping does not help - it is decoded back). This sets the
5
+ * input's .value after the dialog is built instead. The same helper as the
6
+ * core admin's tool-kit promptWith (CMS 1.6.53), kept here so the plugin
7
+ * works on older cores too.
8
+ *
9
+ * @param {string} message
10
+ * @param {object} [options] - E.prompt options (without inputValue)
11
+ * @param {*} [value] - the starting text
12
+ * @returns {Promise<string|null>}
13
+ */
14
+ export function promptWith(message, options, value) {
15
+ const answer = E.prompt(message, options);
16
+ const input = [...document.querySelectorAll('.dm-dialog-input')].at(-1);
17
+ if (input) {
18
+ input.value = String(value ?? '');
19
+ setTimeout(() => { try { input.select(); } catch { /* gone */ } }, 60);
20
+ }
21
+ return answer;
22
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Theme Roller - the pure rules, shared by the server (plugin.js) and the admin
3
+ * screen. No I/O and no globals, so the tests run them directly.
4
+ *
5
+ * A saved theme is `{id, label, baseTheme, changes, createdAt, updatedAt}`:
6
+ * a built-in Domma theme plus the `--dm-*` tokens changed on top of it. The CMS
7
+ * applies it as the base's class plus `dm-custom-<id>` (see the core's
8
+ * themeSettings.js); this plugin only stores themes and offers them.
9
+ */
10
+
11
+ /** A saved theme's id: what the CMS names its class and its config entries by. */
12
+ export const ID_RE = /^[a-z0-9][a-z0-9-]{0,59}$/;
13
+ const TOKEN_RE = /^--dm-[a-z0-9-]+$/;
14
+
15
+ /**
16
+ * An id from a label: lowercase, hyphens, 60 characters at most.
17
+ *
18
+ * @param {string} label
19
+ * @returns {string} '' when nothing usable is left
20
+ */
21
+ export function slugify(label) {
22
+ return String(label || '')
23
+ .toLowerCase()
24
+ .normalize('NFKD').replace(/[̀-ͯ]/g, '')
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/^-+|-+$/g, '')
27
+ .slice(0, 60)
28
+ .replace(/-+$/, '');
29
+ }
30
+
31
+ /**
32
+ * An id for a new theme that collides with nothing: not a built-in theme (the
33
+ * CMS would ignore it) and not another saved theme (it would overwrite it).
34
+ *
35
+ * @param {string} label
36
+ * @param {{builtins?: string[], taken?: string[]}} known
37
+ * @returns {string} '' when the label has nothing usable in it
38
+ */
39
+ export function uniqueId(label, {builtins = [], taken = []} = {}) {
40
+ const base = slugify(label);
41
+ if (!base) return '';
42
+ const used = new Set([...builtins, ...taken]);
43
+ if (!used.has(base)) return base;
44
+ for (let n = 2; n < 1000; n++) {
45
+ const id = `${base.slice(0, 55)}-${n}`;
46
+ if (!used.has(id)) return id;
47
+ }
48
+ return '';
49
+ }
50
+
51
+ /**
52
+ * A token value that cannot break out of its declaration - the same rule the
53
+ * CMS applies before it emits one. Refused, not escaped.
54
+ */
55
+ export function isSafeValue(value) {
56
+ if (typeof value !== 'string') return false;
57
+ const v = value.trim();
58
+ if (!v || v.length > 200) return false;
59
+ if (/[{}<>;\\]|\/\*|\*\//.test(v)) return false; // the core's rule: no comment, no escapes
60
+ return !/@import|javascript:|expression\s*\(|url\s*\(/i.test(v);
61
+ }
62
+
63
+ /**
64
+ * Keep only real `--dm-*` tokens with plain values.
65
+ *
66
+ * @param {object} changes
67
+ * @returns {{changes: Object<string,string>, dropped: string[]}}
68
+ */
69
+ export function cleanChanges(changes) {
70
+ const out = {};
71
+ const dropped = [];
72
+ for (const [k, v] of Object.entries(changes && typeof changes === 'object' ? changes : {})) {
73
+ if (TOKEN_RE.test(k) && isSafeValue(v)) out[k] = v.trim();
74
+ else dropped.push(k);
75
+ }
76
+ return {changes: out, dropped};
77
+ }
78
+
79
+ /** A label for display: trimmed, 80 characters, never empty. */
80
+ export function cleanLabel(label, fallback = 'Untitled theme') {
81
+ const s = String(label ?? '').replace(/\s+/g, ' ').trim().slice(0, 80);
82
+ return s || fallback;
83
+ }
84
+
85
+ /**
86
+ * Where a theme is in use, from the CMS's theme config (config/theme.json).
87
+ *
88
+ * @param {string} id
89
+ * @param {object} cfg
90
+ * @returns {string[]} any of 'site', 'admin', 'day', 'night', 'region'
91
+ */
92
+ export function usageOf(id, cfg = {}) {
93
+ const where = [];
94
+ const auto = cfg.autoTheme || {};
95
+ if (!auto.enabled && cfg.theme === id) where.push('site');
96
+ if (cfg.adminTheme === id) where.push('admin');
97
+ if (auto.enabled && auto.dayTheme === id) where.push('day');
98
+ if (auto.enabled && auto.nightTheme === id) where.push('night');
99
+ if ((cfg.overrides || []).some(o => o && o.enabled !== false && o.theme === id)) where.push('region');
100
+ return where;
101
+ }
102
+
103
+ /** Human wording for usageOf's keys. */
104
+ export const USAGE_LABEL = {site: 'Site', admin: 'Admin', day: 'Day', night: 'Night', region: 'Region'};
105
+
106
+ /**
107
+ * The theme config with every use of `id` swapped for `replacement` - what
108
+ * deleting a theme in use does, so nothing is left pointing at nothing.
109
+ *
110
+ * @param {object} cfg
111
+ * @param {string} id
112
+ * @param {string} replacement - a built-in theme, normally the deleted theme's base
113
+ * @returns {object|null} a partial config to save, or null when nothing uses it
114
+ */
115
+ export function withoutTheme(cfg, id, replacement) {
116
+ const patch = {};
117
+ if (cfg.theme === id) patch.theme = replacement;
118
+ if (cfg.adminTheme === id) patch.adminTheme = replacement;
119
+ const auto = cfg.autoTheme || {};
120
+ if (auto.dayTheme === id || auto.nightTheme === id) {
121
+ patch.autoTheme = {
122
+ ...auto,
123
+ ...(auto.dayTheme === id && {dayTheme: replacement}),
124
+ ...(auto.nightTheme === id && {nightTheme: replacement})
125
+ };
126
+ }
127
+ if ((cfg.overrides || []).some(o => o?.theme === id)) {
128
+ patch.overrides = cfg.overrides.map(o => (o?.theme === id ? {...o, theme: replacement} : o));
129
+ }
130
+ return Object.keys(patch).length ? patch : null;
131
+ }
132
+
133
+ /**
134
+ * A standalone stylesheet for a theme - what "Export CSS" downloads. Scoped to
135
+ * the class the CMS uses, plus a `:root` block commented out for anyone
136
+ * pasting it into a page that does not.
137
+ *
138
+ * @param {{id: string, label?: string, baseTheme: string, changes: object}} theme
139
+ * @param {string} [date] - YYYY-MM-DD
140
+ * @returns {string}
141
+ */
142
+ export function themeCss(theme, date = new Date().toISOString().slice(0, 10)) {
143
+ const {changes} = cleanChanges(theme.changes);
144
+ const lines = Object.entries(changes).map(([k, v]) => ` ${k}: ${v};`);
145
+ // A character class, not a `*/` pattern: removing `*/` from `**//` leaves `*/`.
146
+ const label = cleanLabel(theme.label, theme.id).replace(/[*/<>]/g, '');
147
+ return [
148
+ '/**',
149
+ ` * ${label} - a Domma theme on ${theme.baseTheme}`,
150
+ ` * Exported ${date} from the Theme Roller.`,
151
+ ` * Apply alongside the base: <body class="dm-theme-${theme.baseTheme} dm-custom-${theme.id}">`,
152
+ ' */',
153
+ `.dm-custom-${theme.id} {`,
154
+ ...lines,
155
+ '}',
156
+ ''
157
+ ].join('\n');
158
+ }
159
+
160
+ /**
161
+ * The sidebar badge and its hover popover (the core's countUrl contract:
162
+ * {count, label, tone, details: {title, rows, items, empty}}, all plain text).
163
+ * Count = saved themes; the popover says what the site and the admin wear now,
164
+ * then each saved theme and where it is in use.
165
+ *
166
+ * @param {Array<{id, label, baseTheme, usage?: string[]}>} themes - with usage filled in
167
+ * @param {object} cfg - the CMS theme config
168
+ * @returns {object}
169
+ */
170
+ export function badgeFor(themes, cfg = {}) {
171
+ const byId = new Map(themes.map(t => [t.id, t]));
172
+ const name = (id) => (id ? (byId.get(id)?.label ?? id) : '-');
173
+ const inUse = themes.filter(t => (t.usage || []).length).length;
174
+ const auto = cfg.autoTheme?.enabled ? cfg.autoTheme : null;
175
+ const n = themes.length;
176
+ return {
177
+ count: n,
178
+ label: n ? `${n} custom theme${n === 1 ? '' : 's'}${inUse ? `, ${inUse} in use` : ''}` : 'No custom themes yet',
179
+ tone: 'info',
180
+ details: {
181
+ title: 'Theme Roller',
182
+ rows: [
183
+ auto
184
+ ? {label: 'Site (day / night)', value: `${name(auto.dayTheme)} / ${name(auto.nightTheme)}`}
185
+ : {label: 'Site theme', value: name(cfg.theme)},
186
+ {label: 'Admin theme', value: name(cfg.adminTheme)}
187
+ ],
188
+ items: themes.slice(0, 8).map(t => ({
189
+ text: t.label,
190
+ meta: `On ${t.baseTheme}${(t.usage || []).length ? ` · ${(t.usage).map(u => USAGE_LABEL[u] || u).join(', ')}` : ''}`,
191
+ ...((t.usage || []).length && {tone: 'ok'})
192
+ })),
193
+ empty: 'No saved themes yet - make one and Save it.'
194
+ }
195
+ };
196
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Theme Roller - the screen's pure decisions: the Saved themes filter and its
3
+ * counts, whether a save left newer changes unsaved, and whether a kept draft
4
+ * is older than what the server now holds. No DOM, no storage; tested in
5
+ * tests/view.test.js.
6
+ *
7
+ * @module theme-roller/admin/lib/view
8
+ */
9
+
10
+ export const SHOWS = ['all', 'used', 'unused'];
11
+
12
+ /** How many themes each filter shows. */
13
+ export function listCounts(themes = []) {
14
+ const used = themes.filter(t => (t.usage || []).length).length;
15
+ return {all: themes.length, used, unused: themes.length - used};
16
+ }
17
+
18
+ /** The themes a filter (and a search) shows. */
19
+ export function filterThemes(themes = [], show = 'all', query = '') {
20
+ const q = String(query || '').trim().toLowerCase();
21
+ return themes.filter(t => {
22
+ const used = (t.usage || []).length > 0;
23
+ if (show === 'used' && !used) return false;
24
+ if (show === 'unused' && used) return false;
25
+ return !q || `${t.label} ${t.id} ${t.baseTheme}`.toLowerCase().includes(q);
26
+ });
27
+ }
28
+
29
+ /** A stable text of a theme's changes, to compare one save with the next. */
30
+ export function changesKey(changes = {}) {
31
+ return JSON.stringify(Object.keys(changes).sort().map(k => [k, changes[k]]));
32
+ }
33
+
34
+ /**
35
+ * After a save: is what is in the roller now still what was sent? Anything
36
+ * changed while the save was in flight stays unsaved.
37
+ * @param {string} sent - changesKey(…) + the base, as sent
38
+ * @param {string} now - the same, read after the save
39
+ */
40
+ export const stillDirty = (sent, now) => sent !== now;
41
+
42
+ /**
43
+ * What a kept draft is worth on restore, as a sentence to add to the question
44
+ * (or '' when nothing changed since): the theme it edits was saved again, or
45
+ * deleted, or the Custom CSS file changed.
46
+ * @param {object} draft - {theme?: {id, was}, css?, cssWas?}
47
+ * @param {object[]} themes - the saved themes now
48
+ * @param {string|null} cssNow - content/custom.css now (null = not loaded)
49
+ */
50
+ export function staleNote(draft, themes = [], cssNow = null) {
51
+ const notes = [];
52
+ if (draft?.theme?.id) {
53
+ const t = themes.find(x => x.id === draft.theme.id);
54
+ if (!t) notes.push('The theme has been deleted since - keeping yours starts it again as a new theme.');
55
+ else if (draft.theme.was && t.updatedAt !== draft.theme.was) notes.push('The theme was saved again since - keeping yours puts your older changes over it once you Save.');
56
+ }
57
+ if (draft?.css != null && draft.cssWas != null && cssNow != null && cssNow !== draft.cssWas) {
58
+ notes.push('content/custom.css has changed since - keeping yours replaces the newer file once you Save.');
59
+ }
60
+ return notes.join(' ');
61
+ }
62
+
63
+ /**
64
+ * The question asked when a kept draft is offered back: OK throws it away,
65
+ * Cancel and Escape keep it (restore). Plain text - the caller escapes it.
66
+ * @param {object} draft - {theme?: {id, label}, css?}
67
+ * @param {string} [note] - staleNote()
68
+ */
69
+ export function restoreQuestion(draft, note = '') {
70
+ const parts = [];
71
+ if (draft?.theme) parts.push(draft.theme.id ? `the theme "${draft.theme.label || draft.theme.id}"` : 'a new theme');
72
+ if (draft?.css != null) parts.push('the Custom CSS');
73
+ return `Throw away the changes you left unsaved to ${parts.join(' and ') || 'this screen'}?${note ? ` ${note}` : ''}`;
74
+ }
75
+
76
+ /** The answers that go with restoreQuestion: OK throws away, Cancel (and Escape) keeps. */
77
+ export const RESTORE_ANSWERS = Object.freeze({confirmText: 'Throw away', cancelText: 'Keep them'});
78
+
79
+ /**
80
+ * Where the kept draft lives: per signed-in person, so a shared browser never
81
+ * offers one person's work to another. No one signed in: null - keep nothing.
82
+ */
83
+ export function draftKeyFor(uid) {
84
+ return uid ? `theme-roller.drafts.${uid}` : null;
85
+ }
86
+
87
+ /** The pre-1.2.1 key, shared by everyone on the browser - removed when found, never read. */
88
+ export const OLD_DRAFT_KEY = 'theme-roller.draft';
@@ -0,0 +1,73 @@
1
+ <!-- Theme Roller. Bound with M.applyBindings - see admin/views/theme-roller-view.js, which also
2
+ inserts the row template (the router renders a templateUrl through _.render, eating any {{…}}). -->
3
+ <div class="trx">
4
+ <p class="trx-alert" data-bind-hidden="supported.value">
5
+ <span data-icon="alert-triangle" data-icon-size="16"></span>
6
+ This CMS cannot use custom themes yet - it needs Domma CMS 0.73 or later. Themes still save here.
7
+ </p>
8
+
9
+ <div class="tabs trx-tabs">
10
+ <div class="tab-list">
11
+ <button type="button" class="tab-item active">Roller</button>
12
+ <button type="button" class="tab-item">Saved themes <span class="trx-count" data-bind-text="themes.value.length"></span></button>
13
+ <button type="button" class="tab-item">Custom CSS</button>
14
+ </div>
15
+
16
+ <!-- Roller -->
17
+ <div class="tab-panel active">
18
+ <div class="trx-bar">
19
+ <div class="trx-editing">
20
+ <strong data-bind-text="editingLabel.value">New theme</strong>
21
+ <small data-bind-text="editingMeta.value"></small>
22
+ <span class="trx-chip is-dirty" data-bind-hidden="!dirty.value" title="Changes not saved yet">Unsaved</span>
23
+ <span class="trx-chips" data-bind-hidden="!editingUsage.value.length">
24
+ <span class="trx-chip" data-bind-text="editingUsageText.value"></span>
25
+ </span>
26
+ </div>
27
+ <div class="trx-actions">
28
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="newTheme" title="Start a new theme">
29
+ <span data-icon="plus" data-icon-size="14"></span> New</button>
30
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="saving.value">
31
+ <span data-icon="save" data-icon-size="14"></span> <span data-bind-text="saveText.value">Save</span></button>
32
+ <button type="button" class="btn btn-sm btn-outline" data-on-click="saveAs" data-bind-hidden="!editingId.value" title="Save these settings as a new theme">
33
+ Save as…</button>
34
+ <button type="button" class="btn btn-sm btn-ghost trx-more" data-bind-hidden="!editingId.value" aria-label="More for this theme" title="More">⋮</button>
35
+ <span data-help-title="Theme Roller" data-help="Pick a base theme in the roller, change any colour, font, spacing or border, and Save. A saved theme can be used on the site or in the admin - from here (⋮, or right-click it under Saved themes) or from Content › Theme, where it is listed with the built-in themes. Only what you change is stored; everything else comes from the base."></span>
36
+ </div>
37
+ </div>
38
+ <div class="trx-roller"></div>
39
+ </div>
40
+
41
+ <!-- Saved themes -->
42
+ <div class="tab-panel">
43
+ <div class="trx-card">
44
+ <div class="trx-card-title">Saved themes
45
+ <span data-help-title="Saved themes" data-help="Click a theme to edit it in the roller. Right-click it (or ⋮, or Shift+F10) to use it on the site or in the admin, stop using it, duplicate, rename, export its CSS or delete it. Deleting a theme that is in use puts each place back on its base theme."></span></div>
46
+ <div class="trx-toolbar" hidden data-bind-hidden="loading.value || !themes.value.length">
47
+ <div class="trx-seg" role="group" aria-label="Which themes">
48
+ <button type="button" data-on-click="s_all" data-bind-class="show.value === 'all' && 'is-on'">All <small data-bind-text="counts.value.all"></small></button>
49
+ <button type="button" data-on-click="s_used" data-bind-class="show.value === 'used' && 'is-on'">In use <small data-bind-text="counts.value.used"></small></button>
50
+ <button type="button" data-on-click="s_unused" data-bind-class="show.value === 'unused' && 'is-on'">Not in use <small data-bind-text="counts.value.unused"></small></button>
51
+ </div>
52
+ <input type="search" class="form-input trx-search" placeholder="Name, base theme…" aria-label="Search saved themes" data-model="query.value">
53
+ </div>
54
+ <div class="trx-note trx-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading…</div>
55
+ <p class="trx-note" hidden data-bind-hidden="loading.value || rows.value.length" data-bind-text="emptyText.value"></p>
56
+ <div class="trx-list" data-each="rows.value key=id"></div>
57
+ </div>
58
+ </div>
59
+
60
+ <!-- Custom CSS -->
61
+ <div class="tab-panel">
62
+ <div class="trx-card">
63
+ <div class="trx-card-title">Custom CSS
64
+ <span data-help-title="Custom CSS" data-help="Hand-written CSS appended to every public page, after the theme - so it has the last word. Ctrl+S saves."></span></div>
65
+ <textarea class="form-input trx-css" spellcheck="false" placeholder="/* Add your custom CSS here */"></textarea>
66
+ <div class="trx-css-actions">
67
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="saveCss" data-bind-disabled="savingCss.value">
68
+ <span data-icon="save" data-icon-size="14"></span> Save CSS</button>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ </div>