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,777 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docs - Home.
|
|
3
|
+
*
|
|
4
|
+
* A rail of places (Recent, Starred, Shared with me, My documents and its
|
|
5
|
+
* folders, Templates, Trash, and any an add-on adds) and the documents in the
|
|
6
|
+
* one chosen, as cards or a list. Opening a document goes to its own screen
|
|
7
|
+
* (admin/views/editor.js, #/plugins/docs/d/<id>).
|
|
8
|
+
*
|
|
9
|
+
* On the Tool upgrade path: state is domma-reactive observables bound with
|
|
10
|
+
* M.applyBindings; every per-document and per-folder action is on a context
|
|
11
|
+
* menu (right-click, Shift+F10 or the ⋮); settings are behind the banner cog;
|
|
12
|
+
* help popovers explain the parts that need it.
|
|
13
|
+
*
|
|
14
|
+
* @module docs/admin/views/docs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {docsApi, api, HOME, docHref, esc, icon, loadKit, loadExtensions, allowDocAttrs, setContentFilter, promptWith, readBase64, isPdfFile} from '/plugins/docs/admin/lib/api.js';
|
|
18
|
+
import {pdfFacts, pdfExportItems} from '/plugins/docs/admin/lib/pdf-view.js';
|
|
19
|
+
import {FolderManager} from '/plugins/docs/admin/lib/folders.js';
|
|
20
|
+
import {exportItems, openSendEmail} from '/plugins/docs/admin/lib/send.js';
|
|
21
|
+
import {openShare} from '/plugins/docs/admin/lib/share.js';
|
|
22
|
+
import {openSettings} from '/plugins/docs/admin/lib/settings.js';
|
|
23
|
+
import {fmtDateTime} from '/plugins/_lib/admin/ui/dates.js';
|
|
24
|
+
|
|
25
|
+
const STAR = () => icon('star', 16);
|
|
26
|
+
const KEBAB = () => icon('more-vertical', 16);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Item templates for the lists - the bodies of their `data-each`. Here, not
|
|
30
|
+
* in docs.html, because the router renders a templateUrl through `_.render`
|
|
31
|
+
* first, which replaces every `{{…}}` with nothing. Mustache escapes values.
|
|
32
|
+
* Whole class lists are values: the compiler drops whitespace beside `{{…}}`.
|
|
33
|
+
*/
|
|
34
|
+
const cardTemplate = () => `
|
|
35
|
+
<div class="{{cls}}" data-doc="{{id}}" tabindex="0" role="button" aria-label="{{label}}">
|
|
36
|
+
<div class="docs-card__sheet" aria-hidden="true"><div class="docs-card__excerpt">{{excerpt}}</div></div>
|
|
37
|
+
<div class="docs-card__info">
|
|
38
|
+
<div class="docs-card__title">{{title}}</div>
|
|
39
|
+
<div class="docs-card__meta" title="{{when}}">{{meta}}</div>
|
|
40
|
+
<div class="docs-card__chips">
|
|
41
|
+
<span class="docs-chip docs-chip--pdf">PDF</span>
|
|
42
|
+
<span class="docs-chip docs-chip--shared">Shared</span>
|
|
43
|
+
<span class="docs-chip docs-chip--public">Public link</span>
|
|
44
|
+
<span class="docs-chip docs-chip--locked">{{lockText}}</span>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<button type="button" class="docs-card__star" data-star="{{id}}" aria-label="{{starLabel}}" title="{{starLabel}}">${STAR()}</button>
|
|
48
|
+
<button type="button" class="docs-kebab" aria-label="Actions for {{title}}" title="Actions">${KEBAB()}</button>
|
|
49
|
+
</div>`;
|
|
50
|
+
|
|
51
|
+
const templateTemplate = () => `
|
|
52
|
+
<div class="docs-card docs-card--template" data-template="{{id}}" tabindex="0" role="button" aria-label="New document from {{title}}">
|
|
53
|
+
<div class="docs-card__sheet" aria-hidden="true"><div class="docs-card__excerpt">{{excerpt}}</div></div>
|
|
54
|
+
<div class="docs-card__info">
|
|
55
|
+
<div class="docs-card__title">{{title}}</div>
|
|
56
|
+
<div class="docs-card__meta">{{meta}}</div>
|
|
57
|
+
</div>
|
|
58
|
+
<button type="button" class="docs-kebab" aria-label="Actions for {{title}}" title="Actions">${KEBAB()}</button>
|
|
59
|
+
</div>`;
|
|
60
|
+
|
|
61
|
+
const folderTemplate = () => `
|
|
62
|
+
<div class="{{cls}}" data-folder="{{id}}" tabindex="0" role="button" style="padding-left: {{indent}}rem">
|
|
63
|
+
<span class="docs-folder__name">{{name}}</span>
|
|
64
|
+
<button type="button" class="docs-kebab" aria-label="Actions for folder {{name}}" title="Actions">${KEBAB()}</button>
|
|
65
|
+
</div>`;
|
|
66
|
+
|
|
67
|
+
const VIEWS = {
|
|
68
|
+
recent: {title: 'Recent', empty: 'Documents you open or edit show up here.'},
|
|
69
|
+
starred: {title: 'Starred', empty: 'Star a document to keep it here - the star on its card, or its menu.'},
|
|
70
|
+
shared: {title: 'Shared with me', empty: 'Nobody has shared a document with you yet.'},
|
|
71
|
+
mine: {title: 'My documents', empty: 'No documents here yet.'},
|
|
72
|
+
templates: {title: 'Templates', empty: 'No templates yet. Open a document and choose Save as template from its menu.'},
|
|
73
|
+
trash: {title: 'Trash', empty: 'Trash is empty.'}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const PREFS_KEY = 'docs.home';
|
|
77
|
+
const DEFAULT_PREFS = Object.freeze({view: 'recent', folderId: null, layout: 'grid', sort: 'updated', railCollapsed: false, showWordCount: true});
|
|
78
|
+
|
|
79
|
+
/** Whitelist stored preferences - never merged blind. */
|
|
80
|
+
export function normalisePrefs(raw) {
|
|
81
|
+
const r = raw && typeof raw === 'object' ? raw : {};
|
|
82
|
+
const d = DEFAULT_PREFS;
|
|
83
|
+
return {
|
|
84
|
+
view: typeof r.view === 'string' && (VIEWS[r.view] || /^ext:[a-z0-9-]+$/.test(r.view)) ? r.view : d.view,
|
|
85
|
+
folderId: typeof r.folderId === 'string' ? r.folderId : null,
|
|
86
|
+
layout: r.layout === 'list' ? 'list' : 'grid',
|
|
87
|
+
sort: ['updated', 'opened', 'title', 'created'].includes(r.sort) ? r.sort : d.sort,
|
|
88
|
+
railCollapsed: r.railCollapsed === true,
|
|
89
|
+
showWordCount: typeof r.showWordCount === 'boolean' ? r.showWordCount : d.showWordCount
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Read a picked or dropped file as text, with a size limit. */
|
|
94
|
+
function readFile(file) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
if (file.size > 4 * 1024 * 1024) { reject(new Error(`${file.name} is over 4 MB`)); return; }
|
|
97
|
+
const r = new FileReader();
|
|
98
|
+
r.onload = () => resolve(String(r.result || ''));
|
|
99
|
+
r.onerror = () => reject(new Error(`Could not read ${file.name}`));
|
|
100
|
+
r.readAsText(file);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let lifetime = null;
|
|
105
|
+
|
|
106
|
+
export const docsView = {
|
|
107
|
+
templateUrl: '/plugins/docs/admin/templates/docs.html',
|
|
108
|
+
|
|
109
|
+
async onMount($container) {
|
|
110
|
+
const kit = await loadKit();
|
|
111
|
+
lifetime ??= kit.viewLifetime(/^#\/plugins\/docs\/?(\?.*)?$/);
|
|
112
|
+
lifetime.reset();
|
|
113
|
+
const root = $container.get(0);
|
|
114
|
+
const prefsStore = kit.settingsStore(PREFS_KEY, normalisePrefs);
|
|
115
|
+
let alive = true;
|
|
116
|
+
lifetime.add(() => { alive = false; });
|
|
117
|
+
|
|
118
|
+
let site = {};
|
|
119
|
+
try { site = await docsApi.settings.get(); } catch { /* an older server: defaults */ }
|
|
120
|
+
if (!alive) return;
|
|
121
|
+
const extensions = await loadExtensions(site.extensions);
|
|
122
|
+
if (!alive) return;
|
|
123
|
+
// Skeletons for a place's first load (an older core without the helper shows the slot's text).
|
|
124
|
+
const loader = await import('/admin/js/lib/loading.js').catch(() => null);
|
|
125
|
+
if (!alive) return;
|
|
126
|
+
|
|
127
|
+
// ---- State ----
|
|
128
|
+
const start = prefsStore.read();
|
|
129
|
+
const view = M.observable(start.view);
|
|
130
|
+
const folderId = M.observable(start.view === 'mine' ? start.folderId : null);
|
|
131
|
+
const layout = M.observable(start.layout);
|
|
132
|
+
const sort = M.observable(start.sort);
|
|
133
|
+
const railCollapsed = M.observable(start.railCollapsed || window.innerWidth < 720);
|
|
134
|
+
const showWordCount = M.observable(start.showWordCount);
|
|
135
|
+
const query = M.observable('');
|
|
136
|
+
const raw = M.observable([]); // rows as the server sent them
|
|
137
|
+
const templates = M.observable([]);
|
|
138
|
+
const loading = M.observable(true);
|
|
139
|
+
// The skeleton slot: a place's first load, never a reload (search, after a change) - those keep the list on screen.
|
|
140
|
+
const skeleton = M.observable(true);
|
|
141
|
+
// The rail's numbers - what each place shows when opened (GET /count `places`).
|
|
142
|
+
const placeCounts = M.observable({starred: '', shared: '', mine: '', trash: ''});
|
|
143
|
+
const dragging = M.observable(false);
|
|
144
|
+
const folders = M.observable([]);
|
|
145
|
+
|
|
146
|
+
lifetime.add(M.effect(() => prefsStore.write({
|
|
147
|
+
view: view.value, folderId: folderId.value, layout: layout.value, sort: sort.value,
|
|
148
|
+
railCollapsed: railCollapsed.value, showWordCount: showWordCount.value
|
|
149
|
+
})));
|
|
150
|
+
|
|
151
|
+
// Plain observables fed by effects: a data-each over a computed never re-renders.
|
|
152
|
+
const rows = M.observable([]);
|
|
153
|
+
lifetime.add(M.effect(() => {
|
|
154
|
+
const s = sort.value;
|
|
155
|
+
const words = showWordCount.value;
|
|
156
|
+
const v = view.value;
|
|
157
|
+
const time = (iso) => Date.parse(iso || '') || 0;
|
|
158
|
+
const list = [...raw.value];
|
|
159
|
+
if (v !== 'recent') {
|
|
160
|
+
list.sort(s === 'title' ? (a, b) => a.title.localeCompare(b.title, undefined, {sensitivity: 'base'})
|
|
161
|
+
: s === 'created' ? (a, b) => time(b.createdAt) - time(a.createdAt)
|
|
162
|
+
: s === 'opened' ? (a, b) => time(b.openedAt) - time(a.openedAt)
|
|
163
|
+
: (a, b) => time(b.updatedAt) - time(a.updatedAt));
|
|
164
|
+
}
|
|
165
|
+
rows.value = list.map((d) => {
|
|
166
|
+
const flags = ['docs-card'];
|
|
167
|
+
if (d.starred) flags.push('is-starred');
|
|
168
|
+
if (d.shared) flags.push('has-shared');
|
|
169
|
+
if (d.public) flags.push('has-public');
|
|
170
|
+
if (d.locked) flags.push('has-locked');
|
|
171
|
+
if (d.trashedAt) flags.push('is-trashed');
|
|
172
|
+
if (d.format === 'pdf') flags.push('is-pdf');
|
|
173
|
+
const when = v === 'trash' ? d.trashedAt : (s === 'opened' && d.openedAt ? d.openedAt : d.updatedAt);
|
|
174
|
+
const whenWord = v === 'trash' ? 'Trashed' : (s === 'opened' && d.openedAt ? 'Opened' : 'Edited');
|
|
175
|
+
const parts = [when ? `${whenWord} ${D(when).fromNow()}` : ''];
|
|
176
|
+
if (d.access !== 'owner' || v === 'shared') parts.push(d.ownerName);
|
|
177
|
+
// A PDF (2.13) says its pages and size where a written document says its words.
|
|
178
|
+
if (d.format === 'pdf') parts.push(pdfFacts(d.file) || 'PDF');
|
|
179
|
+
else if (words) parts.push(`${d.wordCount} word${d.wordCount === 1 ? '' : 's'}`);
|
|
180
|
+
return {
|
|
181
|
+
id: d.id,
|
|
182
|
+
cls: flags.join(' '),
|
|
183
|
+
title: d.title || 'Untitled',
|
|
184
|
+
label: `Open ${d.title || 'Untitled'}`,
|
|
185
|
+
excerpt: d.excerpt || '',
|
|
186
|
+
meta: parts.filter(Boolean).join(' · '),
|
|
187
|
+
when: when ? fmtDateTime(when) : '',
|
|
188
|
+
lockText: d.locked ? `${d.locked.name} is editing` : '',
|
|
189
|
+
starLabel: d.starred ? 'Remove star' : 'Star'
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
const templateRows = M.observable([]);
|
|
195
|
+
lifetime.add(M.effect(() => {
|
|
196
|
+
const q = query.value.trim().toLowerCase();
|
|
197
|
+
templateRows.value = templates.value
|
|
198
|
+
.filter(t => !q || (t.name || '').toLowerCase().includes(q) || (t.category || '').toLowerCase().includes(q))
|
|
199
|
+
.map(t => ({id: t.id, title: t.name || 'Untitled template',
|
|
200
|
+
meta: t.builtIn ? [t.category, t.source].filter(Boolean).join(' · ') || 'Ready-made' : 'Your template',
|
|
201
|
+
excerpt: String(t.content || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 220)}));
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
const folderRows = M.observable([]);
|
|
205
|
+
lifetime.add(M.effect(() => {
|
|
206
|
+
const current = view.value === 'mine' ? folderId.value : '__none';
|
|
207
|
+
folderRows.value = folders.value.filter(f => f.id !== null).map(f => ({
|
|
208
|
+
key: f.key, id: f.id, name: f.name,
|
|
209
|
+
indent: (0.75 + f.depth * 0.85).toFixed(2),
|
|
210
|
+
cls: `docs-folder${f.id === current ? ' is-active' : ''}`
|
|
211
|
+
}));
|
|
212
|
+
}));
|
|
213
|
+
|
|
214
|
+
let countsReady = false;
|
|
215
|
+
const folderManager = new FolderManager({
|
|
216
|
+
onFolderSelect: (id) => { go('mine', id); },
|
|
217
|
+
// A folder deleted moves its documents up: My documents' number changes (after the first load).
|
|
218
|
+
onChange: () => { folders.value = folderManager.rows(); if (countsReady) loadCounts(); }
|
|
219
|
+
});
|
|
220
|
+
const folderName = (id) => (id ? (folderManager.byId(id)?.name || 'Folder') : null);
|
|
221
|
+
|
|
222
|
+
const extPlaces = [];
|
|
223
|
+
const isExt = M.computed(() => view.value.startsWith('ext:'));
|
|
224
|
+
const data = {
|
|
225
|
+
view, folderId, layout, sort, query, rows, templateRows, folderRows, railCollapsed, dragging, skeleton, placeCounts,
|
|
226
|
+
isTemplates: M.computed(() => view.value === 'templates'),
|
|
227
|
+
isTrash: M.computed(() => view.value === 'trash'),
|
|
228
|
+
isExt,
|
|
229
|
+
bodyClass: M.computed(() => `docs-main__body ${layout.value === 'list' ? 'is-list' : 'is-grid'}${dragging.value ? ' is-dragging' : ''}`),
|
|
230
|
+
viewTitle: M.computed(() => {
|
|
231
|
+
folders.value;
|
|
232
|
+
const v = view.value;
|
|
233
|
+
if (v.startsWith('ext:')) return extPlaces.find(p => `ext:${p.key}` === v)?.label || 'Docs';
|
|
234
|
+
if (v === 'mine' && folderId.value) return folderName(folderId.value);
|
|
235
|
+
return VIEWS[v]?.title || 'Docs';
|
|
236
|
+
}),
|
|
237
|
+
countText: M.computed(() => {
|
|
238
|
+
if (view.value.startsWith('ext:') || loading.value) return '';
|
|
239
|
+
const n = view.value === 'templates' ? templateRows.value.length : rows.value.length;
|
|
240
|
+
return `${n}`;
|
|
241
|
+
}),
|
|
242
|
+
showEmpty: M.computed(() => !loading.value && !view.value.startsWith('ext:')
|
|
243
|
+
&& (view.value === 'templates' ? templateRows.value.length === 0 : rows.value.length === 0)),
|
|
244
|
+
emptyText: M.computed(() => (query.value.trim() ? 'Nothing matches that search.' : (VIEWS[view.value]?.empty || ''))),
|
|
245
|
+
canCreateHere: M.computed(() => view.value === 'mine' || view.value === 'recent'),
|
|
246
|
+
trashNote: M.computed(() => `Documents in Trash are deleted for good after ${Number(site.trashDays) || 30} days.`)
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// -------------------------------------------------------------- data
|
|
250
|
+
|
|
251
|
+
let loadSeq = 0;
|
|
252
|
+
/** The place's list. `fresh` (a place just opened): clear it and show a skeleton while it loads. */
|
|
253
|
+
async function load({fresh = false} = {}) {
|
|
254
|
+
const seq = ++loadSeq;
|
|
255
|
+
const v = view.peek();
|
|
256
|
+
if (v.startsWith('ext:')) { loading.value = false; skeleton.value = false; return; }
|
|
257
|
+
loading.value = true;
|
|
258
|
+
if (fresh) {
|
|
259
|
+
skeleton.value = true;
|
|
260
|
+
if (v === 'templates') templates.value = []; else raw.value = [];
|
|
261
|
+
}
|
|
262
|
+
const request = v === 'templates' ? api('/templates')
|
|
263
|
+
: docsApi.list({view: v, folder: v === 'mine' ? (folderId.peek() || '') : undefined, q: query.peek().trim() || undefined});
|
|
264
|
+
const slot = root.querySelector('.docs-loading');
|
|
265
|
+
try {
|
|
266
|
+
const list = skeleton.peek() && loader?.whileLoading && slot
|
|
267
|
+
? await loader.whileLoading(slot, request, layout.peek() === 'list' ? loader.SHAPES.rows : loader.SHAPES.tiles)
|
|
268
|
+
: await request;
|
|
269
|
+
if (seq !== loadSeq || !alive) return;
|
|
270
|
+
if (v === 'templates') templates.value = Array.isArray(list) ? list : [];
|
|
271
|
+
else raw.value = Array.isArray(list) ? list : [];
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (alive && seq === loadSeq) E.toast(err.message || 'Could not load documents', {type: 'error'});
|
|
274
|
+
} finally {
|
|
275
|
+
if (seq === loadSeq && alive) { loading.value = false; skeleton.value = false; }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The rail's numbers. Quietly blank when the count cannot be had. */
|
|
280
|
+
let countSeq = 0;
|
|
281
|
+
async function loadCounts() {
|
|
282
|
+
// Only the newest answer counts: a quick trash then restore must not end on the older one.
|
|
283
|
+
const seq = ++countSeq;
|
|
284
|
+
try {
|
|
285
|
+
const r = await api('/count');
|
|
286
|
+
if (alive && seq === countSeq && r?.places) placeCounts.value = {...placeCounts.peek(), ...r.places};
|
|
287
|
+
} catch { /* the numbers are a convenience */ }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const extView = root.querySelector('.docs-ext-view');
|
|
291
|
+
let extDispose = null;
|
|
292
|
+
function go(v, folder = null) {
|
|
293
|
+
if (extDispose) { try { extDispose(); } catch { /* gone */ } extDispose = null; }
|
|
294
|
+
extView.replaceChildren();
|
|
295
|
+
view.value = v;
|
|
296
|
+
folderId.value = v === 'mine' ? folder : null;
|
|
297
|
+
if (v.startsWith('ext:')) {
|
|
298
|
+
const place = extPlaces.find(p => `ext:${p.key}` === v);
|
|
299
|
+
if (place) {
|
|
300
|
+
Promise.resolve(place.render(extView, host)).then((d) => { extDispose = typeof d === 'function' ? d : null; })
|
|
301
|
+
.catch((err) => E.toast(err.message || 'Could not open that view', {type: 'error'}));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (window.innerWidth < 720) railCollapsed.value = true;
|
|
305
|
+
load({fresh: true});
|
|
306
|
+
}
|
|
307
|
+
lifetime.add(() => { if (extDispose) try { extDispose(); } catch { /* gone */ } });
|
|
308
|
+
|
|
309
|
+
let searchTimer = null;
|
|
310
|
+
let firstQuery = true;
|
|
311
|
+
lifetime.add(M.effect(() => {
|
|
312
|
+
query.value;
|
|
313
|
+
if (firstQuery) { firstQuery = false; return; }
|
|
314
|
+
clearTimeout(searchTimer);
|
|
315
|
+
searchTimer = setTimeout(() => { if (view.peek() !== 'templates') load(); }, 250);
|
|
316
|
+
}));
|
|
317
|
+
lifetime.add(() => clearTimeout(searchTimer));
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------- actions
|
|
320
|
+
|
|
321
|
+
const rowById = (id) => raw.peek().find(r => r.id === id);
|
|
322
|
+
/** The sidebar badge (GET /count) and the rail's numbers, after anything that changes them. */
|
|
323
|
+
const recount = () => { kit.sidebarCount('#/plugins/docs'); loadCounts(); };
|
|
324
|
+
const open = (id) => { location.hash = docHref(id); };
|
|
325
|
+
|
|
326
|
+
async function createDoc(content = '', title = 'Untitled') {
|
|
327
|
+
try {
|
|
328
|
+
const doc = await docsApi.create({title, content, folderId: view.peek() === 'mine' ? folderId.peek() : null});
|
|
329
|
+
recount();
|
|
330
|
+
if (alive) open(doc.id);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
E.toast(err.message || 'Could not create the document', {type: 'error'});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Importers add-ons contribute (2.4): {ext: ['.docx'], label, accept, import(file, {folderId}) => doc}. */
|
|
337
|
+
const importers = [];
|
|
338
|
+
const importerFor = (f) => importers.find(i => i.ext.some(e => f.name.toLowerCase().endsWith(e)));
|
|
339
|
+
|
|
340
|
+
async function importFiles(files) {
|
|
341
|
+
const list = [...files].filter(Boolean);
|
|
342
|
+
if (!list.length) return;
|
|
343
|
+
const made = [];
|
|
344
|
+
const maxPdf = Number(site.pdf?.maxBytes) || 20 * 1024 * 1024;
|
|
345
|
+
for (const f of list) {
|
|
346
|
+
try {
|
|
347
|
+
const own = importerFor(f);
|
|
348
|
+
if (own) { made.push(await own.import(f, {folderId: view.peek() === 'mine' ? folderId.peek() : null})); continue; }
|
|
349
|
+
// A PDF (2.13) is kept as a file, not converted.
|
|
350
|
+
if (isPdfFile(f)) {
|
|
351
|
+
if (f.size > maxPdf) throw new Error(`${f.name} is over ${Math.round(maxPdf / 1024 / 1024)} MB`);
|
|
352
|
+
made.push(await docsApi.uploadPdf({name: f.name, data: await readBase64(f), folderId: view.peek() === 'mine' ? folderId.peek() : null}));
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const text = await readFile(f);
|
|
356
|
+
const doc = await docsApi.importFile({name: f.name, text, folderId: view.peek() === 'mine' ? folderId.peek() : null});
|
|
357
|
+
made.push(doc);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
E.toast(err.message || `Could not import ${f.name}`, {type: 'error'});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (made.length) recount();
|
|
363
|
+
if (!alive) return;
|
|
364
|
+
if (made.length === 1) { open(made[0].id); return; }
|
|
365
|
+
if (made.length) {
|
|
366
|
+
E.toast(`Added ${made.length} documents`, {type: 'success'});
|
|
367
|
+
if (view.peek() !== 'mine') go('mine', view.peek() === 'mine' ? folderId.peek() : null);
|
|
368
|
+
else load();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function toggleStar(id) {
|
|
373
|
+
const r = rowById(id);
|
|
374
|
+
if (!r) return;
|
|
375
|
+
try {
|
|
376
|
+
await docsApi.star(id, !r.starred);
|
|
377
|
+
if (!alive) return;
|
|
378
|
+
raw.value = raw.peek().map(x => (x.id === id ? {...x, starred: !r.starred} : x))
|
|
379
|
+
.filter(x => view.peek() !== 'starred' || x.starred);
|
|
380
|
+
recount(); // Starred is in the badge's details and on the rail
|
|
381
|
+
} catch (err) {
|
|
382
|
+
E.toast(err.message || 'Could not change the star', {type: 'error'});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function rename(id) {
|
|
387
|
+
const r = rowById(id);
|
|
388
|
+
const name = await promptWith('Document title', {title: 'Rename document'}, r?.title || '');
|
|
389
|
+
if (!alive || name === null || name === undefined || !String(name).trim()) return;
|
|
390
|
+
try {
|
|
391
|
+
await docsApi.update(id, {title: String(name).trim()});
|
|
392
|
+
if (alive) load();
|
|
393
|
+
} catch (err) {
|
|
394
|
+
E.toast(err.message || 'Could not rename', {type: 'error'});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function copy(id) {
|
|
399
|
+
try {
|
|
400
|
+
const c = await docsApi.duplicate(id);
|
|
401
|
+
E.toast('Copy made in My documents', {type: 'success'});
|
|
402
|
+
recount();
|
|
403
|
+
if (alive && (view.peek() === 'mine' || view.peek() === 'recent')) load();
|
|
404
|
+
return c;
|
|
405
|
+
} catch (err) {
|
|
406
|
+
E.toast(err.message || 'Could not copy', {type: 'error'});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function move(id, folder) {
|
|
411
|
+
try {
|
|
412
|
+
await docsApi.update(id, {folderId: folder});
|
|
413
|
+
E.toast(`Moved to ${folderName(folder) || 'My documents'}`, {type: 'success'});
|
|
414
|
+
if (!alive) return;
|
|
415
|
+
load();
|
|
416
|
+
loadCounts(); // My documents counts its top level
|
|
417
|
+
} catch (err) {
|
|
418
|
+
E.toast(err.message || 'Could not move', {type: 'error'});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function trash(id) {
|
|
423
|
+
try {
|
|
424
|
+
await docsApi.trash(id);
|
|
425
|
+
if (alive) raw.value = raw.peek().filter(r => r.id !== id);
|
|
426
|
+
E.toast('Moved to Trash', {type: 'success'});
|
|
427
|
+
recount();
|
|
428
|
+
} catch (err) {
|
|
429
|
+
E.toast(err.message || 'Could not move to Trash', {type: 'error'});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function restore(id) {
|
|
434
|
+
try {
|
|
435
|
+
await docsApi.restore(id);
|
|
436
|
+
if (alive) raw.value = raw.peek().filter(r => r.id !== id);
|
|
437
|
+
E.toast('Restored', {type: 'success'});
|
|
438
|
+
recount();
|
|
439
|
+
} catch (err) {
|
|
440
|
+
E.toast(err.message || 'Could not restore', {type: 'error'});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function purge(id) {
|
|
445
|
+
const r = rowById(id);
|
|
446
|
+
const ok = await E.confirm(`Delete <strong>${esc(r?.title || 'this document')}</strong> and its version history for good? This cannot be undone.`,
|
|
447
|
+
{title: 'Delete for good', confirmText: 'Delete'});
|
|
448
|
+
if (!ok || !alive) return;
|
|
449
|
+
try {
|
|
450
|
+
await docsApi.purge(id);
|
|
451
|
+
if (alive) raw.value = raw.peek().filter(x => x.id !== id);
|
|
452
|
+
recount();
|
|
453
|
+
} catch (err) {
|
|
454
|
+
E.toast(err.message || 'Could not delete', {type: 'error'});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function emptyTrash() {
|
|
459
|
+
const ok = await E.confirm('Delete everything in Trash for good? This cannot be undone.', {title: 'Empty Trash', confirmText: 'Empty Trash'});
|
|
460
|
+
if (!ok || !alive) return;
|
|
461
|
+
try {
|
|
462
|
+
const r = await docsApi.emptyTrash();
|
|
463
|
+
E.toast(`${r.deleted} document${r.deleted === 1 ? '' : 's'} deleted`, {type: 'success'});
|
|
464
|
+
recount();
|
|
465
|
+
if (alive) load();
|
|
466
|
+
} catch (err) {
|
|
467
|
+
E.toast(err.message || 'Could not empty Trash', {type: 'error'});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Panels opened from Home close with it; one still opening checks alive().
|
|
472
|
+
const panelLife = {alive: () => alive, add: (fn) => lifetime.add(fn)};
|
|
473
|
+
|
|
474
|
+
async function share(id) {
|
|
475
|
+
try {
|
|
476
|
+
const doc = await docsApi.get(id);
|
|
477
|
+
if (!alive) return;
|
|
478
|
+
await openShare({doc, kit, life: panelLife, onChange: () => { if (alive) load(); }, sections: forKind(shareSections, doc), suggestions: Boolean(site.suggestions)});
|
|
479
|
+
} catch (err) {
|
|
480
|
+
E.toast(err.message || 'Could not open sharing', {type: 'error'});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const fetchDoc = (id) => () => docsApi.get(id);
|
|
485
|
+
|
|
486
|
+
function moveItems(id) {
|
|
487
|
+
const r = rowById(id);
|
|
488
|
+
const here = r?.folderId || null;
|
|
489
|
+
return [{id: null, name: 'My documents (no folder)', depth: 0}, ...folderManager.rows().filter(f => f.id !== null)].map(f => ({
|
|
490
|
+
label: `${' '.repeat(f.depth)}${f.name}`,
|
|
491
|
+
icon: f.id === null ? 'folder-open' : 'folder',
|
|
492
|
+
disabled: f.id === here,
|
|
493
|
+
action: () => move(id, f.id)
|
|
494
|
+
}));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Everything you can do to one document from Home. */
|
|
498
|
+
function docMenu(id) {
|
|
499
|
+
const r = rowById(id);
|
|
500
|
+
if (!r) return [];
|
|
501
|
+
if (r.trashedAt) {
|
|
502
|
+
return [
|
|
503
|
+
{header: r.title || 'Untitled'},
|
|
504
|
+
{label: 'Open (read only)', icon: 'eye', action: () => open(id)},
|
|
505
|
+
{label: 'Restore', icon: 'rotate-ccw', action: () => restore(id)},
|
|
506
|
+
{type: 'divider'},
|
|
507
|
+
{label: 'Delete for good…', icon: 'trash', danger: true, action: () => purge(id)}
|
|
508
|
+
];
|
|
509
|
+
}
|
|
510
|
+
const owner = r.access === 'owner';
|
|
511
|
+
const editor = owner || r.access === 'editor';
|
|
512
|
+
const items = [
|
|
513
|
+
{header: r.title || 'Untitled'},
|
|
514
|
+
{label: 'Open', icon: 'edit', action: () => open(id)},
|
|
515
|
+
{label: 'Open in a new tab', icon: 'external-link', action: () => window.open(`${location.pathname}${docHref(id)}`, '_blank', 'noopener')},
|
|
516
|
+
{label: r.starred ? 'Remove star' : 'Star', icon: 'star', action: () => toggleStar(id)},
|
|
517
|
+
{type: 'divider'}
|
|
518
|
+
];
|
|
519
|
+
if (owner) items.push({label: 'Rename…', icon: 'edit-3', action: () => rename(id)});
|
|
520
|
+
items.push({label: 'Make a copy', icon: 'copy', action: () => copy(id)});
|
|
521
|
+
if (owner) items.push({label: 'Move to', icon: 'folder', submenu: moveItems(id)});
|
|
522
|
+
if (editor && (owner || r.outward !== false)) items.push({label: 'Share…', icon: 'user-plus', action: () => share(id)});
|
|
523
|
+
items.push({label: 'Download', icon: 'download', submenu: r.format === 'pdf' ? pdfExportItems(fetchDoc(id)) : exportItems(fetchDoc(id))});
|
|
524
|
+
if (editor && site.allowSend !== false && r.outward !== false) {
|
|
525
|
+
items.push({label: 'Email…', icon: 'mail', action: async () => {
|
|
526
|
+
try {
|
|
527
|
+
const doc = await docsApi.get(id);
|
|
528
|
+
if (alive) openSendEmail({doc, kit, life: panelLife, publicLinksAllowed: site.publicLinks !== false});
|
|
529
|
+
} catch (err) { E.toast(err.message || 'Could not open the document', {type: 'error'}); }
|
|
530
|
+
}});
|
|
531
|
+
}
|
|
532
|
+
for (const fn of forKind(extMenus, r)) {
|
|
533
|
+
try { const extra = fn({row: r, view: 'home'}); if (Array.isArray(extra) && extra.length) items.push({type: 'divider'}, ...extra); } catch { /* an add-on's problem */ }
|
|
534
|
+
}
|
|
535
|
+
if (owner) items.push({type: 'divider'}, {label: 'Move to Trash', icon: 'trash', danger: true, action: () => trash(id)});
|
|
536
|
+
return items;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function useTemplate(id) {
|
|
540
|
+
const t = templates.peek().find(x => x.id === id);
|
|
541
|
+
if (!t) return;
|
|
542
|
+
// A template with {{fields}}: tell the editor (and so Docs Pro) to offer filling them in (2.4).
|
|
543
|
+
if (/\{\{[^{}]{1,80}\}\}/.test(t.content || '')) { try { sessionStorage.setItem('docs:fill-next', '1'); } catch { /* fine */ } }
|
|
544
|
+
await createDoc(t.content || '', t.name || 'Untitled');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function deleteTemplate(id) {
|
|
548
|
+
const t = templates.peek().find(x => x.id === id);
|
|
549
|
+
const ok = await E.confirm(`Delete the template <strong>${esc(t?.name || '')}</strong>? Documents made from it are not affected.`, {title: 'Delete template', confirmText: 'Delete'});
|
|
550
|
+
if (!ok || !alive) return;
|
|
551
|
+
try {
|
|
552
|
+
await api(`/templates/${encodeURIComponent(id)}`, 'DELETE');
|
|
553
|
+
if (alive) templates.value = templates.peek().filter(x => x.id !== id);
|
|
554
|
+
} catch (err) {
|
|
555
|
+
E.toast(err.message || 'Could not delete the template', {type: 'error'});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function pickTemplate() {
|
|
560
|
+
go('templates');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ------------------------------------------------------- extensions
|
|
564
|
+
|
|
565
|
+
// Each add-on's menu items and share sections carry the kinds of document it understands (2.13):
|
|
566
|
+
// an add-on written before PDF documents sees HTML ones only.
|
|
567
|
+
const extMenus = [];
|
|
568
|
+
const shareSections = [];
|
|
569
|
+
let installing = null;
|
|
570
|
+
const tag = (fn) => { fn.kinds = installing?.kinds?.length ? installing.kinds : ['html']; return fn; };
|
|
571
|
+
const forKind = (list, doc) => list.filter(fn => (fn.kinds || ['html']).includes(doc?.format === 'pdf' ? 'pdf' : 'html'));
|
|
572
|
+
const host = {
|
|
573
|
+
view: 'home', api: docsApi, kit, esc, icon,
|
|
574
|
+
open, reload: () => load(),
|
|
575
|
+
addDocMenu(fn) { if (typeof fn === 'function') extMenus.push(tag(fn)); },
|
|
576
|
+
addShareSection(fn) { if (typeof fn === 'function') shareSections.push(tag(fn)); },
|
|
577
|
+
/** More file types for New > Import and drag-and-drop (2.4). */
|
|
578
|
+
addImporter(imp) {
|
|
579
|
+
if (!imp || !Array.isArray(imp.ext) || typeof imp.import !== 'function') return;
|
|
580
|
+
importers.push(imp);
|
|
581
|
+
const input = root.querySelector('.docs-import-input');
|
|
582
|
+
if (input) input.accept = [input.accept, ...imp.ext, imp.accept].filter(Boolean).join(',');
|
|
583
|
+
},
|
|
584
|
+
/** Keep more data-* attributes through the admin's sanitiser (2.4) - e.g. comment anchors. */
|
|
585
|
+
allowAttrs: (list) => allowDocAttrs(list),
|
|
586
|
+
/** Filter the body on its way out - downloads, print, Send to (2.5). */
|
|
587
|
+
setContentFilter: (key, fn) => setContentFilter(key, fn),
|
|
588
|
+
suggestions: Boolean(site.suggestions),
|
|
589
|
+
addPlace(place) {
|
|
590
|
+
if (!place || !/^[a-z0-9-]{1,40}$/.test(place.key) || typeof place.render !== 'function') return;
|
|
591
|
+
extPlaces.push(place);
|
|
592
|
+
const btn = document.createElement('button');
|
|
593
|
+
btn.type = 'button';
|
|
594
|
+
btn.className = 'docs-place';
|
|
595
|
+
btn.dataset.place = `ext:${place.key}`;
|
|
596
|
+
btn.innerHTML = `${icon(place.icon || 'file-text', 16)}<span class="docs-rail__label"></span>`;
|
|
597
|
+
btn.querySelector('.docs-rail__label').textContent = place.label || place.key;
|
|
598
|
+
root.querySelector('.docs-rail__ext').appendChild(btn);
|
|
599
|
+
lifetime.add(M.effect(() => btn.classList.toggle('is-active', view.value === `ext:${place.key}`)));
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
for (const ext of extensions) {
|
|
603
|
+
installing = ext;
|
|
604
|
+
try { await ext.mod.install?.(host); } catch (err) { console.warn('[docs] add-on install failed', ext.owner, err); }
|
|
605
|
+
installing = null;
|
|
606
|
+
if (!alive) return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ---------------------------------------------------------- bindings
|
|
610
|
+
|
|
611
|
+
root.querySelector('.docs-cards:not(.docs-cards--templates)').innerHTML = cardTemplate();
|
|
612
|
+
root.querySelector('.docs-cards--templates').innerHTML = templateTemplate();
|
|
613
|
+
root.querySelector('.docs-rail__folders').innerHTML = folderTemplate();
|
|
614
|
+
// Help first, while every marker is still in the document.
|
|
615
|
+
lifetime.add(kit.helpPopovers(root, {position: 'bottom'}));
|
|
616
|
+
|
|
617
|
+
const newBtn = root.querySelector('.docs-rail__new');
|
|
618
|
+
const fileInput = root.querySelector('.docs-import-input');
|
|
619
|
+
const cards = root.querySelector('.docs-cards:not(.docs-cards--templates)');
|
|
620
|
+
const templateCards = root.querySelector('.docs-cards--templates');
|
|
621
|
+
const folderPane = root.querySelector('.docs-rail__folders');
|
|
622
|
+
const body = root.querySelector('.docs-main__body');
|
|
623
|
+
const search = root.querySelector('.docs-main__search input');
|
|
624
|
+
|
|
625
|
+
// New lives in the banner (the screen's actions go there); its menu is bound to that button.
|
|
626
|
+
// Without the chrome (an old core) the rail keeps its own New button.
|
|
627
|
+
const bannerNew = kit.addBannerAction($container, {id: 'docs-new', icon: 'plus', title: 'New - a blank document, a template, an import or a folder', onClick: () => kit.openMenuAt(bannerNew)});
|
|
628
|
+
const bannerImport = kit.addBannerAction($container, {id: 'docs-import', icon: 'upload', title: 'Import or upload files - .md, .html, .txt, .pdf (or drop them on the list)', onClick: () => fileInput.click()});
|
|
629
|
+
if (bannerNew) newBtn.hidden = true;
|
|
630
|
+
const newTarget = bannerNew || newBtn;
|
|
631
|
+
|
|
632
|
+
const bindings = M.applyBindings(data, root, {
|
|
633
|
+
methods: {
|
|
634
|
+
openNewMenu: () => kit.openMenuAt(newTarget),
|
|
635
|
+
newDoc: () => createDoc(),
|
|
636
|
+
newFolder: () => folderManager.createFolder(null),
|
|
637
|
+
toggleRail: () => { railCollapsed.value = !railCollapsed.peek(); },
|
|
638
|
+
gridLayout: () => { layout.value = 'grid'; },
|
|
639
|
+
listLayout: () => { layout.value = 'list'; },
|
|
640
|
+
emptyTrash
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
lifetime.add(() => bindings.dispose());
|
|
644
|
+
|
|
645
|
+
const menus = [
|
|
646
|
+
E.contextMenu(newTarget, {
|
|
647
|
+
cursor: 'glyph', inherit: false, density: 'compact',
|
|
648
|
+
items: () => [
|
|
649
|
+
{header: 'New'},
|
|
650
|
+
{label: 'Blank document', icon: 'file-text', action: () => createDoc()},
|
|
651
|
+
{label: 'From a template…', icon: 'layout', action: pickTemplate},
|
|
652
|
+
{label: 'Import a file…', icon: 'upload', action: () => fileInput.click()},
|
|
653
|
+
{label: 'Upload a PDF…', icon: 'file', action: () => fileInput.click()},
|
|
654
|
+
{type: 'divider'},
|
|
655
|
+
{label: 'New folder…', icon: 'folder-plus', action: () => folderManager.createFolder(view.peek() === 'mine' ? folderId.peek() : null)}
|
|
656
|
+
]
|
|
657
|
+
}),
|
|
658
|
+
E.contextMenu(cards, {cursor: 'glyph', match: '.docs-card', inherit: false, density: 'compact', items: (el) => docMenu(el.dataset.doc)}),
|
|
659
|
+
E.contextMenu(templateCards, {
|
|
660
|
+
cursor: 'glyph', match: '.docs-card', inherit: false, density: 'compact',
|
|
661
|
+
items: (el) => {
|
|
662
|
+
const t = templates.peek().find(x => x.id === el.dataset.template);
|
|
663
|
+
const items = [{header: t?.name || 'Template'}, {label: 'New document from this', icon: 'plus', action: () => useTemplate(el.dataset.template)}];
|
|
664
|
+
// Ready-made templates (from an add-on) are read only.
|
|
665
|
+
if (t && !t.builtIn) items.push({type: 'divider'}, {label: 'Delete template…', icon: 'trash', danger: true, action: () => deleteTemplate(el.dataset.template)});
|
|
666
|
+
return items;
|
|
667
|
+
}
|
|
668
|
+
}),
|
|
669
|
+
E.contextMenu(folderPane, {
|
|
670
|
+
cursor: 'glyph', match: '.docs-folder', inherit: false, density: 'compact',
|
|
671
|
+
items: (el) => {
|
|
672
|
+
const f = folderManager.byId(el.dataset.folder);
|
|
673
|
+
if (!f) return [];
|
|
674
|
+
return [
|
|
675
|
+
{header: f.name},
|
|
676
|
+
{label: 'Open', icon: 'folder-open', action: () => go('mine', f.id)},
|
|
677
|
+
{label: 'New subfolder…', icon: 'folder-plus', action: () => folderManager.createFolder(f.id)},
|
|
678
|
+
{label: 'Rename…', icon: 'edit-3', action: () => folderManager.renameFolder(f)},
|
|
679
|
+
{type: 'divider'},
|
|
680
|
+
{label: 'Delete folder…', icon: 'trash', danger: true, action: () => folderManager.deleteFolder(f)}
|
|
681
|
+
];
|
|
682
|
+
}
|
|
683
|
+
})
|
|
684
|
+
];
|
|
685
|
+
lifetime.add(() => menus.forEach(m => m?.destroy?.()));
|
|
686
|
+
lifetime.add(() => { bannerNew?.remove(); bannerImport?.remove(); });
|
|
687
|
+
|
|
688
|
+
root.querySelector('.docs-rail__places').addEventListener('click', (e) => {
|
|
689
|
+
const b = e.target.closest('[data-place]');
|
|
690
|
+
if (b) go(b.dataset.place);
|
|
691
|
+
});
|
|
692
|
+
cards.addEventListener('click', (e) => {
|
|
693
|
+
const card = e.target.closest('.docs-card');
|
|
694
|
+
if (!card) return;
|
|
695
|
+
if (e.target.closest('.docs-kebab')) return kit.openMenuAt(e.target.closest('.docs-kebab'));
|
|
696
|
+
if (e.target.closest('[data-star]')) return toggleStar(card.dataset.doc);
|
|
697
|
+
open(card.dataset.doc);
|
|
698
|
+
});
|
|
699
|
+
templateCards.addEventListener('click', (e) => {
|
|
700
|
+
const card = e.target.closest('.docs-card');
|
|
701
|
+
if (!card) return;
|
|
702
|
+
if (e.target.closest('.docs-kebab')) return kit.openMenuAt(e.target.closest('.docs-kebab'));
|
|
703
|
+
useTemplate(card.dataset.template);
|
|
704
|
+
});
|
|
705
|
+
folderPane.addEventListener('click', (e) => {
|
|
706
|
+
const row = e.target.closest('.docs-folder');
|
|
707
|
+
if (!row) return;
|
|
708
|
+
if (e.target.closest('.docs-kebab')) return kit.openMenuAt(e.target.closest('.docs-kebab'));
|
|
709
|
+
folderManager.select(row.dataset.folder);
|
|
710
|
+
});
|
|
711
|
+
for (const pane of [cards, templateCards, folderPane]) {
|
|
712
|
+
pane.addEventListener('keydown', (e) => {
|
|
713
|
+
if (!e.target.matches('.docs-card, .docs-folder')) return;
|
|
714
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
715
|
+
e.preventDefault();
|
|
716
|
+
e.target.click();
|
|
717
|
+
} else if (e.key === 'F10' && e.shiftKey) {
|
|
718
|
+
// The item's menu, as a right-click or its ⋮ opens it.
|
|
719
|
+
e.preventDefault();
|
|
720
|
+
kit.openMenuAt(e.target);
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Import: the file picker, or files dropped anywhere on the list.
|
|
726
|
+
fileInput.addEventListener('change', () => { importFiles(fileInput.files); fileInput.value = ''; });
|
|
727
|
+
let dragDepth = 0;
|
|
728
|
+
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes('Files');
|
|
729
|
+
body.addEventListener('dragenter', (e) => { if (!hasFiles(e)) return; e.preventDefault(); dragDepth++; dragging.value = true; });
|
|
730
|
+
body.addEventListener('dragover', (e) => { if (hasFiles(e)) e.preventDefault(); });
|
|
731
|
+
body.addEventListener('dragleave', () => { dragDepth = Math.max(0, dragDepth - 1); if (!dragDepth) dragging.value = false; });
|
|
732
|
+
body.addEventListener('drop', (e) => {
|
|
733
|
+
if (!hasFiles(e)) return;
|
|
734
|
+
e.preventDefault();
|
|
735
|
+
dragDepth = 0;
|
|
736
|
+
dragging.value = false;
|
|
737
|
+
importFiles(e.dataTransfer.files);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// "/" jumps to search, as in most document lists.
|
|
741
|
+
const onKey = (e) => {
|
|
742
|
+
if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
743
|
+
if (e.target.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
|
744
|
+
e.preventDefault();
|
|
745
|
+
search?.focus();
|
|
746
|
+
};
|
|
747
|
+
document.addEventListener('keydown', onKey);
|
|
748
|
+
lifetime.add(() => document.removeEventListener('keydown', onKey));
|
|
749
|
+
|
|
750
|
+
// Settings: the banner cog.
|
|
751
|
+
const cog = kit.addBannerAction($container, {id: 'docs-settings', icon: 'settings', title: 'Docs settings',
|
|
752
|
+
onClick: () => openSettings({kit, site, life: panelLife, prefs: {showWordCount}, onSaved: (next) => { Object.assign(site, next); }})});
|
|
753
|
+
if (!cog) {
|
|
754
|
+
const btn = document.createElement('button');
|
|
755
|
+
btn.type = 'button';
|
|
756
|
+
btn.className = 'btn btn-sm btn-ghost';
|
|
757
|
+
btn.title = 'Docs settings';
|
|
758
|
+
btn.setAttribute('aria-label', 'Docs settings');
|
|
759
|
+
btn.innerHTML = icon('settings', 16);
|
|
760
|
+
btn.addEventListener('click', () => openSettings({kit, site, life: panelLife, prefs: {showWordCount}, onSaved: (next) => Object.assign(site, next)}));
|
|
761
|
+
root.querySelector('.docs-main__bar')?.appendChild(btn);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
Domma.icons.scan(root);
|
|
765
|
+
|
|
766
|
+
// ---- First load ----
|
|
767
|
+
await folderManager.load();
|
|
768
|
+
if (!alive) return;
|
|
769
|
+
if (folderId.peek() && !folderManager.byId(folderId.peek())) folderId.value = null;
|
|
770
|
+
if (view.peek().startsWith('ext:') && !extPlaces.some(p => `ext:${p.key}` === view.peek())) view.value = 'recent';
|
|
771
|
+
go(view.peek(), folderId.peek());
|
|
772
|
+
countsReady = true;
|
|
773
|
+
loadCounts();
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
export {HOME};
|