living-documentation 7.9.0 → 7.11.0
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/dist/src/frontend/confirm-modal.js +73 -0
- package/dist/src/frontend/documents.js +88 -103
- package/dist/src/frontend/files-modal.js +243 -0
- package/dist/src/frontend/i18n/en.json +51 -16
- package/dist/src/frontend/i18n/fr.json +51 -16
- package/dist/src/frontend/index.html +110 -2
- package/dist/src/frontend/search.js +45 -8
- package/dist/src/frontend/snippets.js +481 -0
- package/dist/src/lib/metadata.d.ts.map +1 -1
- package/dist/src/lib/metadata.js +5 -9
- package/dist/src/lib/metadata.js.map +1 -1
- package/dist/src/routes/documents.d.ts.map +1 -1
- package/dist/src/routes/documents.js +34 -3
- package/dist/src/routes/documents.js.map +1 -1
- package/dist/src/routes/files.d.ts.map +1 -1
- package/dist/src/routes/files.js +100 -0
- package/dist/src/routes/files.js.map +1 -1
- package/dist/starting-doc/1_tutorial/2026_04_11_13_25_[General]_crer_vos_dossiers.md +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// ── Generic confirmation modal ──────────────────────────────────────────────
|
|
2
|
+
// Promise-based replacement for window.confirm(). Any module can call:
|
|
3
|
+
// const ok = await showConfirm({ title, message, detail, confirmLabel, danger });
|
|
4
|
+
// The modal sits at z-[60] so it stacks above other app modals (z-50).
|
|
5
|
+
|
|
6
|
+
let _confirmResolve = null;
|
|
7
|
+
|
|
8
|
+
function _confirmT(key, fallback) {
|
|
9
|
+
return window.t ? window.t(key) : fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function _confirmClose(result) {
|
|
13
|
+
const modal = document.getElementById("confirm-modal");
|
|
14
|
+
if (modal) modal.classList.add("hidden");
|
|
15
|
+
document.removeEventListener("keydown", _confirmKeyHandler);
|
|
16
|
+
const fn = _confirmResolve;
|
|
17
|
+
_confirmResolve = null;
|
|
18
|
+
if (fn) fn(result);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function _confirmKeyHandler(e) {
|
|
22
|
+
if (e.key === "Escape") {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
_confirmClose(false);
|
|
25
|
+
} else if (e.key === "Enter") {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
_confirmClose(true);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function _confirmModalBackdrop(e) {
|
|
32
|
+
if (e.target && e.target.id === "confirm-modal") _confirmClose(false);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function showConfirm(opts) {
|
|
36
|
+
const o = opts || {};
|
|
37
|
+
const modal = document.getElementById("confirm-modal");
|
|
38
|
+
if (!modal) return Promise.resolve(false);
|
|
39
|
+
|
|
40
|
+
const titleEl = document.getElementById("confirm-modal-title");
|
|
41
|
+
const messageEl = document.getElementById("confirm-modal-message");
|
|
42
|
+
const detailEl = document.getElementById("confirm-modal-detail");
|
|
43
|
+
const cancelBtn = document.getElementById("confirm-modal-cancel");
|
|
44
|
+
const okBtn = document.getElementById("confirm-modal-ok");
|
|
45
|
+
|
|
46
|
+
titleEl.textContent = o.title || "";
|
|
47
|
+
titleEl.style.display = o.title ? "" : "none";
|
|
48
|
+
messageEl.textContent = o.message || "";
|
|
49
|
+
messageEl.style.display = o.message ? "" : "none";
|
|
50
|
+
detailEl.textContent = o.detail || "";
|
|
51
|
+
detailEl.style.display = o.detail ? "" : "none";
|
|
52
|
+
|
|
53
|
+
cancelBtn.textContent = o.cancelLabel || _confirmT("common.cancel", "Cancel");
|
|
54
|
+
okBtn.textContent = o.confirmLabel || _confirmT("common.confirm", "Confirm");
|
|
55
|
+
const baseCls = "text-sm px-4 py-1.5 rounded-lg text-white font-semibold transition-colors";
|
|
56
|
+
okBtn.className = o.danger
|
|
57
|
+
? `${baseCls} bg-red-500 hover:bg-red-600`
|
|
58
|
+
: `${baseCls} bg-blue-600 hover:bg-blue-700`;
|
|
59
|
+
|
|
60
|
+
modal.classList.remove("hidden");
|
|
61
|
+
setTimeout(() => okBtn.focus(), 0);
|
|
62
|
+
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
if (_confirmResolve) _confirmResolve(false); // resolve any pending
|
|
65
|
+
_confirmResolve = resolve;
|
|
66
|
+
cancelBtn.onclick = () => _confirmClose(false);
|
|
67
|
+
okBtn.onclick = () => _confirmClose(true);
|
|
68
|
+
document.addEventListener("keydown", _confirmKeyHandler);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
window.showConfirm = showConfirm;
|
|
73
|
+
window._confirmModalBackdrop = _confirmModalBackdrop;
|
|
@@ -3,6 +3,88 @@
|
|
|
3
3
|
// annotations (loadAnnotations, applyAnnotationHighlights, renderElevator),
|
|
4
4
|
// and image-paste.js (handleEditorPaste).
|
|
5
5
|
|
|
6
|
+
// Cache of the last rendered doc HTML so search input changes can re-wire
|
|
7
|
+
// the content without a round-trip to the server.
|
|
8
|
+
let _lastDocHtml = null;
|
|
9
|
+
let _lastDocIdRendered = null;
|
|
10
|
+
|
|
11
|
+
function _wireDocContent(html) {
|
|
12
|
+
const contentEl = document.getElementById("doc-content");
|
|
13
|
+
if (!contentEl) return;
|
|
14
|
+
contentEl.innerHTML = html;
|
|
15
|
+
|
|
16
|
+
contentEl.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((h) => {
|
|
17
|
+
if (!h.id) {
|
|
18
|
+
h.id = h.textContent
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[^\w\s-]/g, "")
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/\s+/g, "-");
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
contentEl.querySelectorAll("pre code").forEach((block) => {
|
|
27
|
+
hljs.highlightElement(block);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
contentEl.querySelectorAll("a[href]").forEach((a) => {
|
|
31
|
+
const href = a.getAttribute("href");
|
|
32
|
+
const m = href && href.match(/[?&]doc=([^&#]+)/);
|
|
33
|
+
if (!m) return;
|
|
34
|
+
const hashIdx = href.indexOf("#");
|
|
35
|
+
const anchorTarget = hashIdx !== -1 ? href.slice(hashIdx + 1) : null;
|
|
36
|
+
a.addEventListener("click", (e) => {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
openDocument(decodeURIComponent(m[1]), false, true, anchorTarget);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
contentEl.querySelectorAll('a[href^="#"]').forEach((a) => {
|
|
43
|
+
const href = a.getAttribute("href");
|
|
44
|
+
if (!href || href.length < 2) return;
|
|
45
|
+
a.addEventListener("click", (e) => {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
scrollToAnchor(href.slice(1));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
contentEl.querySelectorAll("table").forEach((t) => {
|
|
52
|
+
const wrapper = document.createElement("div");
|
|
53
|
+
wrapper.className = "overflow-x-auto";
|
|
54
|
+
t.parentNode.insertBefore(wrapper, t);
|
|
55
|
+
wrapper.appendChild(t);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const notice = document.getElementById("search-notice");
|
|
59
|
+
const isMetaQuery =
|
|
60
|
+
typeof searchQuery === "string" &&
|
|
61
|
+
searchQuery.toLowerCase().startsWith("metadata://");
|
|
62
|
+
if (searchQuery && !isMetaQuery) {
|
|
63
|
+
const matches = highlightMatches(contentEl, searchQuery);
|
|
64
|
+
buildSearchNotice(matches, searchQuery);
|
|
65
|
+
notice.classList.remove("hidden");
|
|
66
|
+
} else {
|
|
67
|
+
notice.classList.add("hidden");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function refreshSearchInCurrentDoc() {
|
|
72
|
+
if (
|
|
73
|
+
!currentDocId ||
|
|
74
|
+
_lastDocHtml === null ||
|
|
75
|
+
currentDocId !== _lastDocIdRendered
|
|
76
|
+
) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const contentArea = document.getElementById("content-area");
|
|
80
|
+
const scrollTop = contentArea ? contentArea.scrollTop : 0;
|
|
81
|
+
_wireDocContent(_lastDocHtml);
|
|
82
|
+
if (typeof loadAnnotations === "function") loadAnnotations(currentDocId);
|
|
83
|
+
if (contentArea) contentArea.scrollTop = scrollTop;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
window.refreshSearchInCurrentDoc = refreshSearchInCurrentDoc;
|
|
87
|
+
|
|
6
88
|
async function loadDocuments() {
|
|
7
89
|
try {
|
|
8
90
|
[allDocs] = await Promise.all([
|
|
@@ -158,8 +240,9 @@ async function openDocument(id, skipHistory = false, fromLink = false, anchor =
|
|
|
158
240
|
document.getElementById("doc-date").textContent =
|
|
159
241
|
doc.formattedDate || "";
|
|
160
242
|
|
|
161
|
-
|
|
162
|
-
|
|
243
|
+
_lastDocHtml = doc.html;
|
|
244
|
+
_lastDocIdRendered = id;
|
|
245
|
+
_wireDocContent(doc.html);
|
|
163
246
|
|
|
164
247
|
// Load annotations for this document
|
|
165
248
|
loadAnnotations(id);
|
|
@@ -169,63 +252,6 @@ async function openDocument(id, skipHistory = false, fromLink = false, anchor =
|
|
|
169
252
|
loadMetadataReport(id);
|
|
170
253
|
}
|
|
171
254
|
|
|
172
|
-
// Add IDs to headings for anchor navigation
|
|
173
|
-
contentEl.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((h) => {
|
|
174
|
-
if (!h.id) {
|
|
175
|
-
h.id = h.textContent
|
|
176
|
-
.toLowerCase()
|
|
177
|
-
.replace(/[^\w\s-]/g, "")
|
|
178
|
-
.trim()
|
|
179
|
-
.replace(/\s+/g, "-");
|
|
180
|
-
}
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
// Syntax highlighting
|
|
184
|
-
contentEl.querySelectorAll("pre code").forEach((block) => {
|
|
185
|
-
hljs.highlightElement(block);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
// Intercept inter-doc links (?doc=X[#anchor]) to stay in SPA and track origin
|
|
189
|
-
contentEl.querySelectorAll("a[href]").forEach((a) => {
|
|
190
|
-
const href = a.getAttribute("href");
|
|
191
|
-
const m = href && href.match(/[?&]doc=([^&#]+)/);
|
|
192
|
-
if (!m) return;
|
|
193
|
-
const hashIdx = href.indexOf("#");
|
|
194
|
-
const anchor = hashIdx !== -1 ? href.slice(hashIdx + 1) : null;
|
|
195
|
-
a.addEventListener("click", (e) => {
|
|
196
|
-
e.preventDefault();
|
|
197
|
-
openDocument(decodeURIComponent(m[1]), false, true, anchor);
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
// Intercept pure-anchor links (#foo) to offset for the sticky header
|
|
202
|
-
contentEl.querySelectorAll('a[href^="#"]').forEach((a) => {
|
|
203
|
-
const href = a.getAttribute("href");
|
|
204
|
-
if (!href || href.length < 2) return;
|
|
205
|
-
a.addEventListener("click", (e) => {
|
|
206
|
-
e.preventDefault();
|
|
207
|
-
scrollToAnchor(href.slice(1));
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
// Make tables responsive
|
|
212
|
-
contentEl.querySelectorAll("table").forEach((t) => {
|
|
213
|
-
const wrapper = document.createElement("div");
|
|
214
|
-
wrapper.className = "overflow-x-auto";
|
|
215
|
-
t.parentNode.insertBefore(wrapper, t);
|
|
216
|
-
wrapper.appendChild(t);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// Highlight search matches in content
|
|
220
|
-
const notice = document.getElementById("search-notice");
|
|
221
|
-
if (searchQuery) {
|
|
222
|
-
const matches = highlightMatches(contentEl, searchQuery);
|
|
223
|
-
buildSearchNotice(matches, searchQuery);
|
|
224
|
-
notice.classList.remove("hidden");
|
|
225
|
-
} else {
|
|
226
|
-
notice.classList.add("hidden");
|
|
227
|
-
}
|
|
228
|
-
|
|
229
255
|
document.title = doc.title;
|
|
230
256
|
|
|
231
257
|
// Scroll to anchor if present (explicit param wins over URL hash)
|
|
@@ -342,50 +368,9 @@ async function saveDocument() {
|
|
|
342
368
|
const doc = await fetch("/api/documents/" + currentDocId).then((r) =>
|
|
343
369
|
r.json(),
|
|
344
370
|
);
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
.querySelectorAll("pre code")
|
|
349
|
-
.forEach((block) => hljs.highlightElement(block));
|
|
350
|
-
|
|
351
|
-
contentEl.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((h) => {
|
|
352
|
-
if (!h.id) {
|
|
353
|
-
h.id = h.textContent
|
|
354
|
-
.toLowerCase()
|
|
355
|
-
.replace(/[^\w\s-]/g, "")
|
|
356
|
-
.trim()
|
|
357
|
-
.replace(/\s+/g, "-");
|
|
358
|
-
}
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
contentEl.querySelectorAll("a[href]").forEach((a) => {
|
|
362
|
-
const href = a.getAttribute("href");
|
|
363
|
-
const m = href && href.match(/[?&]doc=([^&#]+)/);
|
|
364
|
-
if (!m) return;
|
|
365
|
-
const hashIdx = href.indexOf("#");
|
|
366
|
-
const anchor = hashIdx !== -1 ? href.slice(hashIdx + 1) : null;
|
|
367
|
-
a.addEventListener("click", (e) => {
|
|
368
|
-
e.preventDefault();
|
|
369
|
-
openDocument(decodeURIComponent(m[1]), false, true, anchor);
|
|
370
|
-
});
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
contentEl.querySelectorAll('a[href^="#"]').forEach((a) => {
|
|
374
|
-
const href = a.getAttribute("href");
|
|
375
|
-
if (!href || href.length < 2) return;
|
|
376
|
-
a.addEventListener("click", (e) => {
|
|
377
|
-
e.preventDefault();
|
|
378
|
-
scrollToAnchor(href.slice(1));
|
|
379
|
-
});
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
contentEl.querySelectorAll("table").forEach((t) => {
|
|
383
|
-
if (t.parentNode.classList.contains("overflow-x-auto")) return;
|
|
384
|
-
const wrapper = document.createElement("div");
|
|
385
|
-
wrapper.className = "overflow-x-auto";
|
|
386
|
-
t.parentNode.insertBefore(wrapper, t);
|
|
387
|
-
wrapper.appendChild(t);
|
|
388
|
-
});
|
|
371
|
+
_lastDocHtml = doc.html;
|
|
372
|
+
_lastDocIdRendered = currentDocId;
|
|
373
|
+
_wireDocContent(doc.html);
|
|
389
374
|
|
|
390
375
|
applyAnnotationHighlights();
|
|
391
376
|
renderElevator();
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// ── Files modal ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Lists files stored under DOCS_FOLDER/files/, sorted by upload order (the
|
|
3
|
+
// server-assigned filename starts with a YYYYMMDDHHmmss timestamp, so a lex
|
|
4
|
+
// sort is chronological). Supports replacing a file with a new version
|
|
5
|
+
// (overwrites in place, no history kept) and deleting it.
|
|
6
|
+
|
|
7
|
+
const _filesMaxBytes = 19 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function _filesT(key) {
|
|
10
|
+
return window.t ? window.t(key) : key;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function _filesEscape(s) {
|
|
14
|
+
return String(s)
|
|
15
|
+
.replace(/&/g, "&")
|
|
16
|
+
.replace(/</g, "<")
|
|
17
|
+
.replace(/>/g, ">")
|
|
18
|
+
.replace(/"/g, """)
|
|
19
|
+
.replace(/'/g, "'");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function _filesFormatSize(bytes) {
|
|
23
|
+
if (bytes < 1024) {
|
|
24
|
+
return _filesT("files.size_bytes").replace("{n}", String(bytes));
|
|
25
|
+
}
|
|
26
|
+
if (bytes < 1024 * 1024) {
|
|
27
|
+
return _filesT("files.size_kb").replace("{n}", (bytes / 1024).toFixed(1));
|
|
28
|
+
}
|
|
29
|
+
return _filesT("files.size_mb").replace(
|
|
30
|
+
"{n}",
|
|
31
|
+
(bytes / 1024 / 1024).toFixed(2),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _filesFormatDate(iso) {
|
|
36
|
+
if (!iso) return "";
|
|
37
|
+
const d = new Date(iso);
|
|
38
|
+
if (isNaN(d.getTime())) return "";
|
|
39
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
40
|
+
return (
|
|
41
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
|
42
|
+
`${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _filesReadAsBase64(file) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const reader = new FileReader();
|
|
49
|
+
reader.onload = () => resolve(reader.result);
|
|
50
|
+
reader.onerror = reject;
|
|
51
|
+
reader.readAsDataURL(file);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function _filesRender() {
|
|
56
|
+
const body = document.getElementById("files-modal-body");
|
|
57
|
+
if (!body) return;
|
|
58
|
+
body.innerHTML = `<p class="text-sm text-gray-400">${_filesEscape(_filesT("common.loading"))}</p>`;
|
|
59
|
+
|
|
60
|
+
let list = [];
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch("/api/files");
|
|
63
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
64
|
+
const data = await res.json();
|
|
65
|
+
list = Array.isArray(data.files) ? data.files : [];
|
|
66
|
+
} catch (err) {
|
|
67
|
+
body.innerHTML = `<p class="text-sm text-red-500 dark:text-red-400">${_filesEscape(_filesT("files.error_load"))}${_filesEscape(err.message || String(err))}</p>`;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (list.length === 0) {
|
|
72
|
+
body.innerHTML = `<p class="text-sm text-gray-500 dark:text-gray-400">${_filesEscape(_filesT("files.empty"))}</p>`;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rows = list
|
|
77
|
+
.map((f) => {
|
|
78
|
+
const name = _filesEscape(f.displayName || f.filename);
|
|
79
|
+
const date = _filesEscape(_filesFormatDate(f.uploadedAt));
|
|
80
|
+
const size = _filesEscape(_filesFormatSize(f.size || 0));
|
|
81
|
+
const url = _filesEscape(f.url);
|
|
82
|
+
const filenameAttr = _filesEscape(f.filename);
|
|
83
|
+
return `
|
|
84
|
+
<li class="py-3 flex items-center gap-3">
|
|
85
|
+
<i class="fa-solid fa-paperclip text-purple-500 shrink-0"></i>
|
|
86
|
+
<div class="flex-1 min-w-0">
|
|
87
|
+
<a href="${url}" target="_blank" rel="noopener"
|
|
88
|
+
class="block text-sm text-gray-900 dark:text-gray-100 hover:underline truncate">
|
|
89
|
+
${name}
|
|
90
|
+
</a>
|
|
91
|
+
<div class="text-xs text-gray-500 dark:text-gray-400">${date}${date ? " · " : ""}${size}</div>
|
|
92
|
+
</div>
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
data-files-replace="${filenameAttr}"
|
|
96
|
+
data-files-display="${name}"
|
|
97
|
+
class="text-xs px-2 py-1 rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors shrink-0"
|
|
98
|
+
>${_filesEscape(_filesT("files.replace"))}</button>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
data-files-delete="${filenameAttr}"
|
|
102
|
+
data-files-display="${name}"
|
|
103
|
+
class="text-xs px-2 py-1 rounded-lg border border-red-200 dark:border-red-900 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors shrink-0"
|
|
104
|
+
>${_filesEscape(_filesT("files.delete"))}</button>
|
|
105
|
+
</li>`;
|
|
106
|
+
})
|
|
107
|
+
.join("");
|
|
108
|
+
|
|
109
|
+
body.innerHTML = `<ul class="divide-y divide-gray-100 dark:divide-gray-800">${rows}</ul>`;
|
|
110
|
+
|
|
111
|
+
body.querySelectorAll("[data-files-replace]").forEach((btn) => {
|
|
112
|
+
btn.addEventListener("click", () => {
|
|
113
|
+
const filename = btn.getAttribute("data-files-replace");
|
|
114
|
+
const display = btn.getAttribute("data-files-display") || filename;
|
|
115
|
+
_filesReplace(filename, display, btn);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
body.querySelectorAll("[data-files-delete]").forEach((btn) => {
|
|
119
|
+
btn.addEventListener("click", () => {
|
|
120
|
+
const filename = btn.getAttribute("data-files-delete");
|
|
121
|
+
const display = btn.getAttribute("data-files-display") || filename;
|
|
122
|
+
_filesDelete(filename, display, btn);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _filesReplace(filename, displayName, triggerBtn) {
|
|
128
|
+
const input = document.createElement("input");
|
|
129
|
+
input.type = "file";
|
|
130
|
+
input.style.display = "none";
|
|
131
|
+
document.body.appendChild(input);
|
|
132
|
+
input.addEventListener("change", async () => {
|
|
133
|
+
const file = input.files && input.files[0];
|
|
134
|
+
input.remove();
|
|
135
|
+
if (!file) return;
|
|
136
|
+
|
|
137
|
+
const ok = await window.showConfirm({
|
|
138
|
+
title: _filesT("files.confirm_replace_title"),
|
|
139
|
+
message: _filesT("files.confirm_replace_message")
|
|
140
|
+
.replace("{name}", displayName)
|
|
141
|
+
.replace("{newName}", file.name),
|
|
142
|
+
detail: _filesT("files.confirm_replace_detail"),
|
|
143
|
+
confirmLabel: _filesT("files.replace"),
|
|
144
|
+
danger: true,
|
|
145
|
+
});
|
|
146
|
+
if (!ok) return;
|
|
147
|
+
|
|
148
|
+
if (file.size > _filesMaxBytes) {
|
|
149
|
+
const mb = (file.size / 1024 / 1024).toFixed(1);
|
|
150
|
+
window.alert(
|
|
151
|
+
_filesT("files.error_replace") + `${mb} MB (max 19 MB)`,
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const originalLabel = triggerBtn.textContent;
|
|
157
|
+
triggerBtn.disabled = true;
|
|
158
|
+
triggerBtn.textContent = _filesT("files.replacing");
|
|
159
|
+
try {
|
|
160
|
+
const base64 = await _filesReadAsBase64(file);
|
|
161
|
+
const res = await fetch(
|
|
162
|
+
"/api/files/" + encodeURIComponent(filename),
|
|
163
|
+
{
|
|
164
|
+
method: "PUT",
|
|
165
|
+
headers: { "Content-Type": "application/json" },
|
|
166
|
+
body: JSON.stringify({ data: base64 }),
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
let msg = `HTTP ${res.status}`;
|
|
171
|
+
try {
|
|
172
|
+
const body = await res.json();
|
|
173
|
+
if (body.error) msg = body.error;
|
|
174
|
+
} catch { /* ignore */ }
|
|
175
|
+
throw new Error(msg);
|
|
176
|
+
}
|
|
177
|
+
closeFilesModal();
|
|
178
|
+
if (window.runSearchImmediate) {
|
|
179
|
+
window.runSearchImmediate("metadata://" + filename);
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
triggerBtn.disabled = false;
|
|
183
|
+
triggerBtn.textContent = originalLabel;
|
|
184
|
+
window.alert(_filesT("files.error_replace") + (err.message || String(err)));
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
input.click();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function _filesDelete(filename, displayName, triggerBtn) {
|
|
191
|
+
const ok = await window.showConfirm({
|
|
192
|
+
title: _filesT("files.confirm_delete_title"),
|
|
193
|
+
message: _filesT("files.confirm_delete_message").replace(
|
|
194
|
+
"{name}",
|
|
195
|
+
displayName,
|
|
196
|
+
),
|
|
197
|
+
detail: _filesT("files.confirm_delete_detail"),
|
|
198
|
+
confirmLabel: _filesT("files.delete"),
|
|
199
|
+
danger: true,
|
|
200
|
+
});
|
|
201
|
+
if (!ok) return;
|
|
202
|
+
|
|
203
|
+
const originalLabel = triggerBtn.textContent;
|
|
204
|
+
triggerBtn.disabled = true;
|
|
205
|
+
triggerBtn.textContent = _filesT("files.deleting");
|
|
206
|
+
try {
|
|
207
|
+
const res = await fetch("/api/files/" + encodeURIComponent(filename), {
|
|
208
|
+
method: "DELETE",
|
|
209
|
+
});
|
|
210
|
+
if (!res.ok) {
|
|
211
|
+
let msg = `HTTP ${res.status}`;
|
|
212
|
+
try {
|
|
213
|
+
const body = await res.json();
|
|
214
|
+
if (body.error) msg = body.error;
|
|
215
|
+
} catch { /* ignore */ }
|
|
216
|
+
throw new Error(msg);
|
|
217
|
+
}
|
|
218
|
+
closeFilesModal();
|
|
219
|
+
if (window.runSearchImmediate) {
|
|
220
|
+
window.runSearchImmediate("metadata://" + filename);
|
|
221
|
+
}
|
|
222
|
+
} catch (err) {
|
|
223
|
+
triggerBtn.disabled = false;
|
|
224
|
+
triggerBtn.textContent = originalLabel;
|
|
225
|
+
window.alert(_filesT("files.error_delete") + (err.message || String(err)));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function openFilesModal() {
|
|
230
|
+
const modal = document.getElementById("files-modal");
|
|
231
|
+
if (!modal) return;
|
|
232
|
+
modal.classList.remove("hidden");
|
|
233
|
+
_filesRender();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function closeFilesModal() {
|
|
237
|
+
const modal = document.getElementById("files-modal");
|
|
238
|
+
if (!modal) return;
|
|
239
|
+
modal.classList.add("hidden");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
window.openFilesModal = openFilesModal;
|
|
243
|
+
window.closeFilesModal = closeFilesModal;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common.save": "Save",
|
|
3
3
|
"common.cancel": "Cancel",
|
|
4
|
+
"common.confirm": "Confirm",
|
|
4
5
|
"common.reset": "Reset",
|
|
5
6
|
"common.loading": "Loading…",
|
|
6
7
|
"common.remove": "Remove",
|
|
@@ -28,8 +29,31 @@
|
|
|
28
29
|
"nav.new_document": "New document",
|
|
29
30
|
"nav.word_cloud": "☁ Word Cloud",
|
|
30
31
|
"nav.diagram": "◇ Diagram",
|
|
32
|
+
"nav.files": "📁 Metadata Files",
|
|
31
33
|
"nav.admin": "⚙ Admin",
|
|
32
34
|
|
|
35
|
+
"files.title": "📁 Attached files",
|
|
36
|
+
"files.empty": "No files yet — upload one from a document editor (paperclip, drag & drop, or paste).",
|
|
37
|
+
"files.replace": "Replace",
|
|
38
|
+
"files.delete": "Delete",
|
|
39
|
+
"files.open": "Open",
|
|
40
|
+
"files.confirm_replace": "Replace \"{name}\" with \"{newName}\"? The previous version will be lost.",
|
|
41
|
+
"files.confirm_replace_title": "Replace file",
|
|
42
|
+
"files.confirm_replace_message": "Replace \"{name}\" with \"{newName}\"?",
|
|
43
|
+
"files.confirm_replace_detail": "The previous version will be lost.",
|
|
44
|
+
"files.confirm_delete": "Permanently delete \"{name}\"?",
|
|
45
|
+
"files.confirm_delete_title": "Delete file",
|
|
46
|
+
"files.confirm_delete_message": "Permanently delete \"{name}\"?",
|
|
47
|
+
"files.confirm_delete_detail": "This action cannot be undone.",
|
|
48
|
+
"files.replacing": "Replacing…",
|
|
49
|
+
"files.deleting": "Deleting…",
|
|
50
|
+
"files.error_load": "Failed to load files: ",
|
|
51
|
+
"files.error_replace": "Replace failed: ",
|
|
52
|
+
"files.error_delete": "Delete failed: ",
|
|
53
|
+
"files.size_bytes": "{n} B",
|
|
54
|
+
"files.size_kb": "{n} KB",
|
|
55
|
+
"files.size_mb": "{n} MB",
|
|
56
|
+
|
|
33
57
|
"welcome.title": "Select a document",
|
|
34
58
|
"welcome.hint": "Choose a document from the sidebar to start reading.",
|
|
35
59
|
"welcome.pattern_hint": "Expected filename pattern",
|
|
@@ -126,21 +150,21 @@
|
|
|
126
150
|
|
|
127
151
|
"snippet.modal_title": "🧩 Insert a snippet",
|
|
128
152
|
"snippet.type_label": "Snippet type",
|
|
129
|
-
"snippet.diagram": "Diagram",
|
|
130
|
-
"snippet.collapsible": "Collapsible block (details)",
|
|
131
|
-
"snippet.link": "Link",
|
|
132
|
-
"snippet.link_doc": "Link to document",
|
|
133
|
-
"snippet.link_anchor": "Link to anchor",
|
|
134
|
-
"snippet.link_doc_anchor": "Link to document with anchor",
|
|
135
|
-
"snippet.numbered_list": "Numbered list",
|
|
136
|
-
"snippet.bullet_list": "Bullet list",
|
|
137
|
-
"snippet.code_block": "Code block",
|
|
138
|
-
"snippet.blockquote": "Blockquote",
|
|
139
|
-
"snippet.separator": "Horizontal rule",
|
|
140
|
-
"snippet.image": "Image",
|
|
141
|
-
"snippet.table": "Table",
|
|
142
|
-
"snippet.tree": "Tree",
|
|
143
|
-
"snippet.colored_section": "Colored section",
|
|
153
|
+
"snippet.diagram": "◇ Diagram",
|
|
154
|
+
"snippet.collapsible": "▸ Collapsible block (details)",
|
|
155
|
+
"snippet.link": "🔗 Link",
|
|
156
|
+
"snippet.link_doc": "📄 Link to document",
|
|
157
|
+
"snippet.link_anchor": "⚓ Link to anchor",
|
|
158
|
+
"snippet.link_doc_anchor": "📄⚓ Link to document with anchor",
|
|
159
|
+
"snippet.numbered_list": "🔢 Numbered list",
|
|
160
|
+
"snippet.bullet_list": "• Bullet list",
|
|
161
|
+
"snippet.code_block": "💻 Code block",
|
|
162
|
+
"snippet.blockquote": "❝ Blockquote",
|
|
163
|
+
"snippet.separator": "― Horizontal rule",
|
|
164
|
+
"snippet.image": "🖼 Image",
|
|
165
|
+
"snippet.table": "▦ Table",
|
|
166
|
+
"snippet.tree": "🌳 Tree",
|
|
167
|
+
"snippet.colored_section": "🎨 Colored section",
|
|
144
168
|
"snippet.colored_section_color_label": "Color",
|
|
145
169
|
"snippet.colored_section_swatch_info": "Info (blue)",
|
|
146
170
|
"snippet.colored_section_swatch_success": "Success (green)",
|
|
@@ -150,7 +174,7 @@
|
|
|
150
174
|
"snippet.colored_section_swatch_neutral": "Neutral (gray)",
|
|
151
175
|
"snippet.colored_section_content_label": "Content",
|
|
152
176
|
"snippet.colored_section_content_placeholder": "Your text here…",
|
|
153
|
-
"snippet.colored_text": "Colored text",
|
|
177
|
+
"snippet.colored_text": "🖍 Colored text",
|
|
154
178
|
"snippet.colored_text_color_label": "Color",
|
|
155
179
|
"snippet.colored_text_content_label": "Text",
|
|
156
180
|
"snippet.colored_text_content_placeholder": "Your text…",
|
|
@@ -197,6 +221,17 @@
|
|
|
197
221
|
"snippet.saving_btn": "Saving…",
|
|
198
222
|
"snippet.detected_msg": "✓ Detected type: {type}. Fields pre-filled — edit then click Insert to replace selection.",
|
|
199
223
|
"snippet.unknown_type_msg": "⚠ Selected text doesn't match any known snippet type. Choose a type below — the snippet will still replace the selection.",
|
|
224
|
+
"snippet.emojis": "😀 Emojis",
|
|
225
|
+
"snippet.emoji_selected_label": "Selected emojis",
|
|
226
|
+
"snippet.emoji_clear_btn": "Clear",
|
|
227
|
+
"snippet.emoji_cat_smileys": "Smileys & people",
|
|
228
|
+
"snippet.emoji_cat_gestures": "Gestures & body",
|
|
229
|
+
"snippet.emoji_cat_hearts": "Hearts & sparks",
|
|
230
|
+
"snippet.emoji_cat_objects": "Tech & tools",
|
|
231
|
+
"snippet.emoji_cat_office": "Office & docs",
|
|
232
|
+
"snippet.emoji_cat_symbols": "Symbols & arrows",
|
|
233
|
+
"snippet.emoji_search_placeholder": "Search emojis… (e.g. heart, star, rocket)",
|
|
234
|
+
"snippet.emoji_no_results": "No emoji matches this search.",
|
|
200
235
|
"snippet.attachment": "📎 File attachment",
|
|
201
236
|
"snippet.attachment_help": "Click <strong>Insert</strong> to choose a file. It will be uploaded under the <code>files/</code> folder and inserted as a <i class=\"fa-solid fa-paperclip\"></i> link in your document.",
|
|
202
237
|
"snippet.attachment_alt": "Tip: you can also drag & drop a file onto the editor, or paste it from the clipboard.",
|