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,323 @@
1
+ /**
2
+ * The Share panel: people with access (Viewer / Editor), adding people with
3
+ * an optional email, and the public link. A slideover bound with
4
+ * domma-reactive; add-ons (Docs Pro) add their own section below the link
5
+ * through `sections` - expiry dates, passwords.
6
+ *
7
+ * @module docs/admin/lib/share
8
+ */
9
+
10
+ import {docsApi, esc, icon} from '/plugins/docs/admin/lib/api.js';
11
+ import {guardClose, claimPanel, tieToScreen} from '/plugins/docs/admin/lib/panel-guard.js';
12
+
13
+ const KEBAB = icon('more-vertical', 16);
14
+ const PERSON_ROW = `
15
+ <div class="docs-share__person" data-user="{{userId}}">
16
+ <span class="docs-avatar" aria-hidden="true">{{initial}}</span>
17
+ <span class="docs-share__name">{{name}}</span>
18
+ <select class="form-input form-input-sm docs-share__role" aria-label="Access for {{name}}" data-role-for="{{userId}}">
19
+ <option value="viewer">Viewer</option>
20
+ <!--suggester-->
21
+ <option value="editor">Editor</option>
22
+ </select>
23
+ <button type="button" class="docs-kebab docs-share__kebab" aria-label="Actions for {{name}}" title="Actions">${KEBAB}</button>
24
+ <button type="button" class="btn btn-xs btn-ghost docs-share__remove" data-remove="{{userId}}" aria-label="Remove {{name}}" title="Remove">×</button>
25
+ </div>`;
26
+
27
+ const PICK_ROW = `<button type="button" class="docs-share__pick" data-pick="{{id}}"><span class="docs-avatar" aria-hidden="true">{{initial}}</span><span>{{label}}</span></button>`;
28
+
29
+ const initial = (name) => (String(name || '?').trim()[0] || '?').toUpperCase();
30
+
31
+ /**
32
+ * @param {object} opts
33
+ * @param {object} opts.doc the document as GET /documents/:id answers
34
+ * @param {object} opts.kit loadKit()
35
+ * @param {Function} opts.onChange (doc) => void, after any change is saved
36
+ * @param {Array} [opts.sections] add-on sections: ({doc, container, refresh}) => dispose|void
37
+ * @param {boolean} [opts.suggestions] an add-on (Docs Pro) lets people suggest changes (2.5)
38
+ * @param {object} [opts.life] the opening screen's {alive(), add(fn)}: the panel closes with it (2.10)
39
+ * @returns {Promise<object|null>} the slideover; null when one is already open or the screen went while it opened
40
+ */
41
+ export async function openShare(opts) {
42
+ // One Share panel at a time - a second click while the first is still fetching people opened two.
43
+ const release = claimPanel('share');
44
+ if (!release) return null;
45
+ try {
46
+ const so = await buildShare(opts, release);
47
+ if (!so) release();
48
+ return so;
49
+ } catch (err) {
50
+ release();
51
+ throw err;
52
+ }
53
+ }
54
+
55
+ async function buildShare({doc, kit, onChange, sections = [], suggestions = false, life = null}, release) {
56
+ let people = [];
57
+ try { people = await docsApi.people(); } catch { people = []; }
58
+ if (life && !life.alive()) return null;
59
+
60
+ const current = M.observable(doc);
61
+ const shares = M.observable((doc.shares || []).map(s => ({userId: s.userId, name: s.name, role: s.role})));
62
+ const saved = M.observable(JSON.stringify(shares.peek()));
63
+ const query = M.observable('');
64
+ const notify = M.observable(true);
65
+ const message = M.observable('');
66
+ const saving = M.observable(false);
67
+ const linkBusy = M.observable(false);
68
+
69
+ const rows = M.observable([]);
70
+ let panelRef = null;
71
+ const matches = M.observable([]);
72
+ const disposers = [];
73
+ disposers.push(M.effect(() => {
74
+ const list = shares.value;
75
+ rows.value = list.map(s => ({key: s.userId, userId: s.userId, name: s.name || 'Unknown', initial: initial(s.name)}));
76
+ // A select's value is a property, and the row template cannot set it:
77
+ // put each one right once the rows are drawn.
78
+ requestAnimationFrame(() => {
79
+ for (const s of list) {
80
+ const sel = panelRef?.querySelector(`[data-role-for="${CSS.escape(s.userId)}"]`);
81
+ if (sel && sel.value !== s.role) sel.value = s.role;
82
+ }
83
+ });
84
+ }));
85
+ disposers.push(M.effect(() => {
86
+ const q = query.value.trim().toLowerCase();
87
+ const taken = new Set(shares.value.map(s => s.userId));
88
+ matches.value = !q ? [] : people
89
+ .filter(p => !taken.has(p.id) && (p.name.toLowerCase().includes(q) || (p.email || '').toLowerCase().includes(q)))
90
+ .slice(0, 8)
91
+ .map(p => ({key: p.id, id: p.id, initial: initial(p.name), label: p.email ? `${p.name} - ${p.email}` : p.name}));
92
+ }));
93
+
94
+ const linkUrl = () => {
95
+ const t = current.peek().publicLink?.token;
96
+ return t ? `${location.origin}/docs/s/${t}` : '';
97
+ };
98
+ const link = M.observable(linkUrl());
99
+
100
+ const data = {
101
+ rows, matches, query, notify, message, saving, linkBusy, link,
102
+ ownerName: doc.ownerName || 'Owner',
103
+ ownerInitial: initial(doc.ownerName),
104
+ isOwner: doc.access === 'owner',
105
+ // 2.11: a document that may not leave Docs now has no public link either.
106
+ linksAllowed: doc.publicLinksAllowed !== false && !doc.outwardRefusal,
107
+ linksNote: doc.outwardRefusal || 'An administrator has switched public links off for this site.',
108
+ // 2.11: restricted (a members-only wiki page) - people already here stay and can be removed; nobody new.
109
+ canAdd: !doc.restricted,
110
+ addNote: doc.restricted ? `${doc.restricted} No one new can be given access - people listed above keep theirs, and you can remove them.` : '',
111
+ hasMatches: M.computed(() => matches.value.length > 0),
112
+ noMatches: M.computed(() => query.value.trim().length > 0 && matches.value.length === 0),
113
+ dirty: M.computed(() => JSON.stringify(shares.value) !== saved.value),
114
+ hasAdded: M.computed(() => {
115
+ const before = new Set((current.value.shares || []).map(s => s.userId));
116
+ return shares.value.some(s => !before.has(s.userId));
117
+ }),
118
+ linkOn: M.computed(() => Boolean(link.value)),
119
+ noShares: M.computed(() => shares.value.length === 0)
120
+ };
121
+
122
+ const so = E.slideover({title: `Share "${esc(doc.title || 'Untitled')}"`, size: 'md', position: 'right'});
123
+ const panel = document.createElement('div');
124
+ panel.className = 'docs-share';
125
+ panelRef = panel;
126
+ panel.innerHTML = `
127
+ <section class="docs-share__group">
128
+ <h3 class="docs-share__heading">People with access
129
+ <span data-help-title="People with access" data-help="Viewers can read, copy and export. Suggesters (with Docs Pro) can also suggest changes for an editor to accept or reject. Editors can also change the document and share it. Only the owner can move it, remove editors, or put it in Trash. Only one person edits at a time - everyone else sees who is editing."></span>
130
+ </h3>
131
+ <div class="docs-share__person docs-share__person--owner">
132
+ <span class="docs-avatar" aria-hidden="true" data-bind-text="ownerInitial"></span>
133
+ <span class="docs-share__name" data-bind-text="ownerName"></span>
134
+ <span class="docs-chip">Owner</span>
135
+ </div>
136
+ <div class="docs-share__people" data-each="rows.value key=key"></div>
137
+ <p class="docs-share__note" data-bind-hidden="!noShares.value">Only the owner can open this document.</p>
138
+ </section>
139
+ <section class="docs-share__group">
140
+ <p class="docs-share__note" data-bind-hidden="canAdd" data-bind-text="addNote"></p>
141
+ <div data-bind-hidden="!canAdd">
142
+ <label class="form-label" for="docs-share-find">Add people
143
+ <span data-help="Type a name. Only people whose role includes Docs are listed. They are added as Viewers; change that before you save."></span>
144
+ </label>
145
+ <input id="docs-share-find" type="search" class="form-input" placeholder="Name or email" autocomplete="off" data-model="query.value">
146
+ <div class="docs-share__matches" data-bind-hidden="!hasMatches.value" data-each="matches.value key=key"></div>
147
+ <p class="docs-share__note" data-bind-hidden="!noMatches.value">Nobody by that name has Docs.</p>
148
+ <div class="docs-share__notify" data-bind-hidden="!hasAdded.value">
149
+ <label class="docs-check"><input type="checkbox" data-model="notify.value"> Email the people I've added</label>
150
+ <textarea class="form-input" rows="2" placeholder="Message (optional)" data-model="message.value" data-bind-hidden="!notify.value"></textarea>
151
+ </div>
152
+ </div>
153
+ <div class="docs-share__actions">
154
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="!dirty.value || saving.value">Save access</button>
155
+ </div>
156
+ </section>
157
+ <section class="docs-share__group">
158
+ <h3 class="docs-share__heading">General access
159
+ <span data-help-title="Anyone with the link" data-help="Turns on a read-only web page for this document at a long, unguessable address. Anyone who has the address can read it and download it - no sign-in. It is never listed or indexed by search engines. Turn it off and the address stops working at once."></span>
160
+ </h3>
161
+ <div data-bind-hidden="!linksAllowed">
162
+ <label class="docs-check"><input type="checkbox" data-bind-checked="linkOn.value" data-on-change="toggleLink" data-bind-disabled="linkBusy.value"> Anyone with the link can view</label>
163
+ <div class="docs-share__link" data-bind-hidden="!linkOn.value">
164
+ <input type="text" class="form-input form-input-sm" readonly aria-label="Public link" data-bind-value="link.value">
165
+ <button type="button" class="btn btn-sm btn-outline" data-on-click="copy">${icon('copy', 14)} Copy</button>
166
+ <a class="btn btn-sm btn-ghost" target="_blank" rel="noopener" data-bind-href="link.value" title="Open the public page">${icon('external-link', 14)}</a>
167
+ </div>
168
+ </div>
169
+ <p class="docs-share__note" data-bind-hidden="linksAllowed" data-bind-text="linksNote"></p>
170
+ <div class="docs-share__ext"></div>
171
+ </section>
172
+ <div class="docs-share__actions">
173
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="done">Done</button>
174
+ </div>`;
175
+ // "Suggester" while an add-on offers suggestions - or while someone already has it (a lapsed licence keeps the choice).
176
+ const offerSuggest = suggestions || (doc.shares || []).some(s => s.role === 'suggester');
177
+ panel.querySelector('.docs-share__people').innerHTML = PERSON_ROW.replace('<!--suggester-->', offerSuggest ? '<option value="suggester">Suggester</option>' : '');
178
+ panel.querySelector('.docs-share__matches').innerHTML = PICK_ROW;
179
+
180
+ const disposeHelp = kit.helpPopovers(panel);
181
+ so.element.appendChild(panel);
182
+
183
+ const setRole = (userId, role) => { shares.value = shares.peek().map(s => (s.userId === userId ? {...s, role} : s)); };
184
+ const remove = (userId) => { shares.value = shares.peek().filter(s => s.userId !== userId); };
185
+ const add = (id) => {
186
+ const p = people.find(x => x.id === id);
187
+ if (!p) return;
188
+ shares.value = [...shares.peek(), {userId: p.id, name: p.name, role: 'viewer'}];
189
+ query.value = '';
190
+ panel.querySelector('#docs-share-find')?.focus();
191
+ };
192
+ panel.addEventListener('change', (e) => {
193
+ const sel = e.target.closest('[data-role-for]');
194
+ if (sel) setRole(sel.dataset.roleFor, sel.value);
195
+ });
196
+ panel.addEventListener('click', (e) => {
197
+ const rm = e.target.closest('[data-remove]');
198
+ if (rm) return remove(rm.dataset.remove);
199
+ const pick = e.target.closest('[data-pick]');
200
+ if (pick) add(pick.dataset.pick);
201
+ });
202
+ panel.querySelector('#docs-share-find').addEventListener('keydown', (e) => {
203
+ if (e.key === 'Enter' && matches.peek().length) { e.preventDefault(); add(matches.peek()[0].id); }
204
+ });
205
+
206
+ const apply = (next) => {
207
+ current.value = next;
208
+ link.value = linkUrl();
209
+ onChange?.(next);
210
+ };
211
+
212
+ const handle = M.applyBindings(data, panel, {
213
+ methods: {
214
+ done: () => so.close(),
215
+ async save() {
216
+ saving.value = true;
217
+ // The panel stays open: changes made while this saves stay on screen, still unsaved (2.10).
218
+ const sent = JSON.stringify(shares.peek());
219
+ const sentMessage = message.peek();
220
+ try {
221
+ const next = await docsApi.shares(doc.id, {
222
+ shares: shares.peek().map(s => ({userId: s.userId, role: s.role})),
223
+ notify: notify.peek(), message: sentMessage
224
+ });
225
+ const stored = (next.shares || []).map(s => ({userId: s.userId, name: s.name, role: s.role}));
226
+ saved.value = JSON.stringify(stored);
227
+ if (JSON.stringify(shares.peek()) === sent) shares.value = stored;
228
+ if (message.peek() === sentMessage) message.value = '';
229
+ apply(next);
230
+ E.toast(next.notified ? `Access saved - ${next.notified} ${next.notified === 1 ? 'person' : 'people'} emailed` : 'Access saved', {type: 'success'});
231
+ } catch (err) {
232
+ E.toast(err.message || 'Could not save access', {type: 'error'});
233
+ } finally {
234
+ saving.value = false;
235
+ }
236
+ },
237
+ async toggleLink(e) {
238
+ const on = e?.target?.checked;
239
+ linkBusy.value = true;
240
+ try {
241
+ const next = on ? await docsApi.linkOn(doc.id) : await docsApi.linkOff(doc.id);
242
+ apply(next);
243
+ if (on) E.toast('Public link on - anyone with it can read this document', {type: 'success'});
244
+ else E.toast('Public link off - the old address no longer works', {type: 'info'});
245
+ } catch (err) {
246
+ if (e?.target) e.target.checked = !on;
247
+ E.toast(err.message || 'Could not change the link', {type: 'error'});
248
+ } finally {
249
+ linkBusy.value = false;
250
+ }
251
+ },
252
+ async copy() {
253
+ try {
254
+ await navigator.clipboard.writeText(link.peek());
255
+ E.toast('Link copied', {type: 'success'});
256
+ } catch {
257
+ panel.querySelector('.docs-share__link input')?.select();
258
+ E.toast('Press Ctrl+C to copy the selected link', {type: 'info'});
259
+ }
260
+ }
261
+ }
262
+ });
263
+
264
+ // Draw the role selects once the first rows are in.
265
+ requestAnimationFrame(() => { shares.value = shares.peek().slice(); });
266
+
267
+ // Add-on sections, each given a container of its own.
268
+ const extHost = panel.querySelector('.docs-share__ext');
269
+ const extDispose = [];
270
+ for (const section of sections) {
271
+ const box = document.createElement('div');
272
+ box.className = 'docs-share__ext-section';
273
+ extHost.appendChild(box);
274
+ try {
275
+ const d = await section({doc: current.peek(), container: box, refresh: apply, current});
276
+ if (typeof d === 'function') extDispose.push(d);
277
+ } catch (err) { console.warn('[docs] share section failed', err); }
278
+ }
279
+
280
+ // Each person's menu: right-click a row, its ⋮, or Shift+F10 on it. The role select keeps the browser's own menu.
281
+ const peopleList = panel.querySelector('.docs-share__people');
282
+ peopleList.addEventListener('contextmenu', (e) => { if (e.target.closest('input, select, textarea')) e.stopPropagation(); });
283
+ peopleList.addEventListener('click', (e) => { const k = e.target.closest('.docs-share__kebab'); if (k) kit.openMenuAt(k); });
284
+ const ROLES = [['viewer', 'Viewer', 'eye'], ...(offerSuggest ? [['suggester', 'Suggester', 'message-square']] : []), ['editor', 'Editor', 'edit-3']];
285
+ const personMenu = E.contextMenu(peopleList, {
286
+ cursor: 'glyph', match: '.docs-share__person', inherit: false, density: 'compact',
287
+ items: (el) => {
288
+ const s = shares.peek().find(x => x.userId === el.dataset.user);
289
+ if (!s) return [];
290
+ return [
291
+ {header: s.name || 'Unknown'},
292
+ ...ROLES.map(([role, label, ic]) => ({label: s.role === role ? `${label} - now` : label, icon: ic, disabled: s.role === role, action: () => setRole(s.userId, role)})),
293
+ {type: 'divider'},
294
+ {label: 'Remove', icon: 'x', danger: true, action: () => remove(s.userId)}
295
+ ];
296
+ }
297
+ });
298
+
299
+ // The screen went while add-on sections were drawn: nothing to show.
300
+ const teardown = () => {
301
+ handle.dispose();
302
+ personMenu?.destroy?.();
303
+ disposers.forEach(d => d?.());
304
+ extDispose.forEach(d => { try { d(); } catch { /* gone */ } });
305
+ disposeHelp?.();
306
+ };
307
+ if (life && !life.alive()) {
308
+ teardown();
309
+ try { so.destroy?.(); } catch { /* gone */ }
310
+ so.element?.remove();
311
+ return null;
312
+ }
313
+
314
+ if (typeof Domma !== 'undefined') Domma.icons?.scan(panel);
315
+ let untie = () => {};
316
+ kit.onceClosed(so, () => { teardown(); untie(); release(); });
317
+ // People added or roles changed but not saved: ask before they are thrown away.
318
+ guardClose(so, () => data.dirty.peek(), 'Discard the access changes you have not saved?');
319
+ untie = tieToScreen(so, life);
320
+ so.open();
321
+ setTimeout(() => panel.querySelector('#docs-share-find')?.focus(), 250);
322
+ return so;
323
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Markdown shortcuts while typing: what a line typed so far turns into when
3
+ * the space (or Enter) that follows it is pressed. Pure, so it is tested.
4
+ *
5
+ * # ## ### #### headings - * + bulleted list
6
+ * 1. 1) numbered list [] [ ] checklist
7
+ * > quote ``` code block
8
+ * --- *** (Enter) horizontal line
9
+ *
10
+ * @module docs/admin/lib/shortcuts
11
+ */
12
+
13
+ const ON_SPACE = [
14
+ [/^#$/, {type: 'block', tag: 'h1'}],
15
+ [/^##$/, {type: 'block', tag: 'h2'}],
16
+ [/^###$/, {type: 'block', tag: 'h3'}],
17
+ [/^####$/, {type: 'block', tag: 'h4'}],
18
+ [/^[-*+]$/, {type: 'ul'}],
19
+ [/^1[.)]$/, {type: 'ol'}],
20
+ [/^\[ ?\]$/, {type: 'checklist'}],
21
+ [/^>$/, {type: 'block', tag: 'blockquote'}],
22
+ [/^```$/, {type: 'block', tag: 'pre'}]
23
+ ];
24
+
25
+ const ON_ENTER = [
26
+ [/^(-{3,}|\*{3,}|_{3,})$/, {type: 'hr'}],
27
+ [/^```$/, {type: 'block', tag: 'pre'}]
28
+ ];
29
+
30
+ /**
31
+ * @param {string} before the text from the start of the block to the caret
32
+ * @param {string} key ' ' or 'Enter'
33
+ * @returns {{length:number, action:object}|null} how many characters to remove, and what to do
34
+ */
35
+ export function markdownTrigger(before, key) {
36
+ const text = String(before ?? '').replace(/ /g, ' ');
37
+ if (!text || text.length > 5) return null;
38
+ const table = key === 'Enter' ? ON_ENTER : key === ' ' ? ON_SPACE : null;
39
+ if (!table) return null;
40
+ for (const [re, action] of table) {
41
+ if (re.test(text)) return {length: text.length, action};
42
+ }
43
+ return null;
44
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Document templates.
3
+ *
4
+ * A template is a saved body with a name. The picker hands its content back
5
+ * to the caller rather than creating anything itself - the screen already
6
+ * knows which folder a new document belongs in, and this does not.
7
+ *
8
+ * Same light-DOM rule as the version panel: the list is appended to the modal
9
+ * element, because a sanitised `content` string would arrive with its buttons
10
+ * removed.
11
+ *
12
+ * @module docs/admin/lib/templates
13
+ */
14
+
15
+ import {api, esc} from './api.js';
16
+
17
+ export class DocumentTemplates {
18
+ constructor() {
19
+ this.templates = [];
20
+ }
21
+
22
+ /**
23
+ * @returns {Promise<object[]>}
24
+ */
25
+ async load() {
26
+ try {
27
+ const templates = await api('/templates');
28
+ this.templates = Array.isArray(templates) ? templates : [];
29
+ } catch (err) {
30
+ E.toast(err.message || 'Failed to load templates', {type: 'error'});
31
+ }
32
+ return this.templates;
33
+ }
34
+
35
+ /**
36
+ * Open the picker.
37
+ *
38
+ * @param {Function} onPick called with the chosen template's content
39
+ * @returns {Promise<object|null>} the Domma modal, or null when there is nothing to pick
40
+ */
41
+ async showPicker(onPick) {
42
+ await this.load();
43
+
44
+ if (!this.templates.length) {
45
+ E.toast('No templates saved yet. Open a document and use Save as template.', {type: 'info'});
46
+ return null;
47
+ }
48
+
49
+ const modal = E.modal({
50
+ title: 'New from template',
51
+ size: 'medium',
52
+ scrollable: true,
53
+ buttons: [{id: 'close', text: 'Cancel', variant: 'secondary'}]
54
+ });
55
+
56
+ modal.element.appendChild(this._buildList(modal, onPick));
57
+ modal.open();
58
+ return modal;
59
+ }
60
+
61
+ /**
62
+ * Save some content as a named template.
63
+ *
64
+ * @param {string} content
65
+ * @returns {Promise<object|null>} the new template, or null if cancelled
66
+ */
67
+ async saveAsTemplate(content) {
68
+ const name = await E.prompt('Template name', {
69
+ title: 'Save as template',
70
+ inputPlaceholder: 'e.g. Weekly report'
71
+ });
72
+ if (!name || !name.trim()) return null;
73
+
74
+ try {
75
+ const template = await api('/templates', 'POST', {name: name.trim(), content});
76
+ this.templates.push(template);
77
+ E.toast('Template saved', {type: 'success'});
78
+ return template;
79
+ } catch (err) {
80
+ E.toast(err.message || 'Failed to save template', {type: 'error'});
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // -----------------------------------------------------------------------
86
+ // Internals
87
+ // -----------------------------------------------------------------------
88
+
89
+ /**
90
+ * @param {object} modal
91
+ * @param {Function} onPick
92
+ * @returns {HTMLElement}
93
+ */
94
+ _buildList(modal, onPick) {
95
+ const wrap = document.createElement('div');
96
+ wrap.className = 'docs-templates';
97
+
98
+ this.templates.forEach(template => {
99
+ const row = document.createElement('div');
100
+ row.className = 'docs-template';
101
+
102
+ const name = document.createElement('strong');
103
+ name.className = 'docs-template__name';
104
+ name.textContent = template.name || 'Untitled template';
105
+ row.appendChild(name);
106
+
107
+ const preview = document.createElement('p');
108
+ preview.className = 'docs-template__preview';
109
+ preview.textContent = this._excerpt(template.content);
110
+ row.appendChild(preview);
111
+
112
+ const actions = document.createElement('div');
113
+ actions.className = 'docs-template__actions';
114
+
115
+ const use = document.createElement('button');
116
+ use.type = 'button';
117
+ use.className = 'btn btn-sm btn-primary';
118
+ use.textContent = 'Use';
119
+ use.addEventListener('click', () => {
120
+ modal.close();
121
+ onPick(template.content || '');
122
+ });
123
+ actions.appendChild(use);
124
+
125
+ // Ready-made templates (from an add-on, 2.6) are read only.
126
+ if (template.builtIn) {
127
+ const tag = document.createElement('small');
128
+ tag.className = 'docs-template__source';
129
+ tag.textContent = ` · ${[template.category, template.source].filter(Boolean).join(' · ')}`;
130
+ name.appendChild(tag);
131
+ row.appendChild(actions);
132
+ wrap.appendChild(row);
133
+ return;
134
+ }
135
+ const remove = document.createElement('button');
136
+ remove.type = 'button';
137
+ remove.className = 'btn btn-sm btn-ghost';
138
+ remove.title = 'Delete template';
139
+ remove.setAttribute('aria-label', `Delete template ${template.name || ''}`);
140
+ const icon = document.createElement('span');
141
+ icon.dataset.icon = 'trash';
142
+ icon.dataset.iconSize = '14';
143
+ remove.appendChild(icon);
144
+ remove.addEventListener('click', () => this._delete(template, row, modal));
145
+ actions.appendChild(remove);
146
+
147
+ row.appendChild(actions);
148
+ wrap.appendChild(row);
149
+ });
150
+
151
+ if (typeof Domma !== 'undefined' && Domma.icons) {
152
+ // The rows are not in the document yet, so scan the fragment.
153
+ queueMicrotask(() => Domma.icons.scan(wrap));
154
+ }
155
+
156
+ return wrap;
157
+ }
158
+
159
+ /**
160
+ * @param {object} template
161
+ * @param {HTMLElement} row
162
+ * @param {object} modal
163
+ * @returns {Promise<void>}
164
+ */
165
+ async _delete(template, row, modal) {
166
+ const confirmed = await E.confirm(`Delete the template "${esc(template.name)}"?`, {
167
+ title: 'Delete template',
168
+ confirmText: 'Delete'
169
+ });
170
+ if (!confirmed) return;
171
+
172
+ try {
173
+ await api(`/templates/${encodeURIComponent(template.id)}`, 'DELETE');
174
+ this.templates = this.templates.filter(t => t.id !== template.id);
175
+ row.remove();
176
+ // An empty picker has nothing left to pick, so it closes rather
177
+ // than sitting there as a blank panel.
178
+ if (!this.templates.length) modal.close();
179
+ E.toast('Template deleted', {type: 'success'});
180
+ } catch (err) {
181
+ E.toast(err.message || 'Failed to delete template', {type: 'error'});
182
+ }
183
+ }
184
+
185
+ /**
186
+ * @param {string} content
187
+ * @returns {string}
188
+ */
189
+ _excerpt(content) {
190
+ const text = String(content || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
191
+ if (!text) return 'Empty template';
192
+ return text.length > 140 ? `${text.slice(0, 140)}...` : text;
193
+ }
194
+ }