domma-cms 1.8.33 → 1.8.35
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/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/plugins/docs/CLAUDE.md +318 -0
- package/plugins/docs/admin/css/docs.css +783 -0
- package/plugins/docs/admin/lib/api.js +340 -0
- package/plugins/docs/admin/lib/convert.js +482 -0
- package/plugins/docs/admin/lib/drafts.js +115 -0
- package/plugins/docs/admin/lib/editor.js +851 -0
- package/plugins/docs/admin/lib/find-replace.js +426 -0
- package/plugins/docs/admin/lib/folders.js +203 -0
- package/plugins/docs/admin/lib/panel-guard.js +73 -0
- package/plugins/docs/admin/lib/pdf-view.js +126 -0
- package/plugins/docs/admin/lib/send.js +156 -0
- package/plugins/docs/admin/lib/settings.js +142 -0
- package/plugins/docs/admin/lib/share.js +323 -0
- package/plugins/docs/admin/lib/shortcuts.js +44 -0
- package/plugins/docs/admin/lib/templates.js +194 -0
- package/plugins/docs/admin/lib/versions.js +192 -0
- package/plugins/docs/admin/templates/docs.html +92 -0
- package/plugins/docs/admin/templates/editor.html +86 -0
- package/plugins/docs/admin/views/docs.js +777 -0
- package/plugins/docs/admin/views/editor.js +978 -0
- package/plugins/docs/config.js +24 -0
- package/plugins/docs/docs/guide.md +270 -0
- package/plugins/docs/lib/extensions.js +213 -0
- package/plugins/docs/lib/importer.js +72 -0
- package/plugins/docs/lib/pdf-worker.js +48 -0
- package/plugins/docs/lib/pdf.js +222 -0
- package/plugins/docs/lib/store.js +748 -0
- package/plugins/docs/plugin.js +1108 -0
- package/plugins/docs/plugin.json +89 -0
- package/plugins/docs/plugin.public.js +114 -0
- package/plugins/docs/templates/public-doc.html +36 -0
- package/plugins/docs/tests/_env.js +10 -0
- package/plugins/docs/tests/_pdf.js +73 -0
- package/plugins/docs/tests/convert.test.js +95 -0
- package/plugins/docs/tests/drafts.test.js +82 -0
- package/plugins/docs/tests/pdf.test.js +86 -0
- package/plugins/docs/tests/plugin.test.js +816 -0
- package/plugins/docs/tests/shortcuts.test.js +30 -0
- package/plugins/free-tier.lock.json +105 -1
- package/plugins/garage/CLAUDE.md +51 -0
- package/plugins/garage/admin/css/garage.css +73 -0
- package/plugins/garage/admin/lib/garage.js +236 -0
- package/plugins/garage/admin/templates/garage.html +86 -0
- package/plugins/garage/admin/views/garage.js +581 -0
- package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
- package/plugins/garage/config.js +18 -0
- package/plugins/garage/docs/guide.md +118 -0
- package/plugins/garage/plugin.js +471 -0
- package/plugins/garage/plugin.json +68 -0
- package/plugins/garage/tests/access.test.js +134 -0
- package/plugins/garage/tests/garage.test.js +106 -0
- package/plugins/messages/CLAUDE.md +230 -0
- package/plugins/messages/admin/css/messages.css +117 -0
- package/plugins/messages/admin/lib/messages.js +452 -0
- package/plugins/messages/admin/templates/messages.html +76 -0
- package/plugins/messages/admin/views/messages.js +1219 -0
- package/plugins/messages/collections/user-message-threads/schema.json +22 -0
- package/plugins/messages/collections/user-messages/schema.json +23 -0
- package/plugins/messages/config.js +14 -0
- package/plugins/messages/docs/guide.md +176 -0
- package/plugins/messages/lib/call.js +138 -0
- package/plugins/messages/plugin.js +994 -0
- package/plugins/messages/plugin.json +59 -0
- package/plugins/messages/plugin.public.js +52 -0
- package/plugins/messages/templates/call.html +6 -0
- package/plugins/messages/tests/after-send.test.js +22 -0
- package/plugins/messages/tests/api.test.js +187 -0
- package/plugins/messages/tests/call.test.js +497 -0
- package/plugins/messages/tests/drafts.test.js +25 -0
- package/plugins/messages/tests/messages.test.js +167 -0
- package/plugins/theme-roller/CLAUDE.md +65 -0
- package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
- package/plugins/theme-roller/admin/lib/prompt.js +22 -0
- package/plugins/theme-roller/admin/lib/themes.js +196 -0
- package/plugins/theme-roller/admin/lib/view.js +88 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/docs/guide.md +104 -0
- package/plugins/theme-roller/plugin.js +263 -0
- package/plugins/theme-roller/plugin.json +42 -0
- package/plugins/theme-roller/tests/api.test.js +161 -0
- package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
- package/plugins/theme-roller/tests/themes.test.js +77 -0
- package/plugins/theme-roller/tests/view.test.js +55 -0
- package/server/routes/public.js +16 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/** The pure part of Messages - admin/lib/messages.js. */
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import {
|
|
5
|
+
ago, arrangeThreads, attachmentUrl, buildBubbles, cleanAttachments, cleanDrafts, dayLabel, filterCounts,
|
|
6
|
+
indexByThread, initials, messageRights, threadTitle, unreadBadge, UNREAD_AFTER_MS, unreadKey, unreadNotices,
|
|
7
|
+
CALL_PICK_MAX, callGroupName, callTarget, callTargetText} from '../admin/lib/messages.js';
|
|
8
|
+
|
|
9
|
+
const me = 'u1';
|
|
10
|
+
const T = (id, extra = {}) => ({id, kind: 'group-adhoc', name: id.toUpperCase(), participants: [{id: me, name: 'Me'}, {id: 'u2', name: 'Ann'}], unread: 0, lastMessageAt: '2026-09-20T10:00:00Z', ...extra});
|
|
11
|
+
|
|
12
|
+
test('titles: a DM is the other person, a group its name', () => {
|
|
13
|
+
assert.equal(threadTitle({kind: 'dm', participants: [{id: me, name: 'Me'}, {id: 'u2', name: 'Ann Lee'}]}, me), 'Ann Lee');
|
|
14
|
+
assert.equal(threadTitle({kind: 'dm', participants: [me, 'u9']}, me), 'u9');
|
|
15
|
+
assert.equal(threadTitle({kind: 'group-adhoc', name: ''}, me), 'Unnamed group');
|
|
16
|
+
assert.equal(initials('Ann Lee'), 'AL');
|
|
17
|
+
assert.equal(initials(''), '?');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('arrange: newest first; inbox hides archived; unread and archived filters; search', () => {
|
|
21
|
+
const list = [T('a', {lastMessageAt: '2026-09-01T00:00:00Z', unread: 2}), T('b', {archivedBy: [me]}), T('c', {lastMessageAt: '2026-09-24T00:00:00Z', lastMessagePreview: 'budget'})];
|
|
22
|
+
assert.deepEqual(arrangeThreads(list, {myId: me}).map(t => t.id), ['c', 'a']);
|
|
23
|
+
assert.deepEqual(arrangeThreads(list, {myId: me, filter: 'unread'}).map(t => t.id), ['a']);
|
|
24
|
+
assert.deepEqual(arrangeThreads(list, {myId: me, filter: 'archived'}).map(t => t.id), ['b']);
|
|
25
|
+
assert.deepEqual(arrangeThreads(list, {myId: me, query: 'BUDGET'}).map(t => t.id), ['c']);
|
|
26
|
+
assert.deepEqual(filterCounts(list, me), {inbox: 2, unread: 1, archived: 1});
|
|
27
|
+
// Someone else's archive is not mine.
|
|
28
|
+
assert.equal(arrangeThreads([T('x', {archivedBy: ['u2']})], {myId: me}).length, 1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('rights: edit only mine, inside the window; delete mine, or as admin or thread creator', () => {
|
|
32
|
+
const now = Date.parse('2026-09-25T12:00:00Z');
|
|
33
|
+
const m = {senderId: me, createdAt: '2026-09-25T11:50:00Z'};
|
|
34
|
+
assert.deepEqual([messageRights(m, {myId: me, now}).canEdit, messageRights(m, {myId: me, now}).editLeftMinutes], [true, 5]);
|
|
35
|
+
assert.equal(messageRights({...m, createdAt: '2026-09-25T11:00:00Z'}, {myId: me, now}).canEdit, false);
|
|
36
|
+
const theirs = {senderId: 'u2', createdAt: m.createdAt};
|
|
37
|
+
assert.equal(messageRights(theirs, {myId: me, now}).canDelete, false);
|
|
38
|
+
assert.equal(messageRights(theirs, {myId: me, now, isAdmin: true}).canDelete, true);
|
|
39
|
+
assert.equal(messageRights(theirs, {myId: me, now, threadCreator: me}).canDelete, true);
|
|
40
|
+
assert.equal(messageRights({...m, deletedAt: 'x'}, {myId: me, now}).canDelete, false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('attachments: the address is always rebuilt - a stored url is never trusted', () => {
|
|
44
|
+
assert.equal(attachmentUrl({mediaId: 'a b.png'}), '/media/a%20b.png');
|
|
45
|
+
assert.equal(attachmentUrl({mediaId: 'x.png', url: 'javascript:alert(1)'}), '/media/x.png');
|
|
46
|
+
assert.equal(attachmentUrl({url: '/media/y.png'}), '/media/y.png');
|
|
47
|
+
const clean = cleanAttachments([{mediaId: 'ok.pdf', filename: 'Ok', url: 'https://evil'}, {mediaId: '../etc/passwd'}, {filename: 'no id'}]);
|
|
48
|
+
assert.deepEqual(clean, [{mediaId: 'ok.pdf', filename: 'Ok', mime: '', size: 0, url: '/media/ok.pdf'}]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('drafts: whitelisted, empty ones dropped', () => {
|
|
52
|
+
const d = cleanDrafts({t1: {text: 'hi', attachments: [{mediaId: 'a.png', filename: 'a'}, {nope: 1}]}, t2: {text: ' ', attachments: []}, t3: 'junk'});
|
|
53
|
+
assert.deepEqual(Object.keys(d), ['t1']);
|
|
54
|
+
assert.equal(d.t1.attachments.length, 1);
|
|
55
|
+
assert.deepEqual(cleanDrafts(null), {});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('bubbles: a day separator on each new day; the sender only in groups, only when it changes', () => {
|
|
59
|
+
const now = Date.parse('2026-09-25T12:00:00Z');
|
|
60
|
+
const msgs = [
|
|
61
|
+
{id: '1', senderId: 'u2', body: 'hi', createdAt: '2026-09-24T09:00:00Z'},
|
|
62
|
+
{id: '2', senderId: 'u2', body: 'again', createdAt: '2026-09-24T09:01:00Z'},
|
|
63
|
+
{id: '3', senderId: me, body: 'yo', createdAt: '2026-09-25T09:00:00Z', editedAt: 'x'},
|
|
64
|
+
{id: '4', senderId: 'u2', body: '', deletedAt: 'x', createdAt: '2026-09-25T09:05:00Z', attachments: [{mediaId: 'a.png', mime: 'image/png'}]}
|
|
65
|
+
];
|
|
66
|
+
const b = buildBubbles(msgs, {myId: me, isGroup: true, nameOf: () => 'Ann', now});
|
|
67
|
+
assert.deepEqual(b.map(x => x.isDay ? `[${x.dayLabel}]` : x.id), ['[Yesterday]', '1', '2', '[Today]', '3', '4']);
|
|
68
|
+
assert.deepEqual(b.filter(x => !x.isDay).map(x => x.showSender), [true, false, false, true]);
|
|
69
|
+
assert.match(b[4].meta, /edited/);
|
|
70
|
+
assert.equal(b[5].bodyText, 'message deleted');
|
|
71
|
+
assert.equal(b[5].attachments.length, 0, 'a deleted message shows no attachments');
|
|
72
|
+
assert.equal(buildBubbles(msgs, {myId: me, isGroup: false, now}).some(x => x.showSender), false);
|
|
73
|
+
assert.equal(dayLabel('nonsense', now), '');
|
|
74
|
+
// 1.6: a line typed in the conversation's call says so beside the time.
|
|
75
|
+
const inCall = buildBubbles([{id: '5', senderId: 'u2', body: 'from the call', createdAt: '2026-09-25T10:00:00Z', callChat: {id: 'x', who: 'w'}}], {myId: me, now});
|
|
76
|
+
assert.match(inCall.find(x => x.id === '5').meta, /in the call/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('badge: unread in conversations not archived; plain text popover', () => {
|
|
80
|
+
const now = Date.parse('2026-09-25T12:00:00Z');
|
|
81
|
+
const b = unreadBadge([T('a', {unread: 2}), T('b', {unread: 5, archivedBy: [me]}), T('c', {unread: 1, name: '<b>x</b>'})], me, now);
|
|
82
|
+
assert.equal(b.count, 3);
|
|
83
|
+
assert.equal(b.label, '3 unread messages in 2 conversations');
|
|
84
|
+
assert.equal(b.tone, 'info');
|
|
85
|
+
assert.deepEqual(b.details.rows.map(r => r.value), ['3', '2', '1']);
|
|
86
|
+
assert.ok(b.details.items.some(i => i.text === '<b>x</b>'), 'names pass through as text');
|
|
87
|
+
assert.match(b.details.items.find(i => i.text === 'A').meta, /^2 unread · /);
|
|
88
|
+
assert.equal(unreadBadge([], me, now).count, 0);
|
|
89
|
+
assert.equal(unreadBadge([], me, now).showZero, true);
|
|
90
|
+
assert.equal(b.showZero, true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('unread notices: waiting 10 minutes, one per conversation, to the recipient only', () => {
|
|
94
|
+
const now = Date.parse('2026-09-26T12:00:00Z');
|
|
95
|
+
const at = (mins) => new Date(now - mins * 60_000).toISOString();
|
|
96
|
+
const threads = [
|
|
97
|
+
{id: 't1', kind: 'dm', participants: ['a', 'b']},
|
|
98
|
+
{id: 't2', kind: 'group-adhoc', name: 'Site team', participants: ['a', 'b', 'c'], archivedBy: ['c']}
|
|
99
|
+
];
|
|
100
|
+
const msgs = [
|
|
101
|
+
{threadId: 't1', senderId: 'a', body: 'Are you in today?', createdAt: at(12), readBy: ['a']},
|
|
102
|
+
{threadId: 't1', senderId: 'a', body: 'just now', createdAt: at(2), readBy: ['a']},
|
|
103
|
+
{threadId: 't2', senderId: 'b', body: 'Skip', createdAt: at(30), readBy: ['b', 'a']},
|
|
104
|
+
{threadId: 't2', senderId: 'b', body: 'old', createdAt: at(60 * 24 * 3), readBy: ['b']}
|
|
105
|
+
];
|
|
106
|
+
const n = unreadNotices(threads, msgs, {a: 'Ann', b: 'Bob'}, now);
|
|
107
|
+
assert.equal(n.length, 1, 'b in the DM; a read the group; c archived it; the 3-day-old one is left alone');
|
|
108
|
+
assert.equal(n[0].title, 'Ann sent you a message');
|
|
109
|
+
assert.equal(n[0].body, 'Are you in today?');
|
|
110
|
+
assert.deepEqual(n[0].audience, {userIds: ['b']});
|
|
111
|
+
assert.equal(n[0].dedupeKey, unreadKey('t1', 'b'));
|
|
112
|
+
assert.ok(UNREAD_AFTER_MS === 600_000);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('older dates read dd/mm/yyyy: the list stamp and the day separator', () => {
|
|
116
|
+
const now = new Date(2026, 8, 27, 12, 0).getTime();
|
|
117
|
+
const old = new Date(2026, 8, 3, 9, 30).toISOString();
|
|
118
|
+
assert.equal(ago(old, now), '03/09/2026');
|
|
119
|
+
assert.equal(dayLabel(old, now), 'Thu 03/09/2026');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('Call…: which conversation a call with the people picked goes to', () => {
|
|
123
|
+
const P = (...ids) => ids.map(id => ({id, name: id}));
|
|
124
|
+
const threads = [
|
|
125
|
+
{id: 'dm-ann', kind: 'dm', participants: P(me, 'u2'), lastMessageAt: '2026-09-20T10:00:00Z'},
|
|
126
|
+
{id: 'dm-bob-archived', kind: 'dm', participants: [me, 'u3'], archivedBy: [me]},
|
|
127
|
+
{id: 'g-old', kind: 'group-adhoc', name: 'Old', participants: P(me, 'u2', 'u3'), lastMessageAt: '2026-09-01T10:00:00Z'},
|
|
128
|
+
{id: 'g-new', kind: 'group-adhoc', name: 'New', participants: P('u3', me, 'u2'), lastMessageAt: '2026-09-25T10:00:00Z'},
|
|
129
|
+
{id: 'g-big', kind: 'group-adhoc', name: 'Big', participants: P(me, 'u2', 'u3', 'u4')},
|
|
130
|
+
{id: 'g-gone', kind: 'group-adhoc', name: 'Gone', participants: P(me, 'u4', 'u5'), archivedBy: [me]},
|
|
131
|
+
{id: 'm-team', kind: 'group-managed', name: 'Team', participants: P(me, 'u5', 'u6')},
|
|
132
|
+
{id: 'g-without-me', kind: 'group-adhoc', name: 'Them', participants: P('u6', 'u7')}
|
|
133
|
+
];
|
|
134
|
+
assert.equal(callTarget(threads, me, ['u2']).id, 'dm-ann', 'one person: your DM');
|
|
135
|
+
assert.equal(callTarget(threads, me, ['u3']).id, 'dm-bob-archived', 'a DM is one per pair, archived or not');
|
|
136
|
+
assert.deepEqual(callTarget(threads, me, ['u9']), {create: {kind: 'dm', participants: ['u9']}});
|
|
137
|
+
assert.equal(callTarget(threads, me, ['u3', 'u2']).id, 'g-new', 'exactly those people, the latest used');
|
|
138
|
+
assert.equal(callTarget(threads, me, ['u2', 'u3', 'u2', me]).id, 'g-new', 'repeats and yourself are ignored');
|
|
139
|
+
assert.deepEqual(callTarget(threads, me, ['u4', 'u5']), {create: {kind: 'group-adhoc', participants: ['u4', 'u5']}}, 'not one you archived');
|
|
140
|
+
assert.ok(callTarget(threads, me, ['u5', 'u6']).create, 'a managed group is not taken for an ad-hoc call');
|
|
141
|
+
assert.ok(callTarget(threads, me, ['u6', 'u7']).create, 'nor one you are not in');
|
|
142
|
+
assert.equal(callTarget(threads, me, []), null);
|
|
143
|
+
assert.equal(CALL_PICK_MAX, 5);
|
|
144
|
+
assert.ok(callTarget(threads, me, ['a', 'b', 'c', 'd', 'e']).create);
|
|
145
|
+
assert.equal(callTarget(threads, me, ['a', 'b', 'c', 'd', 'e', 'f']), null, 'a call holds 6, you included');
|
|
146
|
+
|
|
147
|
+
assert.equal(callGroupName([{name: 'Alex Carter'}, {name: 'Priya Shah'}, {name: 'Sam Patel'}]), 'Alex, Priya and Sam');
|
|
148
|
+
assert.equal(callGroupName([{name: 'Alex Carter'}, {name: 'Priya'}]), 'Alex and Priya');
|
|
149
|
+
assert.equal(callGroupName([]), 'Call');
|
|
150
|
+
assert.ok(callGroupName(Array.from({length: 30}, () => ({name: 'Bartholomew'}))).length <= 80);
|
|
151
|
+
|
|
152
|
+
assert.equal(callTargetText(callTarget(threads, me, ['u2']), me), 'Calls in your conversation with u2.');
|
|
153
|
+
assert.equal(callTargetText(callTarget(threads, me, ['u2', 'u3']), me), 'Calls in your group "New".');
|
|
154
|
+
assert.equal(callTargetText(callTarget(threads, me, ['u8', 'u9']), me), 'Starts a new group for these people and calls.');
|
|
155
|
+
assert.equal(callTargetText(null, me), '');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('indexByThread: one list per conversation, in collection order; entries with no thread are left out', () => {
|
|
159
|
+
const E = (id, threadId, extra = {}) => ({id, data: {threadId, ...extra}});
|
|
160
|
+
const idx = indexByThread([E('m1', 't1'), E('m2', 't2'), E('m3', 't1', {deletedAt: 'x'}), {id: 'odd', data: {}}, null]);
|
|
161
|
+
assert.deepEqual([...idx.keys()], ['t1', 't2']);
|
|
162
|
+
assert.deepEqual(idx.get('t1').map(e => e.id), ['m1', 'm3']); // deleted ones stay in: the routes decide what to skip
|
|
163
|
+
assert.deepEqual(idx.get('t2').map(e => e.id), ['m2']);
|
|
164
|
+
assert.equal(idx.get('t9'), undefined);
|
|
165
|
+
assert.equal(indexByThread([]).size, 0);
|
|
166
|
+
assert.equal(indexByThread(undefined).size, 0);
|
|
167
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Theme Roller
|
|
2
|
+
|
|
3
|
+
Makes custom themes - a built-in Domma theme plus the `--dm-*` tokens changed on top - and offers them to
|
|
4
|
+
the CMS with `hooks.registerThemes` (domma-cms 0.73+). The CMS does the rest: they are listed under
|
|
5
|
+
Custom themes in Content › Theme and applied as `dm-theme-<base> dm-custom-<id>` (core
|
|
6
|
+
`server/services/themeSettings.js`, "Custom themes"). This plugin only stores them and offers them.
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
- `plugin.js` - API + provider. Themes live in `data/themes/<id>.json` (a site's own, declared in
|
|
11
|
+
`contentPaths`, never shipped); an in-memory Map mirrors them so the provider - called on every page
|
|
12
|
+
render - never touches disk. Writes are tmp+rename. "Use" goes through the core's `saveThemeConfig`.
|
|
13
|
+
- `admin/lib/themes.js` - PURE, shared by server and browser: slug/unique ids (never a built-in id),
|
|
14
|
+
cleaning tokens (same safe-value rule as the core), `usageOf`, `withoutTheme` (delete in use -> base),
|
|
15
|
+
`themeCss` (export).
|
|
16
|
+
- `admin/views/theme-roller-view.js`, `admin/templates/theme-roller.html`, `admin/css/theme-roller.css`.
|
|
17
|
+
- **The Custom CSS tab** edits the site-wide `content/custom.css` (inlined into every public page after the
|
|
18
|
+
theme) through CORE's `GET/PUT /api/settings/custom-css` - not this plugin's API - so it needs the
|
|
19
|
+
`settings.update` permission (Site Settings > Edit); without it the tab cannot load or save. The box stays
|
|
20
|
+
locked until the file has loaded (saving an empty box would replace the whole file); Ctrl+S saves.
|
|
21
|
+
- `tests/themes.test.js` (pure), `tests/api.test.js` (API against the real core theme service -
|
|
22
|
+
snapshots and restores the CMS checkout's config/theme.json and site.json).
|
|
23
|
+
|
|
24
|
+
A stored theme is `{id, label, baseTheme, changes, createdAt, updatedAt}`. Files from 1.0 (`name`, no
|
|
25
|
+
label) load as id = label = name.
|
|
26
|
+
|
|
27
|
+
## Gotchas (the ThemeRoller component, from Domma's tools bundle)
|
|
28
|
+
|
|
29
|
+
- It re-themes the WHOLE admin while it runs (theme.set + a preview `<style>` on `body, :root` with
|
|
30
|
+
`!important`). The view takes the admin's `dm-custom-*` class off `<body>` while it runs, and on
|
|
31
|
+
leaving destroys the roller and re-applies the admin theme via the core's `admin-theme.js`.
|
|
32
|
+
- There is NO `getBaseTheme()` (1.0 called it on Save and threw - that was "it doesn't save", along with
|
|
33
|
+
`data/themes/` never being created). The tools bundle has its OWN theme engine, so the roller's
|
|
34
|
+
dropdown and `Domma.theme.get()` can disagree: the base is `#theme-select`'s value (`currentBase`).
|
|
35
|
+
- `loadPreset()` / `_refreshInputs()` wipe the changes. `loadInto` sets the base, builds a fresh
|
|
36
|
+
roller, puts `_changes` in place and re-renders its inputs (`_render` + accordion + `_bindEvents`),
|
|
37
|
+
falling back to `setAll` if those privates disappear. `_render` reads `_changes[var] || computed`.
|
|
38
|
+
- E.prompt/E.confirm render HTML (sanitised): labels in the message go in `esc()`d. Never give a prompt its starting
|
|
39
|
+
value through `inputValue` (a `"` cuts it short) - use `promptWith` (admin/lib/prompt.js); a test bans it.
|
|
40
|
+
- The public theme switcher cannot offer custom themes (the core refuses them there): a visitor can only switch
|
|
41
|
+
between built-in themes. Say so to a site owner who expects their custom theme in the switcher.
|
|
42
|
+
|
|
43
|
+
## 1.2.0 (2026-10-15 Tool Upgrade, second pass)
|
|
44
|
+
|
|
45
|
+
- Saved themes: All / In use / Not in use with counts, a search box, and core's skeleton on the first load
|
|
46
|
+
(`loading.js` at run time; `.trx-loading` keeps its text on an older core). `load()` checks `here()` after awaits.
|
|
47
|
+
- **Saves never swallow newer edits**: `save()` compares what it sent (`base|changesKey`) with the roller after the
|
|
48
|
+
PUT and `saveCss()` the box with what it sent - anything changed meanwhile stays unsaved (and is drafted on leave).
|
|
49
|
+
- **Drafts know their age**: the theme draft carries `was` (the theme's `updatedAt` when editing began) and the CSS
|
|
50
|
+
draft `cssWas` (the file as loaded or last saved). `staleNote()` (admin/lib/view.js, tests/view.test.js) adds to the
|
|
51
|
+
restore question when the theme was saved again or deleted since, or custom.css changed.
|
|
52
|
+
|
|
53
|
+
## 1.2.1 - unsaved-work sweep
|
|
54
|
+
|
|
55
|
+
The draft is per user (`theme-roller.drafts.<uid>`; the old shared `theme-roller.draft` is removed unread). Restore asks
|
|
56
|
+
"Throw away the changes you left unsaved to …?" (OK throws away, Cancel/Escape keeps; `restoreQuestion` in
|
|
57
|
+
admin/lib/view.js). A CSS draft whose file cannot be loaded is kept for next time, never dropped - `pendingCss` carries it
|
|
58
|
+
into the leave write so unsaved theme changes cannot overwrite it. Row Enter and every menu action run a task later, so
|
|
59
|
+
the Enter cannot answer the confirm they open.
|
|
60
|
+
|
|
61
|
+
## 1.3.0 (2026-10-21): free
|
|
62
|
+
|
|
63
|
+
`tier: free`, no `closedSource` - no licence is needed or checked any more (the engine only checks a
|
|
64
|
+
`closedSource` plugin). Included in every hosted plan and free to self-host; not `bundle: false`, so the next
|
|
65
|
+
`make sync-free-tier` ships it in the engine.
|
|
@@ -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';
|