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,126 @@
1
+ /**
2
+ * A PDF document on its screen (2.13): the file in the browser's own viewer,
3
+ * what the document knows about it, and the actions a PDF has instead of a
4
+ * body - replace the file, download it.
5
+ *
6
+ * The frame's address is the document's view route with a short-lived ticket
7
+ * (GET /documents/:id answers `fileTicket`, fresh with every poll): a frame
8
+ * carries no bearer header, and the admin's CSP frames 'self' only - a blob:
9
+ * address is refused, which is how the first version of this failed. The frame
10
+ * carries no `sandbox` attribute on purpose: Chrome's PDF viewer will not open
11
+ * in a sandboxed frame (chrome-error instead). The response itself is sent
12
+ * with `Content-Security-Policy: sandbox`, which the viewer accepts, and a PDF
13
+ * with scripts in it was refused at upload.
14
+ *
15
+ * @module docs/admin/lib/pdf-view
16
+ */
17
+
18
+ import {docsApi, esc, icon, fetchBlob, downloadBlob, readBase64, isPdfFile} from '/plugins/docs/admin/lib/api.js';
19
+ import {fileName} from '/plugins/docs/admin/lib/convert.js';
20
+
21
+ /** "3 pages · 1.2 MB". */
22
+ export function pdfFacts(file) {
23
+ const pages = Number(file?.pages) || 0;
24
+ const size = Number(file?.size) || 0;
25
+ const parts = [pages ? `${pages} page${pages === 1 ? '' : 's'}` : '', size ? sizeText(size) : ''];
26
+ return parts.filter(Boolean).join(' · ');
27
+ }
28
+
29
+ export function sizeText(bytes) {
30
+ const n = Number(bytes) || 0;
31
+ if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(n >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
32
+ if (n >= 1024) return `${Math.round(n / 1024)} KB`;
33
+ return `${n} B`;
34
+ }
35
+
36
+ /**
37
+ * Show a PDF document's file in `host`. Returns a handle: `reload()` after
38
+ * the file changed, `dispose()` when the screen goes.
39
+ *
40
+ * @param {HTMLElement} host the page element (.docs-page)
41
+ * @param {{id: string, title: () => string}} doc
42
+ */
43
+ export function mountPdfViewer(host, doc) {
44
+ host.classList.add('docs-pdf');
45
+ host.innerHTML = `
46
+ <div class="docs-pdf__state" aria-live="polite">${icon('file-text', 28)}<p>Loading the PDF…</p></div>
47
+ <iframe class="docs-pdf__frame" title="" hidden></iframe>`;
48
+ const state = host.querySelector('.docs-pdf__state');
49
+ const frame = host.querySelector('.docs-pdf__frame');
50
+ let gone = false;
51
+
52
+ function reload() {
53
+ if (gone) return;
54
+ const ticket = doc.ticket();
55
+ if (!ticket) {
56
+ state.hidden = false;
57
+ frame.hidden = true;
58
+ state.innerHTML = `${icon('alert-circle', 28)}<p>The PDF could not be shown.</p><p class="docs-pdf__hint">You can still download it from the menu.</p>`;
59
+ return;
60
+ }
61
+ frame.title = doc.title();
62
+ // A fresh address each time, so the browser never shows the file it had.
63
+ frame.src = `${docsApi.viewUrl(doc.id, ticket)}&v=${Date.now().toString(36)}`;
64
+ frame.hidden = false;
65
+ state.hidden = true;
66
+ }
67
+ reload();
68
+ return {
69
+ reload,
70
+ dispose() { gone = true; frame.src = 'about:blank'; host.classList.remove('docs-pdf'); host.replaceChildren(); }
71
+ };
72
+ }
73
+
74
+ /** Save the document's PDF with its title as the file name. */
75
+ export async function downloadPdf(doc) {
76
+ try {
77
+ const blob = await fetchBlob(docsApi.fileUrl(doc.id, {download: true}));
78
+ downloadBlob(fileName(doc.title || 'Untitled', 'pdf'), blob);
79
+ } catch (err) {
80
+ E.toast(err.message || 'Could not download the PDF', {type: 'error'});
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Ask for a PDF and upload it as the document's new file (the old one becomes a
86
+ * version). Resolves with the updated document, or null when nothing was chosen.
87
+ */
88
+ export function pickPdf() {
89
+ return new Promise((resolve) => {
90
+ const input = document.createElement('input');
91
+ input.type = 'file';
92
+ input.accept = '.pdf,application/pdf';
93
+ input.hidden = true;
94
+ document.body.appendChild(input);
95
+ const done = (file) => { input.remove(); resolve(file || null); };
96
+ input.addEventListener('change', () => done(input.files?.[0]));
97
+ // A cancelled picker fires no change; the input is dropped when focus comes back without one.
98
+ window.addEventListener('focus', () => setTimeout(() => { if (input.isConnected && !input.files?.length) done(null); }, 400), {once: true});
99
+ input.click();
100
+ });
101
+ }
102
+
103
+ /** The downloads a PDF document offers (where an HTML one offers Markdown, HTML, text and print). */
104
+ export function pdfExportItems(get) {
105
+ return [{label: 'PDF (.pdf)', icon: 'download', action: async () => { try { await downloadPdf(await get()); } catch (err) { E.toast(err.message || 'Could not download', {type: 'error'}); } }}];
106
+ }
107
+
108
+ export async function replacePdf(doc, {session, host = null, maxBytes = 20 * 1024 * 1024} = {}) {
109
+ const file = await pickPdf();
110
+ if (!file) return null;
111
+ if (!isPdfFile(file)) { E.toast('Choose a PDF file.', {type: 'warning'}); return null; }
112
+ if (file.size > maxBytes) { E.toast(`${esc(file.name)} is over ${Math.round(maxBytes / 1024 / 1024)} MB.`, {type: 'warning'}); return null; }
113
+ const ok = await E.confirm(`Replace the file with <strong>${esc(file.name)}</strong>? The file it has now is kept in Version history.`, {title: 'Replace the PDF', confirmText: 'Replace'});
114
+ if (!ok) return null;
115
+ const loader = host && E.loader ? E.loader(host, {type: 'dots'}) : null;
116
+ try {
117
+ const updated = await docsApi.replaceFile(doc.id, {name: file.name, data: await readBase64(file)}, session);
118
+ E.toast('PDF replaced', {type: 'success'});
119
+ return updated;
120
+ } catch (err) {
121
+ E.toast(err.message || 'Could not replace the PDF', {type: 'error'});
122
+ return null;
123
+ } finally {
124
+ try { loader?.destroy?.(); } catch { /* gone */ }
125
+ }
126
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Send to: email, a new Page, a new Blog post; and the downloads (Markdown,
3
+ * HTML, text, print / PDF).
4
+ *
5
+ * Pages and Blog posts are made through the CMS's own APIs with the user's
6
+ * own token, so the CMS decides whether they may - Docs grants nothing.
7
+ * Both are made as drafts, in Markdown, and open in their own editors.
8
+ *
9
+ * @module docs/admin/lib/send
10
+ */
11
+
12
+ import {docsApi, esc, download, printHtml, outgoing} from '/plugins/docs/admin/lib/api.js';
13
+ import {guardClose, claimPanel, tieToScreen} from '/plugins/docs/admin/lib/panel-guard.js';
14
+ import {htmlToMarkdown, htmlToText, standaloneHtml, fileName, startsWithTitle} from '/plugins/docs/admin/lib/convert.js';
15
+
16
+ /** Slug from a title: "Q3 plan - draft" -> "q3-plan-draft". */
17
+ export function slugify(title) {
18
+ return String(title || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '')
19
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'document';
20
+ }
21
+
22
+ /** The four downloads, as menu items. `get` answers {title, content} (fetched for a list row). */
23
+ export function exportItems(get) {
24
+ const run = (fn) => async () => {
25
+ try { const d = await get(); fn({...d, content: outgoing(d.content)}); } catch (err) { E.toast(err.message || 'Could not export', {type: 'error'}); }
26
+ };
27
+ return [
28
+ {label: 'Markdown (.md)', icon: 'file-text', action: run(d => download(fileName(d.title, 'md'), startsWithTitle(d.title, d.content) ? htmlToMarkdown(d.content) : `# ${d.title}\n\n${htmlToMarkdown(d.content)}`, 'text/markdown;charset=utf-8'))},
29
+ {label: 'Web page (.html)', icon: 'globe', action: run(d => download(fileName(d.title, 'html'), standaloneHtml(d.title, d.content), 'text/html;charset=utf-8'))},
30
+ {label: 'Plain text (.txt)', icon: 'type', action: run(d => download(fileName(d.title, 'txt'), `${d.title}\n\n${htmlToText(d.content)}\n`, 'text/plain;charset=utf-8'))},
31
+ {type: 'divider'},
32
+ {label: 'Print or save as PDF…', icon: 'printer', action: run(d => printHtml(standaloneHtml(d.title, d.content)))}
33
+ ];
34
+ }
35
+
36
+ /**
37
+ * Email the document: its body, or a link. A slideover; the owner and
38
+ * editors only (the server says so too). One at a time; it closes with the
39
+ * screen that opened it (`life`, 2.10); locked while it sends, because it
40
+ * closes when the send lands.
41
+ */
42
+ export function openSendEmail({doc, kit, publicLinksAllowed = true, life = null}) {
43
+ // A PDF document (2.13) goes as an attachment, and the choice says so.
44
+ if (life && !life.alive()) return null;
45
+ const release = claimPanel('email');
46
+ if (!release) return null;
47
+ try {
48
+ return buildSendEmail({doc, kit, publicLinksAllowed, life}, release);
49
+ } catch (err) {
50
+ release();
51
+ throw err;
52
+ }
53
+ }
54
+
55
+ function buildSendEmail({doc, kit, publicLinksAllowed, life}, release) {
56
+ const so = E.slideover({title: 'Email this document', size: 'sm', position: 'right'});
57
+ const panel = document.createElement('div');
58
+ panel.className = 'docs-form';
59
+ const to = M.observable('');
60
+ const message = M.observable('');
61
+ const mode = M.observable('inline');
62
+ const sending = M.observable(false);
63
+ panel.innerHTML = `
64
+ <label class="form-label" for="docs-send-to">To
65
+ <span data-help="Up to 10 email addresses, separated by commas. They do not need an account."></span>
66
+ </label>
67
+ <input id="docs-send-to" type="text" class="form-input" placeholder="name@example.com, other@example.com" data-model="to.value">
68
+ <label class="form-label" for="docs-send-msg">Message</label>
69
+ <textarea id="docs-send-msg" class="form-input" rows="3" placeholder="Optional" data-model="message.value"></textarea>
70
+ <fieldset class="docs-form__choice">
71
+ <legend class="form-label">Send
72
+ <span data-help-title="Inline or link" data-help="In the email puts the whole document in the message - a copy of it as it is now (a PDF is attached as a file). As a link turns on the document's public link (if it is not on already) and sends that, so they always see the latest version. Turn the link off under Share to stop it working."></span>
73
+ </legend>
74
+ <label class="docs-check"><input type="radio" name="docs-send-mode" value="inline" data-model="mode.value"> ${doc.format === 'pdf' ? 'The PDF, attached to the email' : 'The document, in the email'}</label>
75
+ <label class="docs-check" data-bind-hidden="!linksAllowed"><input type="radio" name="docs-send-mode" value="link" data-model="mode.value"> A link to read it online</label>
76
+ </fieldset>
77
+ <div class="docs-form__actions">
78
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
79
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="sending.value || !to.value">Send</button>
80
+ </div>`;
81
+ const disposeHelp = kit.helpPopovers(panel);
82
+ so.element.appendChild(panel);
83
+ const handle = M.applyBindings({to, message, mode, sending, linksAllowed: publicLinksAllowed}, panel, {
84
+ methods: {
85
+ cancel: () => so.close(),
86
+ async send() {
87
+ if (sending.peek()) return;
88
+ sending.value = true;
89
+ // The panel closes once this lands: nothing typed meanwhile may be lost, so it is locked.
90
+ panel.inert = true;
91
+ try {
92
+ const r = await docsApi.send(doc.id, {to: to.peek(), message: message.peek(), mode: mode.peek()});
93
+ E.toast(r.failed?.length ? `Sent to ${r.sent}; not sent to ${r.failed.join(', ')}` : `Sent to ${r.sent} ${r.sent === 1 ? 'person' : 'people'}`, {type: r.failed?.length ? 'warning' : 'success'});
94
+ guard.done();
95
+ so.close();
96
+ } catch (err) {
97
+ E.toast(err.message || 'Could not send', {type: 'error'});
98
+ } finally {
99
+ sending.value = false;
100
+ panel.inert = false;
101
+ }
102
+ }
103
+ }
104
+ });
105
+ let untie = () => {};
106
+ kit.onceClosed(so, () => { handle.dispose(); disposeHelp?.(); untie(); release(); });
107
+ // Typed addresses or a message are worth a question before they go; while it sends, a close waits for it.
108
+ const guard = guardClose(so, () => Boolean(to.peek().trim() || message.peek().trim()), 'Discard this email without sending it?', () => sending.peek());
109
+ untie = tieToScreen(so, life);
110
+ so.open();
111
+ setTimeout(() => panel.querySelector('#docs-send-to')?.focus(), 250);
112
+ return so;
113
+ }
114
+
115
+ /** A new draft Page from the document, at an address the user confirms. */
116
+ export async function sendToPage(doc) {
117
+ const suggested = `/${slugify(doc.title)}`;
118
+ const answer = await E.prompt('Address for the new page', {title: 'Send to a new page', inputValue: suggested});
119
+ if (answer === null || answer === undefined) return;
120
+ let urlPath = String(answer).trim().toLowerCase().replace(/\s+/g, '-');
121
+ if (!urlPath.startsWith('/')) urlPath = `/${urlPath}`;
122
+ if (!/^\/[a-z0-9/_-]*$/.test(urlPath) || urlPath.includes('//')) {
123
+ E.toast('Use lowercase letters, numbers, - and / only', {type: 'warning'});
124
+ return;
125
+ }
126
+ try {
127
+ await H.post('/api/pages', {
128
+ urlPath,
129
+ frontmatter: {title: doc.title || 'Untitled', status: 'draft', showInNav: false},
130
+ body: htmlToMarkdown(outgoing(doc.content))
131
+ }, {silent: true});
132
+ const ok = await E.confirm(`Draft page made at <strong>${esc(urlPath)}</strong>. Open it in the page editor?`, {title: 'Page created', confirmText: 'Open page', cancelText: 'Stay here'});
133
+ if (ok) location.hash = `#/pages/edit${urlPath}`;
134
+ } catch (err) {
135
+ const msg = /403|permission/i.test(err.message || '') ? 'Your role cannot create pages.' : (err.message || 'Could not create the page');
136
+ E.toast(msg, {type: 'error'});
137
+ }
138
+ }
139
+
140
+ /** A new draft Blog post from the document. */
141
+ export async function sendToBlog(doc) {
142
+ try {
143
+ const post = await H.post('/api/plugins/blog/posts', {
144
+ title: doc.title || 'Untitled', slug: slugify(doc.title), status: 'draft',
145
+ content: htmlToMarkdown(outgoing(doc.content)), excerpt: htmlToText(outgoing(doc.content)).replace(/\s+/g, ' ').slice(0, 200)
146
+ }, {silent: true});
147
+ const id = post?.id || post?.data?.id;
148
+ const ok = await E.confirm('Draft blog post made. Open it in the Blog?', {title: 'Post created', confirmText: 'Open post', cancelText: 'Stay here'});
149
+ if (ok) location.hash = id ? `#/plugins/blog/posts/${encodeURIComponent(id)}` : '#/plugins/blog/posts';
150
+ } catch (err) {
151
+ const m = err.message || '';
152
+ const msg = /404|not found/i.test(m) ? 'The Blog is not switched on for this site.'
153
+ : /403|permission/i.test(m) ? 'Your role cannot write blog posts.' : (m || 'Could not create the post');
154
+ E.toast(msg, {type: 'error'});
155
+ }
156
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The Docs settings slideover, behind the banner cog: site settings (admins
3
+ * change them, everyone sees them) and this browser's own preferences.
4
+ *
5
+ * @module docs/admin/lib/settings
6
+ */
7
+
8
+ import {docsApi, esc} from '/plugins/docs/admin/lib/api.js';
9
+ import {guardClose, claimPanel, tieToScreen} from '/plugins/docs/admin/lib/panel-guard.js';
10
+
11
+ /** Autosave choices, in milliseconds. */
12
+ const AUTOSAVE = [[0, 'Off'], [10000, '10 seconds after I stop typing'], [30000, '30 seconds after I stop typing'],
13
+ [60000, 'A minute after I stop typing'], [300000, 'Five minutes after I stop typing']];
14
+
15
+ /**
16
+ * @param {object} opts
17
+ * @param {object} opts.kit
18
+ * @param {object} opts.site GET /settings (mutated on save)
19
+ * @param {object} opts.prefs {showWordCount: observable} - this browser's
20
+ * @param {Function} [opts.onSaved]
21
+ * @param {object} [opts.life] the opening screen's {alive(), add(fn)}: the panel closes with it (2.10)
22
+ */
23
+ export function openSettings(opts) {
24
+ // One settings panel at a time.
25
+ const release = claimPanel('settings');
26
+ if (!release) return null;
27
+ try {
28
+ return buildSettings(opts, release);
29
+ } catch (err) {
30
+ release();
31
+ throw err;
32
+ }
33
+ }
34
+
35
+ function buildSettings({kit, site, prefs = {}, onSaved, life = null}, release) {
36
+ const so = E.slideover({title: 'Docs settings', size: 'sm', position: 'right'});
37
+ const panel = document.createElement('div');
38
+ panel.className = 'docs-settings';
39
+ const autoSave = M.observable(String(site.autoSaveInterval ?? 30000));
40
+ const maxVersions = M.observable(String(site.maxVersions ?? 50));
41
+ const trashDays = M.observable(String(site.trashDays ?? 30));
42
+ const publicLinks = M.observable(site.publicLinks !== false);
43
+ const allowSend = M.observable(site.allowSend !== false);
44
+ const saving = M.observable(false);
45
+ const snapshot = () => JSON.stringify([autoSave.value, maxVersions.value, trashDays.value, publicLinks.value, allowSend.value]);
46
+ const initial = M.observable(snapshot());
47
+
48
+ panel.innerHTML = `
49
+ <section class="docs-settings__group">
50
+ <h3 class="docs-settings__heading">For everyone on this site
51
+ <span data-help="Site settings apply to everyone who uses Docs. Only an administrator can change them."></span>
52
+ </h3>
53
+ <p class="docs-settings__note" data-bind-hidden="canEdit">Only an administrator can change these.</p>
54
+ <label class="form-label" for="docs-set-autosave">Autosave
55
+ <span data-help="How long after the last change an open document saves itself. Off means only Save or Ctrl+S saves - and unsaved work is still kept in this browser if you leave."></span>
56
+ </label>
57
+ <select id="docs-set-autosave" class="form-input" data-model="autoSave.value" data-bind-disabled="!canEdit">
58
+ ${AUTOSAVE.map(([ms, label]) => `<option value="${ms}">${esc(label)}</option>`).join('')}
59
+ </select>
60
+ <label class="form-label" for="docs-set-versions">Versions kept per document
61
+ <span data-help="Every save that changes a document keeps what it replaced, so it can be restored from Version history. Past this many, the oldest go. 5 to 500."></span>
62
+ </label>
63
+ <input id="docs-set-versions" type="number" min="5" max="500" step="5" class="form-input" data-model="maxVersions.value" data-bind-disabled="!canEdit">
64
+ <label class="form-label" for="docs-set-trash">Days in Trash
65
+ <span data-help="A document moved to Trash can be restored for this many days, then it is deleted for good with its history. 1 to 365."></span>
66
+ </label>
67
+ <input id="docs-set-trash" type="number" min="1" max="365" class="form-input" data-model="trashDays.value" data-bind-disabled="!canEdit">
68
+ <label class="docs-check">
69
+ <input type="checkbox" data-model="publicLinks.value" data-bind-disabled="!canEdit"> Allow public links
70
+ <span data-help-title="Public links" data-help="Lets people turn on &quot;Anyone with the link can view&quot; for their documents - a read-only page on this site at an unguessable address. Switching this off stops every existing link at once; switching it back on brings them back."></span>
71
+ </label>
72
+ <label class="docs-check">
73
+ <input type="checkbox" data-model="allowSend.value" data-bind-disabled="!canEdit"> Allow emailing documents
74
+ <span data-help-title="Emailing documents" data-help="Lets owners and editors use Send to &gt; Email, from this site's email address. Each person can email up to 50 addresses a day."></span>
75
+ </label>
76
+ <div class="docs-settings__row">
77
+ <span class="form-label">Who sees documents
78
+ <span data-help="Private: people see their own documents and those shared with them. Everyone: every Docs user can read every document, and administrators can change them. Set in the plugin's configuration, not here, because changing it exposes or hides other people's work."></span>
79
+ </span>
80
+ <strong data-bind-text="scopeText"></strong>
81
+ </div>
82
+ <div class="docs-settings__actions" data-bind-hidden="!canEdit">
83
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="!dirty.value || saving.value">Save site settings</button>
84
+ </div>
85
+ </section>
86
+ <section class="docs-settings__group">
87
+ <h3 class="docs-settings__heading">Just for you
88
+ <span data-help="Kept in this browser as you change them."></span>
89
+ </h3>
90
+ <label class="docs-check"><input type="checkbox" data-model="showWordCount.value"> Show word counts on documents</label>
91
+ </section>
92
+ <div class="docs-settings__actions">
93
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="done">Done</button>
94
+ </div>`;
95
+
96
+ const disposeHelp = kit.helpPopovers(panel);
97
+ so.element.appendChild(panel);
98
+ const handle = M.applyBindings({
99
+ canEdit: site.canEdit === true,
100
+ scopeText: site.scope === 'all' ? 'Everyone with Docs' : 'Private, unless shared',
101
+ autoSave, maxVersions, trashDays, publicLinks, allowSend, saving,
102
+ showWordCount: prefs.showWordCount || M.observable(true),
103
+ dirty: M.computed(() => snapshot() !== initial.value)
104
+ }, panel, {
105
+ methods: {
106
+ done: () => so.close(),
107
+ async save() {
108
+ if (saving.peek()) return;
109
+ saving.value = true;
110
+ // The panel stays open: a field changed while this saves keeps its new value, still unsaved (2.10).
111
+ const sent = {autoSave: autoSave.peek(), maxVersions: maxVersions.peek(), trashDays: trashDays.peek()};
112
+ try {
113
+ const body = {autoSaveInterval: Number(autoSave.peek()), maxVersions: Number(maxVersions.peek()),
114
+ trashDays: Number(trashDays.peek()), publicLinks: Boolean(publicLinks.peek()), allowSend: Boolean(allowSend.peek())};
115
+ const r = await docsApi.settings.save(body);
116
+ const next = {...body, ...r};
117
+ delete next.ok;
118
+ Object.assign(site, next);
119
+ // What the server stored (it clamps numbers), shown only where nothing was typed since.
120
+ const stored = {autoSave: String(next.autoSaveInterval), maxVersions: String(next.maxVersions), trashDays: String(next.trashDays)};
121
+ if (maxVersions.peek() === sent.maxVersions) maxVersions.value = stored.maxVersions;
122
+ if (trashDays.peek() === sent.trashDays) trashDays.value = stored.trashDays;
123
+ if (autoSave.peek() === sent.autoSave) autoSave.value = stored.autoSave;
124
+ initial.value = JSON.stringify([stored.autoSave, stored.maxVersions, stored.trashDays, Boolean(next.publicLinks), Boolean(next.allowSend)]);
125
+ onSaved?.(next);
126
+ E.toast('Site settings saved', {type: 'success'});
127
+ } catch (err) {
128
+ E.toast(err.message || 'Could not save settings', {type: 'error'});
129
+ } finally {
130
+ saving.value = false;
131
+ }
132
+ }
133
+ }
134
+ });
135
+ let untie = () => {};
136
+ kit.onceClosed(so, () => { handle.dispose(); disposeHelp?.(); untie(); release(); });
137
+ // Site settings changed and not saved: ask before they are thrown away.
138
+ guardClose(so, () => snapshot() !== initial.peek(), 'Discard the settings you have not saved?');
139
+ untie = tieToScreen(so, life);
140
+ so.open();
141
+ return so;
142
+ }