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.
- package/CHANGELOG.md +9 -0
- package/docs/DommaFeatures.md +13 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -1
- package/plugins/_lib/admin/games/kit.js +329 -124
- package/plugins/_lib/games/http.js +11 -1
- package/plugins/_lib/games/plays.js +28 -7
- package/plugins/crossword/CLAUDE.md +37 -0
- package/plugins/crossword/admin/css/index.css +18 -1
- package/plugins/crossword/admin/views/crossword.js +337 -205
- package/plugins/crossword/docs/guide.md +53 -0
- package/plugins/crossword/inject/body.html +1 -1
- package/plugins/crossword/plugin.js +38 -5
- package/plugins/crossword/plugin.json +4 -3
- package/plugins/crossword/public/crossword.css +36 -1
- package/plugins/crossword/public/crossword.js +468 -6
- 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 +147 -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/quiz/CLAUDE.md +43 -0
- package/plugins/quiz/admin/css/index.css +13 -1
- package/plugins/quiz/admin/views/quiz.js +423 -245
- package/plugins/quiz/docs/guide.md +61 -0
- package/plugins/quiz/inject/body.html +1 -1
- package/plugins/quiz/plugin.js +39 -5
- package/plugins/quiz/plugin.json +4 -3
- package/plugins/quiz/public/quiz.css +36 -1
- package/plugins/quiz/public/quiz.js +298 -6
- 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
|
@@ -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
|
+
}
|