ES module imports are read-only live bindings — can't reassign
imported let/const variables. Replace individual 'export let' with
single 'export const state = {...}' mutable object.
All modules updated: import { state } from './state.js'
All state access changed to state.xxx pattern.
Fixes cascade of 'Assignment to constant variable' errors.
1555 lines
62 KiB
JavaScript
1555 lines
62 KiB
JavaScript
1|/* ObsiGate — Viewer module: Outline, ScrollSpy, ReadingProgress, file viewer, frontmatter card, editor init */
|
||
import { state } from './state.js';
|
||
3|import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon } from "./utils.js";
|
||
4|
|
||
5|// initEditor is defined in utils.js — re-exported below.
|
||
6|
|
||
7|// ---------------------------------------------------------------------------
|
||
8|// Outline/TOC Manager
|
||
9|// ---------------------------------------------------------------------------
|
||
10|
|
||
11|const OutlineManager = {
|
||
12| /**
|
||
13| * Slugify text to create valid IDs
|
||
14| */
|
||
15| slugify(text) {
|
||
16| return (
|
||
17| text
|
||
18| .toLowerCase()
|
||
19| .normalize("NFD")
|
||
20| .replace(/[\u0300-\u036f]/g, "")
|
||
21| .replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||
22| .replace(/\s+/g, "-")
|
||
23| .replace(/-+/g, "-")
|
||
24| .trim() || "heading"
|
||
25| );
|
||
26| },
|
||
27|
|
||
28| /**
|
||
29| * Parse headings from markdown content
|
||
30| */
|
||
31| parseHeadings() {
|
||
32| const contentArea = document.querySelector(".md-content");
|
||
33| if (!contentArea) return [];
|
||
34|
|
||
35| const headings = [];
|
||
36| const h2s = contentArea.querySelectorAll("h2");
|
||
37| const h3s = contentArea.querySelectorAll("h3");
|
||
38| const allHeadings = [...h2s, ...h3s].sort((a, b) => {
|
||
39| return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
|
||
40| });
|
||
41|
|
||
42| const usedIds = new Map();
|
||
43|
|
||
44| allHeadings.forEach((heading) => {
|
||
45| const text = heading.textContent.trim();
|
||
46| if (!text) return;
|
||
47|
|
||
48| const level = parseInt(heading.tagName[1]);
|
||
49| let id = this.slugify(text);
|
||
50|
|
||
51| // Handle duplicate IDs
|
||
52| if (usedIds.has(id)) {
|
||
53| const count = usedIds.get(id) + 1;
|
||
54| usedIds.set(id, count);
|
||
55| id = `${id}-${count}`;
|
||
56| } else {
|
||
57| usedIds.set(id, 1);
|
||
58| }
|
||
59|
|
||
60| // Inject ID into heading if not present
|
||
61| if (!heading.id) {
|
||
62| heading.id = id;
|
||
63| } else {
|
||
64| id = heading.id;
|
||
65| }
|
||
66|
|
||
67| headings.push({
|
||
68| id,
|
||
69| level,
|
||
70| text,
|
||
71| element: heading,
|
||
72| });
|
||
73| });
|
||
74|
|
||
75| return headings;
|
||
76| },
|
||
77|
|
||
78| /**
|
||
79| * Render outline list
|
||
80| */
|
||
81| renderOutline(headings) {
|
||
82| const outlineList = document.getElementById("outline-list");
|
||
83| const outlineEmpty = document.getElementById("outline-empty");
|
||
84|
|
||
85| if (!outlineList) return;
|
||
86|
|
||
87| outlineList.innerHTML = "";
|
||
88|
|
||
89| if (!headings || headings.length === 0) {
|
||
90| outlineList.hidden = true;
|
||
91| if (outlineEmpty) {
|
||
92| outlineEmpty.hidden = false;
|
||
93| safeCreateIcons();
|
||
94| }
|
||
95| return;
|
||
96| }
|
||
97|
|
||
98| outlineList.hidden = false;
|
||
99| if (outlineEmpty) outlineEmpty.hidden = true;
|
||
100|
|
||
101| headings.forEach((heading) => {
|
||
102| const item = el(
|
||
103| "a",
|
||
104| {
|
||
105| class: `outline-item level-${heading.level}`,
|
||
106| href: `#${heading.id}`,
|
||
107| "data-heading-id": heading.id,
|
||
108| role: "link",
|
||
109| },
|
||
110| [document.createTextNode(heading.text)],
|
||
111| );
|
||
112|
|
||
113| item.addEventListener("click", (e) => {
|
||
114| e.preventDefault();
|
||
115| this.scrollToHeading(heading.id);
|
||
116| });
|
||
117|
|
||
118| outlineList.appendChild(item);
|
||
119| });
|
||
120|
|
||
121| state.headingsCache = headings;
|
||
122| },
|
||
123|
|
||
124| /**
|
||
125| * Scroll to heading with smooth behavior
|
||
126| */
|
||
127| scrollToHeading(headingId) {
|
||
128| const heading = document.getElementById(headingId);
|
||
129| if (!heading) return;
|
||
130|
|
||
131| const contentArea = document.getElementById("content-area");
|
||
132| if (!contentArea) return;
|
||
133|
|
||
134| // Calculate offset for fixed header (if any)
|
||
135| const headerHeight = 80;
|
||
136| const headingTop = heading.offsetTop;
|
||
137|
|
||
138| contentArea.scrollTo({
|
||
139| top: headingTop - headerHeight,
|
||
140| behavior: "smooth",
|
||
141| });
|
||
142|
|
||
143| // Update active state immediately
|
||
144| this.setActiveHeading(headingId);
|
||
145| },
|
||
146|
|
||
147| /**
|
||
148| * Set active heading in outline
|
||
149| */
|
||
150| setActiveHeading(headingId) {
|
||
151| if (state.activeHeadingId === headingId) return;
|
||
152|
|
||
153| state.activeHeadingId = headingId;
|
||
154|
|
||
155| const items = document.querySelectorAll(".outline-item");
|
||
156| items.forEach((item) => {
|
||
157| if (item.getAttribute("data-heading-id") === headingId) {
|
||
158| item.classList.add("active");
|
||
159| item.setAttribute("aria-current", "location");
|
||
160| // Scroll outline item into view
|
||
161| item.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||
162| } else {
|
||
163| item.classList.remove("active");
|
||
164| item.removeAttribute("aria-current");
|
||
165| }
|
||
166| });
|
||
167| },
|
||
168|
|
||
169| /**
|
||
170| * Initialize outline for current document
|
||
171| */
|
||
172| init() {
|
||
173| const headings = this.parseHeadings();
|
||
174| this.renderOutline(headings);
|
||
175| ScrollSpyManager.init(headings);
|
||
176| ReadingProgressManager.init();
|
||
177| },
|
||
178|
|
||
179| /**
|
||
180| * Cleanup
|
||
181| */
|
||
182| destroy() {
|
||
183| ScrollSpyManager.destroy();
|
||
184| ReadingProgressManager.destroy();
|
||
185| state.headingsCache = [];
|
||
186| state.activeHeadingId = null;
|
||
187| },
|
||
188|};
|
||
189|
|
||
190|
|
||
191|// ---------------------------------------------------------------------------
|
||
192|// Scroll Spy Manager
|
||
193|// ---------------------------------------------------------------------------
|
||
194|
|
||
195|const ScrollSpyManager = {
|
||
196| observer: null,
|
||
197| headings: [],
|
||
198|
|
||
199| init(headings) {
|
||
200| this.destroy();
|
||
201| this.headings = headings;
|
||
202|
|
||
203| if (!headings || headings.length === 0) return;
|
||
204|
|
||
205| const contentArea = document.getElementById("content-area");
|
||
206| if (!contentArea) return;
|
||
207|
|
||
208| const options = {
|
||
209| root: contentArea,
|
||
210| rootMargin: "-20% 0px -70% 0px",
|
||
211| threshold: [0, 0.3, 0.5, 1.0],
|
||
212| };
|
||
213|
|
||
214| this.observer = new IntersectionObserver((entries) => {
|
||
215| // Find the most visible heading
|
||
216| let mostVisible = null;
|
||
217| let maxRatio = 0;
|
||
218|
|
||
219| entries.forEach((entry) => {
|
||
220| if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
|
||
221| maxRatio = entry.intersectionRatio;
|
||
222| mostVisible = entry.target;
|
||
223| }
|
||
224| });
|
||
225|
|
||
226| if (mostVisible && mostVisible.id) {
|
||
227| OutlineManager.setActiveHeading(mostVisible.id);
|
||
228| }
|
||
229| }, options);
|
||
230|
|
||
231| // Observe all headings
|
||
232| headings.forEach((heading) => {
|
||
233| if (heading.element) {
|
||
234| this.observer.observe(heading.element);
|
||
235| }
|
||
236| });
|
||
237| },
|
||
238|
|
||
239| destroy() {
|
||
240| if (this.observer) {
|
||
241| this.observer.disconnect();
|
||
242| this.observer = null;
|
||
243| }
|
||
244| this.headings = [];
|
||
245| },
|
||
246|};
|
||
247|
|
||
248|
|
||
249|// ---------------------------------------------------------------------------
|
||
250|// Reading Progress Manager
|
||
251|// ---------------------------------------------------------------------------
|
||
252|
|
||
253|const ReadingProgressManager = {
|
||
254| scrollHandler: null,
|
||
255|
|
||
256| init() {
|
||
257| this.destroy();
|
||
258|
|
||
259| const contentArea = document.getElementById("content-area");
|
||
260| if (!contentArea) return;
|
||
261|
|
||
262| this.scrollHandler = this.throttle(() => {
|
||
263| this.updateProgress();
|
||
264| }, 100);
|
||
265|
|
||
266| contentArea.addEventListener("scroll", this.scrollHandler);
|
||
267| this.updateProgress();
|
||
268| },
|
||
269|
|
||
270| updateProgress() {
|
||
271| const contentArea = document.getElementById("content-area");
|
||
272| const progressFill = document.getElementById("reading-progress-fill");
|
||
273| const progressText = document.getElementById("reading-progress-text");
|
||
274|
|
||
275| if (!contentArea || !progressFill || !progressText) return;
|
||
276|
|
||
277| const scrollTop = contentArea.scrollTop;
|
||
278| const scrollHeight = contentArea.scrollHeight;
|
||
279| const clientHeight = contentArea.clientHeight;
|
||
280|
|
||
281| const maxScroll = scrollHeight - clientHeight;
|
||
282| const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0;
|
||
283|
|
||
284| progressFill.style.width = `${percentage}%`;
|
||
285| progressText.textContent = `${percentage}%`;
|
||
286| },
|
||
287|
|
||
288| throttle(func, delay) {
|
||
289| let lastCall = 0;
|
||
290| return function (...args) {
|
||
291| const now = Date.now();
|
||
292| if (now - lastCall >= delay) {
|
||
293| lastCall = now;
|
||
294| func.apply(this, args);
|
||
295| }
|
||
296| };
|
||
297| },
|
||
298|
|
||
299| destroy() {
|
||
300| const contentArea = document.getElementById("content-area");
|
||
301| if (contentArea && this.scrollHandler) {
|
||
302| contentArea.removeEventListener("scroll", this.scrollHandler);
|
||
303| }
|
||
304| this.scrollHandler = null;
|
||
305|
|
||
306| // Reset progress
|
||
307| const progressFill = document.getElementById("reading-progress-fill");
|
||
308| const progressText = document.getElementById("reading-progress-text");
|
||
309| if (progressFill) progressFill.style.width = "0%";
|
||
310| if (progressText) progressText.textContent = "0%";
|
||
311| },
|
||
312|};
|
||
313|
|
||
314|
|
||
315|// ---------------------------------------------------------------------------
|
||
316|// File viewer
|
||
317|// ---------------------------------------------------------------------------
|
||
318|async function openFile(vaultName, filePath) {
|
||
319| state.currentVault = vaultName;
|
||
320| state.currentPath = filePath;
|
||
321| state.showingSource = false;
|
||
322| state.cachedRawSource = null;
|
||
323|
|
||
324| // Highlight active
|
||
325| syncActiveFileTreeItem(vaultName, filePath);
|
||
326|
|
||
327| // Show loading state while fetching
|
||
328| const area = document.getElementById("content-area");
|
||
329| area.innerHTML = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
|
||
330|
|
||
331| try {
|
||
332| const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
|
||
333| const data = await api(url);
|
||
334| renderFile(data);
|
||
335| } catch (err) {
|
||
336| area.innerHTML = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier.</p></div>';
|
||
337| }
|
||
338|}
|
||
339|
|
||
340|async function renderBacklinksPanel(vault, path, container) {
|
||
341| try {
|
||
342| const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`);
|
||
343| if (!data.backlinks || data.backlinks.length === 0) return;
|
||
344|
|
||
345| const panel = el("div", { class: "backlinks-panel" });
|
||
346| const header = el("div", { class: "backlinks-header" }, [
|
||
347| icon("link", 14),
|
||
348| document.createTextNode(` ${data.total} lien(s) entrant(s)`),
|
||
349| ]);
|
||
350| panel.appendChild(header);
|
||
351|
|
||
352| const list = el("div", { class: "backlinks-list" });
|
||
353| data.backlinks.forEach((bl) => {
|
||
354| const item = el("div", { class: "backlink-item" });
|
||
355| const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]);
|
||
356| const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]);
|
||
357| item.appendChild(icon(getFileIcon(bl.path), 12));
|
||
358| item.appendChild(vaultBadge);
|
||
359| item.appendChild(titleEl);
|
||
360| item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path));
|
||
361| item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); });
|
||
362| list.appendChild(item);
|
||
363| });
|
||
364| panel.appendChild(list);
|
||
365| container.appendChild(panel);
|
||
366| } catch (err) {
|
||
367| // Silently ignore — backlinks are optional
|
||
368| console.debug("Backlinks fetch failed:", err);
|
||
369| }
|
||
370|}
|
||
371|
|
||
372|function renderFile(data) {
|
||
373| const area = document.getElementById("content-area");
|
||
374|
|
||
375| // Handle unsupported (binary) files
|
||
376| if (data.unsupported) {
|
||
377| const sizeStr = data.size_bytes
|
||
378| ? data.size_bytes < 1024 ? `${data.size_bytes} o`
|
||
379| : data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko`
|
||
380| : `${(data.size_bytes / 1048576).toFixed(1)} Mo`
|
||
381| : "";
|
||
382| area.innerHTML = `
|
||
383| <div class="unsupported-file">
|
||
384| <i data-lucide="file" style="width:48px;height:48px"></i>
|
||
385| <div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
|
||
386| <div>Ce fichier est binaire et ne peut pas être affiché.</div>
|
||
387| ${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
|
||
388| <button class="btn-action" id="unsupported-download-btn">
|
||
389| <i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
|
||
390| </button>
|
||
391| </div>`;
|
||
392| lucide.createIcons();
|
||
393| document.getElementById("unsupported-download-btn").addEventListener("click", () => {
|
||
394| const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
|
||
395| window.open(dlUrl, "_blank");
|
||
396| });
|
||
397| return;
|
||
398| }
|
||
399|
|
||
400| // Breadcrumb
|
||
401| const parts = data.path.split("/");
|
||
402| const breadcrumbEls = [];
|
||
403| breadcrumbEls.push(
|
||
404| makeBreadcrumbSpan(data.vault, () => {
|
||
405| focusPathInSidebar(data.vault, "", { alignToTop: "center" });
|
||
406| }),
|
||
407| );
|
||
408| let accumulated = "";
|
||
409| parts.forEach((part, i) => {
|
||
410| breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")]));
|
||
411| accumulated += (accumulated ? "/" : "") + part;
|
||
412| const p = accumulated;
|
||
413| if (i < parts.length - 1) {
|
||
414| breadcrumbEls.push(
|
||
415| makeBreadcrumbSpan(part, () => {
|
||
416| focusPathInSidebar(data.vault, p, { alignToTop: "center" });
|
||
417| }),
|
||
418| );
|
||
419| } else {
|
||
420| breadcrumbEls.push(
|
||
421| makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => {
|
||
422| focusPathInSidebar(data.vault, data.path, { alignToTop: "center" });
|
||
423| }),
|
||
424| );
|
||
425| }
|
||
426| });
|
||
427|
|
||
428| const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls);
|
||
429|
|
||
430| // Tags
|
||
431| const tagsDiv = el("div", { class: "file-tags" });
|
||
432| (data.tags || []).forEach((tag) => {
|
||
433| if (!TagFilterService.isTagFiltered(tag)) {
|
||
434| const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
|
||
435| t.addEventListener("click", () => searchByTag(tag));
|
||
436| tagsDiv.appendChild(t);
|
||
437| }
|
||
438| });
|
||
439|
|
||
440| // Action buttons
|
||
441| const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]);
|
||
442| copyBtn.addEventListener("click", async () => {
|
||
443| try {
|
||
444| // Fetch raw content if not already cached
|
||
445| if (!state.cachedRawSource) {
|
||
446| const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
||
447| const rawData = await api(rawUrl);
|
||
448| state.cachedRawSource = rawData.raw;
|
||
449| }
|
||
450| await navigator.clipboard.writeText(state.cachedRawSource);
|
||
451| copyBtn.lastChild.textContent = "Copié !";
|
||
452| setTimeout(() => (copyBtn.lastChild.textContent = "Copier"), 1500);
|
||
453| } catch (err) {
|
||
454| console.error("Copy error:", err);
|
||
455| showToast("Erreur lors de la copie", "error");
|
||
456| }
|
||
457| });
|
||
458|
|
||
459| const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]);
|
||
460|
|
||
461| // MD download button
|
||
462| const mdBtn = el("button", { class: "btn-action", title: "Télécharger en .md" }, [icon("file-text", 14), document.createTextNode(".md")]);
|
||
463| mdBtn.addEventListener("click", () => {
|
||
464| const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
|
||
465| const a = document.createElement("a");
|
||
466| a.href = dlUrl;
|
||
467| a.download = data.path.split("/").pop();
|
||
468| document.body.appendChild(a);
|
||
469| a.click();
|
||
470| document.body.removeChild(a);
|
||
471| });
|
||
472|
|
||
473| // PDF download button
|
||
474| const pdfBtn = el("button", { class: "btn-action", title: "Télécharger en PDF" }, [icon("file", 14), document.createTextNode("PDF")]);
|
||
475| pdfBtn.addEventListener("click", () => {
|
||
476| const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`;
|
||
477| window.open(pdfUrl, "_blank");
|
||
478| });
|
||
479|
|
||
480| const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [icon("edit", 14), document.createTextNode("Éditer")]);
|
||
481| editBtn.addEventListener("click", () => {
|
||
482| openEditor(data.vault, data.path);
|
||
483| });
|
||
484|
|
||
485| const openNewWindowBtn = el("button", { class: "btn-action", title: "Ouvrir dans une nouvelle fenêtre" }, [icon("external-link", 14), document.createTextNode("pop-out")]);
|
||
486| openNewWindowBtn.addEventListener("click", () => {
|
||
487| const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}`;
|
||
488| window.open(popoutUrl, `popout_${data.vault}_${data.path.replace(/[^a-zA-Z0-9]/g, "_")}`, "width=1000,height=700,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no");
|
||
489| });
|
||
490|
|
||
491| const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]);
|
||
492| tocBtn.addEventListener("click", () => {
|
||
493| RightSidebarManager.toggle();
|
||
494| });
|
||
495|
|
||
496| // Share button — check if already shared
|
||
497| const shareBtn = el("button", { class: "btn-action btn-share", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]);
|
||
498| // Check if already shared and color the button
|
||
499| (async () => {
|
||
500| try {
|
||
501| const shares = await api("/api/shares");
|
||
502| if (shares.some(s => s.vault === data.vault && s.path === data.path)) {
|
||
503| shareBtn.classList.add("shared");
|
||
504| shareBtn.title = "Document partagé — cliquer pour gérer";
|
||
505| }
|
||
506| } catch (e) { /* ignore */ }
|
||
507| })();
|
||
508| shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path));
|
||
509|
|
||
510| // Bookmark button — check if already bookmarked
|
||
511| const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: "Ajouter/Retirer des bookmarks" }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]);
|
||
512| // Check bookmark status and color the button
|
||
513| (async () => {
|
||
514| try {
|
||
515| const bms = await api("/api/bookmarks");
|
||
516| if (Array.isArray(bms) && bms.some(b => b.vault === data.vault && b.path === data.path)) {
|
||
517| bookmarkBtn.classList.add("active");
|
||
518| bookmarkBtn.title = "Retirer des bookmarks";
|
||
519| }
|
||
520| } catch (e) { /* ignore */ }
|
||
521| })();
|
||
522| bookmarkBtn.addEventListener("click", async () => {
|
||
523| try {
|
||
524| const res = await api("/api/bookmarks/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vault: data.vault, path: data.path, title: data.title }) });
|
||
525| bookmarkBtn.classList.toggle("active", res.bookmarked);
|
||
526| bookmarkBtn.title = res.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks";
|
||
527| showToast(res.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success");
|
||
528| if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load();
|
||
529| } catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||
530| });
|
||
531|
|
||
532| // Frontmatter — Accent Card
|
||
533| let fmSection = null;
|
||
534| if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
|
||
535| fmSection = buildFrontmatterCard(data.frontmatter);
|
||
536| }
|
||
537|
|
||
538| // Content container (rendered HTML)
|
||
539| const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" });
|
||
540| mdDiv.innerHTML = data.html;
|
||
541|
|
||
542| // Raw source container (hidden initially)
|
||
543| const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" });
|
||
544|
|
||
545| // Source button toggle logic
|
||
546| sourceBtn.addEventListener("click", async () => {
|
||
547| const rendered = document.getElementById("file-rendered-content");
|
||
548| const raw = document.getElementById("file-raw-content");
|
||
549| if (!rendered || !raw) return;
|
||
550|
|
||
551| state.showingSource = !state.showingSource;
|
||
552| if (state.showingSource) {
|
||
553| sourceBtn.classList.add("active");
|
||
554| if (!state.cachedRawSource) {
|
||
555| const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
||
556| const rawData = await api(rawUrl);
|
||
557| state.cachedRawSource = rawData.raw;
|
||
558| }
|
||
559| raw.textContent = state.cachedRawSource;
|
||
560| rendered.style.display = "none";
|
||
561| raw.style.display = "block";
|
||
562| } else {
|
||
563| sourceBtn.classList.remove("active");
|
||
564| rendered.style.display = "block";
|
||
565| raw.style.display = "none";
|
||
566| }
|
||
567| });
|
||
568|
|
||
569| // Assemble
|
||
570| area.innerHTML = "";
|
||
571| area.appendChild(breadcrumb);
|
||
572| area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, mdBtn, pdfBtn, editBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])]));
|
||
573| if (fmSection) area.appendChild(fmSection);
|
||
574| area.appendChild(mdDiv);
|
||
575| area.appendChild(rawDiv);
|
||
576|
|
||
577| // Backlinks panel
|
||
578| if (data.is_markdown) {
|
||
579| renderBacklinksPanel(data.vault, data.path, area);
|
||
580| }
|
||
581|
|
||
582| // Highlight code blocks
|
||
583| area.querySelectorAll("pre code").forEach((block) => {
|
||
584| safeHighlight(block);
|
||
585| });
|
||
586|
|
||
587| // Wire up wikilinks
|
||
588| area.querySelectorAll(".wikilink").forEach((link) => {
|
||
589| link.addEventListener("click", (e) => {
|
||
590| e.preventDefault();
|
||
591| const v = link.getAttribute("data-vault");
|
||
592| const p = link.getAttribute("data-path");
|
||
593| if (v && p) openFile(v, p);
|
||
594| });
|
||
595| });
|
||
596|
|
||
597| safeCreateIcons();
|
||
598| area.scrollTop = 0;
|
||
599|
|
||
600| // Initialize outline/TOC for this document
|
||
601| OutlineManager.init();
|
||
602|}
|
||
603|
|
||
604|
|
||
605|function buildFrontmatterCard(frontmatter) {
|
||
606| // Helper: format date
|
||
607| function formatDate(iso) {
|
||
608| if (!iso) return "—";
|
||
609| const d = new Date(iso);
|
||
610| const date = d.toISOString().slice(0, 10);
|
||
611| const time = d.toTimeString().slice(0, 5);
|
||
612| return `${date} · ${time}`;
|
||
613| }
|
||
614|
|
||
615| // Extract boolean flags
|
||
616| const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] }));
|
||
617|
|
||
618| // Toggle state
|
||
619| let isOpen = true;
|
||
620|
|
||
621| // Build header with chevron
|
||
622| const chevron = el("span", { class: "fm-chevron open" });
|
||
623| chevron.innerHTML = '<i data-lucide="chevron-down" style="width:14px;height:14px"></i>';
|
||
624|
|
||
625| const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]);
|
||
626|
|
||
627| // ZONE 1: Top strip
|
||
628| const topBadges = [];
|
||
629|
|
||
630| // Title badge
|
||
631| const title = frontmatter.titre || frontmatter.title || "";
|
||
632| if (title) {
|
||
633| topBadges.push(el("span", { class: "ac-title" }, [document.createTextNode(`"${title}"`)]));
|
||
634| }
|
||
635|
|
||
636| // Status badge
|
||
637| if (frontmatter.statut) {
|
||
638| const statusBadge = el("span", { class: "ac-badge green" }, [el("span", { class: "ac-dot" }), document.createTextNode(frontmatter.statut)]);
|
||
639| topBadges.push(statusBadge);
|
||
640| }
|
||
641|
|
||
642| // Category badge
|
||
643| if (frontmatter.catégorie || frontmatter.categorie) {
|
||
644| const cat = frontmatter.catégorie || frontmatter.categorie;
|
||
645| const catBadge = el("span", { class: "ac-badge blue" }, [document.createTextNode(cat)]);
|
||
646| topBadges.push(catBadge);
|
||
647| }
|
||
648|
|
||
649| // Publish badge
|
||
650| if (frontmatter.publish) {
|
||
651| topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("publié")]));
|
||
652| }
|
||
653|
|
||
654| // Favoris badge
|
||
655| if (frontmatter.favoris) {
|
||
656| topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("favori")]));
|
||
657| }
|
||
658|
|
||
659| const acTop = el("div", { class: "ac-top" }, topBadges);
|
||
660|
|
||
661| // ZONE 2: Body 2 columns
|
||
662| const leftCol = el("div", { class: "ac-col" }, [
|
||
663| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("auteur")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.auteur || "—")])]),
|
||
664| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("catégorie")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.catégorie || frontmatter.categorie || "—")])]),
|
||
665| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("statut")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.statut || "—")])]),
|
||
666| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("aliases")]), el("span", { class: "ac-v muted" }, [document.createTextNode(frontmatter.aliases && frontmatter.aliases.length > 0 ? frontmatter.aliases.join(", ") : "[]")])]),
|
||
667| ]);
|
||
668|
|
||
669| const rightCol = el("div", { class: "ac-col" }, [
|
||
670| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("creation_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.creation_date))])]),
|
||
671| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("modification_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.modification_date))])]),
|
||
672| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("publish")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.publish || false))])]),
|
||
673| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("favoris")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.favoris || false))])]),
|
||
674| ]);
|
||
675|
|
||
676| const acBody = el("div", { class: "ac-body" }, [leftCol, rightCol]);
|
||
677|
|
||
678| // ZONE 3: Tags row
|
||
679| const tagPills = [];
|
||
680| if (frontmatter.tags && frontmatter.tags.length > 0) {
|
||
681| frontmatter.tags.forEach((tag) => {
|
||
682| tagPills.push(el("span", { class: "ac-tag" }, [document.createTextNode(tag)]));
|
||
683| });
|
||
684| }
|
||
685|
|
||
686| const acTagsRow = el("div", { class: "ac-tags-row" }, [el("span", { class: "ac-tags-k" }, [document.createTextNode("tags")]), el("div", { class: "ac-tags-wrap" }, tagPills)]);
|
||
687|
|
||
688| // ZONE 4: Flags row
|
||
689| const flagChips = [];
|
||
690| booleanFlags.forEach((flag) => {
|
||
691| const chipClass = flag.value ? "flag-chip on" : "flag-chip off";
|
||
692| flagChips.push(el("span", { class: chipClass }, [el("span", { class: "flag-dot" }), document.createTextNode(flag.key)]));
|
||
693| });
|
||
694|
|
||
695| const acFlagsRow = el("div", { class: "ac-flags-row" }, [el("span", { class: "ac-flags-k" }, [document.createTextNode("flags")]), ...flagChips]);
|
||
696|
|
||
697| // Assemble the card
|
||
698| const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]);
|
||
699|
|
||
700| // Toggle functionality
|
||
701| fmHeader.addEventListener("click", () => {
|
||
702| isOpen = !isOpen;
|
||
703| if (isOpen) {
|
||
704| acCard.style.display = "block";
|
||
705| chevron.classList.remove("closed");
|
||
706| chevron.classList.add("open");
|
||
707| } else {
|
||
708| acCard.style.display = "none";
|
||
709| chevron.classList.remove("open");
|
||
710| chevron.classList.add("closed");
|
||
711| }
|
||
712| safeCreateIcons();
|
||
713| });
|
||
714|
|
||
715| // Wrap in section
|
||
716| const fmSection = el("div", { class: "fm-section" }, [fmHeader, acCard]);
|
||
717|
|
||
718| return fmSection;
|
||
719|}
|
||
720|
|
||
721|
|
||
722|// ---------------------------------------------------------------------------
|
||
723|// Helpers
|
||
724|// ---------------------------------------------------------------------------
|
||
725|// escapeHtml imported from utils.js above
|
||
726|
|
||
727|function el(tag, attrs, children) {
|
||
728| const e = document.createElement(tag);
|
||
729| if (attrs) {
|
||
730| Object.entries(attrs).forEach(([k, v]) => {
|
||
731| // Skip boolean false for standard HTML boolean attributes to avoid setAttribute("checked", "false") bug
|
||
732| if (v === false && (k === "checked" || k === "disabled" || k === "hidden" || k === "required" || k === "readonly")) {
|
||
733| return;
|
||
734| }
|
||
735| e.setAttribute(k, v);
|
||
736| });
|
||
737| }
|
||
738| if (children) {
|
||
739| children.forEach((c) => {
|
||
740| if (c) e.appendChild(c);
|
||
741| });
|
||
742| }
|
||
743| return e;
|
||
744|}
|
||
745|
|
||
746|function icon(name, size) {
|
||
747| const i = document.createElement("i");
|
||
748| i.setAttribute("data-lucide", name);
|
||
749| i.style.width = size + "px";
|
||
750| i.style.height = size + "px";
|
||
751| i.classList.add("icon");
|
||
752| return i;
|
||
753|}
|
||
754|
|
||
755|function smallBadge(count) {
|
||
756| const s = document.createElement("span");
|
||
757| s.className = "badge-small";
|
||
758| s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px";
|
||
759| s.textContent = `(${count})`;
|
||
760| return s;
|
||
761|}
|
||
762|
|
||
763|function getContextMenuPositionFromElement(target) {
|
||
764| const rect = target.getBoundingClientRect();
|
||
765| return {
|
||
766| x: Math.min(rect.right - 8, window.innerWidth - 16),
|
||
767| y: Math.min(rect.top + rect.height / 2, window.innerHeight - 16),
|
||
768| };
|
||
769|}
|
||
770|
|
||
771|function attachTreeItemActionButton(itemEl, vault, path, type, isReadonly) {
|
||
772| const button = document.createElement("button");
|
||
773| button.type = "button";
|
||
774| button.className = "tree-item-action-btn";
|
||
775| button.setAttribute("aria-label", "Afficher le menu d’actions");
|
||
776| button.setAttribute("title", "Actions");
|
||
777| const iconEl = icon("more-vertical", 16);
|
||
778| button.appendChild(iconEl);
|
||
779| button.addEventListener("click", (e) => {
|
||
780| e.preventDefault();
|
||
781| e.stopPropagation();
|
||
782| const pos = getContextMenuPositionFromElement(button);
|
||
783| ContextMenuManager.show(pos.x, pos.y, vault, path, type, isReadonly);
|
||
784| });
|
||
785| itemEl.appendChild(button);
|
||
786| // Ensure Lucide icons are rendered for the button
|
||
787| setTimeout(() => {
|
||
788| safeCreateIcons();
|
||
789| }, 0);
|
||
790|}
|
||
791|
|
||
792|function attachTreeItemLongPress(itemEl, getMenuData) {
|
||
793| let pressTimer = null;
|
||
794| let pressHandled = false;
|
||
795| let startX = 0;
|
||
796| let startY = 0;
|
||
797| const longPressDelay = 550;
|
||
798| const moveThreshold = 10;
|
||
799|
|
||
800| const clearPressTimer = () => {
|
||
801| if (pressTimer) {
|
||
802| clearTimeout(pressTimer);
|
||
803| pressTimer = null;
|
||
804| }
|
||
805| };
|
||
806|
|
||
807| itemEl.addEventListener("touchstart", (e) => {
|
||
808| if (!e.touches || e.touches.length !== 1) return;
|
||
809| pressHandled = false;
|
||
810| startX = e.touches[0].clientX;
|
||
811| startY = e.touches[0].clientY;
|
||
812| clearPressTimer();
|
||
813| pressTimer = setTimeout(() => {
|
||
814| const data = getMenuData();
|
||
815| if (!data) return;
|
||
816| pressHandled = true;
|
||
817| ContextMenuManager.show(startX, startY, data.vault, data.path, data.type, data.isReadonly);
|
||
818| }, longPressDelay);
|
||
819| }, { passive: true });
|
||
820|
|
||
821| itemEl.addEventListener("touchmove", (e) => {
|
||
822| if (!e.touches || e.touches.length !== 1) return;
|
||
823| const dx = Math.abs(e.touches[0].clientX - startX);
|
||
824| const dy = Math.abs(e.touches[0].clientY - startY);
|
||
825| if (dx > moveThreshold || dy > moveThreshold) {
|
||
826| clearPressTimer();
|
||
827| }
|
||
828| }, { passive: true });
|
||
829|
|
||
830| itemEl.addEventListener("touchend", () => {
|
||
831| clearPressTimer();
|
||
832| }, { passive: true });
|
||
833|
|
||
834| itemEl.addEventListener("touchcancel", () => {
|
||
835| clearPressTimer();
|
||
836| }, { passive: true });
|
||
837|
|
||
838| itemEl.addEventListener("click", (e) => {
|
||
839| if (pressHandled) {
|
||
840| e.preventDefault();
|
||
841| e.stopPropagation();
|
||
842| setTimeout(() => {
|
||
843| pressHandled = false;
|
||
844| }, 0);
|
||
845| }
|
||
846| }, true);
|
||
847|}
|
||
848|
|
||
849|function getVaultIcon(vaultName, size = 16) {
|
||
850| const v = state.allVaults.find((val) => val.name === vaultName);
|
||
851| const type = v ? v.type : "VAULT";
|
||
852|
|
||
853| if (type === "DIR") {
|
||
854| const i = icon("folder", size);
|
||
855| i.style.color = "#eab308"; // yellow tint
|
||
856| return i;
|
||
857| } else {
|
||
858| const purple = "#8b5cf6";
|
||
859| const svgNS = "http://www.w3.org/2000/svg";
|
||
860| const svg = document.createElementNS(svgNS, "svg");
|
||
861| svg.setAttribute("xmlns", svgNS);
|
||
862| svg.setAttribute("width", size);
|
||
863| svg.setAttribute("height", size);
|
||
864| svg.setAttribute("viewBox", "0 0 24 24");
|
||
865| svg.setAttribute("fill", "none");
|
||
866| svg.setAttribute("stroke", purple);
|
||
867| svg.setAttribute("stroke-width", "2");
|
||
868| svg.setAttribute("stroke-linecap", "round");
|
||
869| svg.setAttribute("stroke-linejoin", "round");
|
||
870| svg.classList.add("icon");
|
||
871|
|
||
872| const path1 = document.createElementNS(svgNS, "path");
|
||
873| path1.setAttribute("d", "M6 3h12l4 6-10 12L2 9z");
|
||
874| const path2 = document.createElementNS(svgNS, "path");
|
||
875| path2.setAttribute("d", "M11 3 8 9l4 12");
|
||
876| const path3 = document.createElementNS(svgNS, "path");
|
||
877| path3.setAttribute("d", "M12 21l4-12-3-6");
|
||
878| const path4 = document.createElementNS(svgNS, "path");
|
||
879| path4.setAttribute("d", "M2 9h20");
|
||
880|
|
||
881| svg.appendChild(path1);
|
||
882| svg.appendChild(path2);
|
||
883| svg.appendChild(path3);
|
||
884| svg.appendChild(path4);
|
||
885| return svg;
|
||
886| }
|
||
887|}
|
||
888|
|
||
889|function makeBreadcrumbSpan(text, onClick) {
|
||
890| const s = document.createElement("span");
|
||
891| s.textContent = text;
|
||
892| if (onClick) {
|
||
893| s.addEventListener("click", async (event) => {
|
||
894| event.preventDefault();
|
||
895| if (s.dataset.busy === "true") return;
|
||
896| s.dataset.busy = "true";
|
||
897| s.style.pointerEvents = "none";
|
||
898| try {
|
||
899| await onClick(event);
|
||
900| } finally {
|
||
901| s.dataset.busy = "false";
|
||
902| s.style.pointerEvents = "";
|
||
903| }
|
||
904| });
|
||
905| }
|
||
906| return s;
|
||
907|}
|
||
908|
|
||
909|function appendHighlightedText(container, text, query, caseSensitive) {
|
||
910| container.textContent = "";
|
||
911| if (!query) {
|
||
912| container.appendChild(document.createTextNode(text));
|
||
913| return;
|
||
914| }
|
||
915|
|
||
916| const source = caseSensitive ? text : text.toLowerCase();
|
||
917| const needle = caseSensitive ? query : query.toLowerCase();
|
||
918| let start = 0;
|
||
919| let index = source.indexOf(needle, start);
|
||
920|
|
||
921| if (index === -1) {
|
||
922| container.appendChild(document.createTextNode(text));
|
||
923| return;
|
||
924| }
|
||
925|
|
||
926| while (index !== -1) {
|
||
927| if (index > start) {
|
||
928| container.appendChild(document.createTextNode(text.slice(start, index)));
|
||
929| }
|
||
930| const mark = el("mark", { class: "filter-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
|
||
931| container.appendChild(mark);
|
||
932| start = index + query.length;
|
||
933| index = source.indexOf(needle, start);
|
||
934| }
|
||
935|
|
||
936| if (start < text.length) {
|
||
937| container.appendChild(document.createTextNode(text.slice(start)));
|
||
938| }
|
||
939|}
|
||
940|
|
||
941|function highlightSearchText(container, text, query, caseSensitive) {
|
||
942| container.textContent = "";
|
||
943| if (!query || !text) {
|
||
944| container.appendChild(document.createTextNode(text || ""));
|
||
945| return;
|
||
946| }
|
||
947|
|
||
948| const source = caseSensitive ? text : text.toLowerCase();
|
||
949| const needle = caseSensitive ? query : query.toLowerCase();
|
||
950| let start = 0;
|
||
951| let index = source.indexOf(needle, start);
|
||
952|
|
||
953| if (index === -1) {
|
||
954| container.appendChild(document.createTextNode(text));
|
||
955| return;
|
||
956| }
|
||
957|
|
||
958| while (index !== -1) {
|
||
959| if (index > start) {
|
||
960| container.appendChild(document.createTextNode(text.slice(start, index)));
|
||
961| }
|
||
962| const mark = el("mark", { class: "search-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
|
||
963| container.appendChild(mark);
|
||
964| start = index + query.length;
|
||
965| index = source.indexOf(needle, start);
|
||
966| }
|
||
967|
|
||
968| if (start < text.length) {
|
||
969| container.appendChild(document.createTextNode(text.slice(start)));
|
||
970| }
|
||
971|}
|
||
972|
|
||
973|function showWelcome() {
|
||
974| hideProgressBar();
|
||
975|
|
||
976| // Restore or rebuild the dashboard with tabbed sections
|
||
977| const area = document.getElementById("content-area");
|
||
978| const home = document.getElementById("dashboard-home");
|
||
979|
|
||
980| if (area && !home) {
|
||
981| area.innerHTML = `
|
||
982| <div id="dashboard-home" class="dashboard-home" role="region" aria-label="Tableau de bord">
|
||
983| <!-- Dashboard Tabs -->
|
||
984| <div class="dashboard-tabs">
|
||
985| <button class="dashboard-tab active" data-tab="stats">
|
||
986| <i data-lucide="bar-chart-3" style="width:14px;height:14px"></i> Statistiques
|
||
987| </button>
|
||
988| <button class="dashboard-tab" data-tab="bookmarks">
|
||
989| <i data-lucide="bookmark" style="width:14px;height:14px"></i> Bookmarks
|
||
990| </button>
|
||
991| <button class="dashboard-tab" data-tab="recent">
|
||
992| <i data-lucide="clock" style="width:14px;height:14px"></i> Récents
|
||
993| </button>
|
||
994| <button class="dashboard-tab" data-tab="shared">
|
||
995| <i data-lucide="share-2" style="width:14px;height:14px"></i> Partagés
|
||
996| </button>
|
||
997| </div>
|
||
998|
|
||
999| <!-- Stats Panel -->
|
||
1000| <div id="dashboard-panel-stats" class="dashboard-panel active">
|
||
1001| <div id="dashboard-stats-grid" class="dashboard-stats-grid">
|
||
1002| <div class="dashboard-stats-loading">Chargement...</div>
|
||
1003| </div>
|
||
1004| <div id="dashboard-conflicts-container" style="margin-top:16px"></div>
|
||
1005| </div>
|
||
1006|
|
||
1007| <!-- Bookmarks Panel -->
|
||
1008| <div id="dashboard-panel-bookmarks" class="dashboard-panel">
|
||
1009| <div id="dashboard-bookmarks-grid" class="dashboard-recent-grid"></div>
|
||
1010| <div id="dashboard-bookmarks-empty" class="dashboard-recent-empty">
|
||
1011| <i data-lucide="pin"></i>
|
||
1012| <span>Aucun bookmark</span>
|
||
1013| <p>Épinglez des fichiers pour les retrouver ici.</p>
|
||
1014| </div>
|
||
1015| </div>
|
||
1016|
|
||
1017| <!-- Recent Panel -->
|
||
1018| <div id="dashboard-panel-recent" class="dashboard-panel">
|
||
1019| <div class="dashboard-header">
|
||
1020| <div class="dashboard-title-row">
|
||
1021| <span id="dashboard-count" class="dashboard-badge"></span>
|
||
1022| </div>
|
||
1023| </div>
|
||
1024| <div id="dashboard-recent-grid" class="dashboard-recent-grid"></div>
|
||
1025| <div id="dashboard-loading" class="dashboard-loading">
|
||
1026| <div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
|
||
1027| <div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
|
||
1028| </div>
|
||
1029| <div id="dashboard-recent-empty" class="dashboard-recent-empty hidden">
|
||
1030| <i data-lucide="inbox"></i>
|
||
1031| <span>Aucun fichier récent</span>
|
||
1032| <p>Ouvrez un fichier pour le voir apparaître ici</p>
|
||
1033| </div>
|
||
1034| </div>
|
||
1035|
|
||
1036| <!-- Shared Panel -->
|
||
1037| <div id="dashboard-panel-shared" class="dashboard-panel">
|
||
1038| <div id="dashboard-shared-grid" class="dashboard-recent-grid"></div>
|
||
1039| <div id="dashboard-shared-empty" class="dashboard-recent-empty">
|
||
1040| <i data-lucide="share-2"></i>
|
||
1041| <span>Aucun document partagé</span>
|
||
1042| <p>Partagez un document pour le voir apparaître ici</p>
|
||
1043| </div>
|
||
1044| </div>
|
||
1045| </div>`;
|
||
1046|
|
||
1047| // Re-initialize widgets and dashboard tabs
|
||
1048| if (typeof DashboardRecentWidget !== "undefined") {
|
||
1049| DashboardRecentWidget.init();
|
||
1050| }
|
||
1051| initDashboardTabs();
|
||
1052| safeCreateIcons();
|
||
1053| } else if (home) {
|
||
1054| // Dashboard already exists, show it with default tab
|
||
1055| home.style.display = "";
|
||
1056| // Reset tabs to default
|
||
1057| document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
|
||
1058| document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
|
||
1059| const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]');
|
||
1060| const defaultPanel = document.getElementById("dashboard-panel-stats");
|
||
1061| if (defaultTab) defaultTab.classList.add("active");
|
||
1062| if (defaultPanel) defaultPanel.classList.add("active");
|
||
1063| }
|
||
1064|
|
||
1065| // Load all widgets (they handle missing elements gracefully)
|
||
1066| if (typeof DashboardStatsWidget !== "undefined") {
|
||
1067| DashboardStatsWidget.load();
|
||
1068| }
|
||
1069| if (typeof DashboardConflictsWidget !== "undefined") {
|
||
1070| DashboardConflictsWidget.load();
|
||
1071| }
|
||
1072| if (typeof DashboardRecentWidget !== "undefined") {
|
||
1073| DashboardRecentWidget.load(state.selectedContextVault);
|
||
1074| }
|
||
1075| if (typeof DashboardBookmarkWidget !== "undefined") {
|
||
1076| DashboardBookmarkWidget.load(state.selectedContextVault);
|
||
1077| }
|
||
1078| if (typeof DashboardSharedWidget !== "undefined") {
|
||
1079| DashboardSharedWidget.load();
|
||
1080| }
|
||
1081|
|
||
1082| // Load saved searches sidebar
|
||
1083| loadSavedSearches();
|
||
1084|}
|
||
1085|
|
||
1086|async function loadSavedSearches() {
|
||
1087| const list = document.getElementById("saved-searches-list");
|
||
1088| const empty = document.getElementById("saved-searches-empty");
|
||
1089| if (!list) return;
|
||
1090| try {
|
||
1091| const searches = await api("/api/saved-searches");
|
||
1092| if (!searches.length) {
|
||
1093| list.innerHTML = "";
|
||
1094| if (empty) empty.style.display = "";
|
||
1095| return;
|
||
1096| }
|
||
1097| if (empty) empty.style.display = "none";
|
||
1098| list.innerHTML = searches.map(s => {
|
||
1099| const badges = [];
|
||
1100| if (s.case_sensitive) badges.push('<span class="search-filter-badge">Aa</span>');
|
||
1101| if (s.whole_word) badges.push('<span class="search-filter-badge">wd</span>');
|
||
1102| if (s.regex) badges.push('<span class="search-filter-badge">.*</span>');
|
||
1103| const pathFilters = [];
|
||
1104| if (s.include_paths) pathFilters.push(`<span class="saved-search-path" title="Inclure: ${escapeHtml(s.include_paths)}">📥 ${escapeHtml(s.include_paths)}</span>`);
|
||
1105| if (s.exclude_paths) pathFilters.push(`<span class="saved-search-path" title="Exclure: ${escapeHtml(s.exclude_paths)}">📤 ${escapeHtml(s.exclude_paths)}</span>`);
|
||
1106| const vaultStr = s.vault && s.vault !== "all" ? `<span class="saved-search-vault">📁 ${escapeHtml(s.vault)}</span>` : "";
|
||
1107| return `
|
||
1108| <div class="saved-search-item">
|
||
1109| <div class="saved-search-query">${escapeHtml(s.query)}</div>
|
||
1110| <div class="saved-search-meta">
|
||
1111| ${badges.join("")}
|
||
1112| ${vaultStr}
|
||
1113| </div>
|
||
1114| ${pathFilters.length ? '<div class="saved-search-filters">' + pathFilters.join(" ") + '</div>' : ""}
|
||
1115| <button class="saved-search-delete" data-id="${s.id}" title="Supprimer">✕</button>
|
||
1116| </div>
|
||
1117| `}).join("");
|
||
1118| list.querySelectorAll(".saved-search-item").forEach(item => {
|
||
1119| item.addEventListener("click", (e) => {
|
||
1120| if (e.target.classList.contains("saved-search-delete")) return;
|
||
1121| const idx = Array.from(list.children).indexOf(item);
|
||
1122| const s = searches[idx];
|
||
1123| if (!s) return;
|
||
1124| // Apply the saved search
|
||
1125| const input = document.getElementById("search-input");
|
||
1126| if (input) input.value = s.query;
|
||
1127| state.searchCaseSensitive = s.case_sensitive || false;
|
||
1128| state.searchWholeWord = s.whole_word || false;
|
||
1129| state.searchRegex = s.regex || false;
|
||
1130| if (typeof _updateToggleUI === "function") _updateToggleUI();
|
||
1131| if (s.include_paths) {
|
||
1132| const incl = document.getElementById("search-include-input");
|
||
1133| if (incl) incl.value = s.include_paths;
|
||
1134| }
|
||
1135| if (s.exclude_paths) {
|
||
1136| const excl = document.getElementById("search-exclude-input");
|
||
1137| if (excl) excl.value = s.exclude_paths;
|
||
1138| }
|
||
1139| // Execute the search — suppress dropdown from appearing
|
||
1140| AutocompleteDropdown.hide();
|
||
1141| AutocompleteDropdown._suppressNext = true;
|
||
1142| const vault = s.vault || "all";
|
||
1143| if (input) { input.dispatchEvent(new Event("input")); }
|
||
1144| clearTimeout(state.searchTimeout);
|
||
1145| state.advancedSearchOffset = 0;
|
||
1146| performAdvancedSearch(s.query, vault, null);
|
||
1147| });
|
||
1148| });
|
||
1149| list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => {
|
||
1150| e.stopPropagation();
|
||
1151| await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" });
|
||
1152| loadSavedSearches();
|
||
1153| }));
|
||
1154| safeCreateIcons();
|
||
1155| } catch (err) { /* silently ignore */ }
|
||
1156|}
|
||
1157|
|
||
1158|function showLoading() {
|
||
1159| const area = document.getElementById("content-area");
|
||
1160| area.innerHTML = `
|
||
1161| <div class="loading-indicator">
|
||
1162| <div class="loading-spinner"></div>
|
||
1163| <div>Recherche en cours...</div>
|
||
1164| </div>`;
|
||
1165| showProgressBar();
|
||
1166|}
|
||
1167|
|
||
1168|function showProgressBar() {
|
||
1169| const bar = document.getElementById("search-progress-bar");
|
||
1170| if (bar) bar.classList.add("active");
|
||
1171|}
|
||
1172|
|
||
1173|function hideProgressBar() {
|
||
1174| const bar = document.getElementById("search-progress-bar");
|
||
1175| if (bar) bar.classList.remove("active");
|
||
1176|}
|
||
1177|
|
||
1178|function goHome() {
|
||
1179| const searchInput = document.getElementById("search-input");
|
||
1180| if (searchInput) searchInput.value = "";
|
||
1181|
|
||
1182| document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
|
||
1183|
|
||
1184| state.currentVault = null;
|
||
1185| state.currentPath = null;
|
||
1186| state.showingSource = false;
|
||
1187| state.cachedRawSource = null;
|
||
1188|
|
||
1189| closeMobileSidebar();
|
||
1190| showWelcome();
|
||
1191|}
|
||
1192|
|
||
1193|
|
||
1194|// initEditor wires up the editor modal — editor functions (openEditor, closeEditor, saveFile, deleteFile) are in utils.js
|
||
1195|function initEditor() {
|
||
1196| const cancelBtn = document.getElementById("editor-cancel");
|
||
1197| const deleteBtn = document.getElementById("editor-delete");
|
||
1198| const saveBtn = document.getElementById("editor-save");
|
||
1199| const modal = document.getElementById("editor-modal");
|
||
1200|
|
||
1201| cancelBtn.addEventListener("click", closeEditor);
|
||
1202| deleteBtn.addEventListener("click", deleteFile);
|
||
1203| saveBtn.addEventListener("click", saveFile);
|
||
1204|
|
||
1205| // Close on overlay click
|
||
1206| modal.addEventListener("click", (e) => {
|
||
1207| if (e.target === modal) {
|
||
1208| closeEditor();
|
||
1209| }
|
||
1210| });
|
||
1211|
|
||
1212| // ESC to close
|
||
1213| document.addEventListener("keydown", (e) => {
|
||
1214| if (e.key === "Escape" && modal.classList.contains("active")) {
|
||
1215| closeEditor();
|
||
1216| }
|
||
1217| });
|
||
1218|
|
||
1219| // Fix mouse wheel scrolling in editor
|
||
1220| modal.addEventListener(
|
||
1221| "wheel",
|
||
1222| (e) => {
|
||
1223| const editorBody = document.getElementById("editor-body");
|
||
1224| if (editorBody && editorBody.contains(e.target)) {
|
||
1225| // Let the editor handle the scroll
|
||
1226| return;
|
||
1227| }
|
||
1228| // Prevent modal from scrolling if not in editor area
|
||
1229| e.preventDefault();
|
||
1230| },
|
||
1231| { passive: false },
|
||
1232| );
|
||
1233|}
|
||
1234|
|
||
1235|
|
||
1236|// ---------------------------------------------------------------------------
|
||
1237|// SSE Client — IndexUpdateManager
|
||
1238|// ---------------------------------------------------------------------------
|
||
1239|const IndexUpdateManager = (() => {
|
||
1240| let eventSource = null;
|
||
1241| let reconnectTimer = null;
|
||
1242| let reconnectDelay = 1000;
|
||
1243| const MAX_RECONNECT_DELAY = 30000;
|
||
1244| let recentEvents = [];
|
||
1245| const MAX_RECENT_EVENTS = 20;
|
||
1246| let connectionState = "disconnected"; // disconnected | connecting | connected
|
||
1247|
|
||
1248| function connect() {
|
||
1249| if (eventSource) {
|
||
1250| eventSource.close();
|
||
1251| }
|
||
1252| connectionState = "connecting";
|
||
1253| _updateBadge();
|
||
1254|
|
||
1255| eventSource = new EventSource("/api/events");
|
||
1256|
|
||
1257| eventSource.addEventListener("connected", (e) => {
|
||
1258| connectionState = "connected";
|
||
1259| reconnectDelay = 1000;
|
||
1260| _updateBadge();
|
||
1261| });
|
||
1262|
|
||
1263| eventSource.addEventListener("index_updated", (e) => {
|
||
1264| try {
|
||
1265| const data = JSON.parse(e.data);
|
||
1266| _addEvent("index_updated", data);
|
||
1267| _onIndexUpdated(data);
|
||
1268| } catch (err) {
|
||
1269| console.error("SSE parse error:", err);
|
||
1270| }
|
||
1271| });
|
||
1272|
|
||
1273| eventSource.addEventListener("index_reloaded", (e) => {
|
||
1274| try {
|
||
1275| const data = JSON.parse(e.data);
|
||
1276| _addEvent("index_reloaded", data);
|
||
1277| _onIndexReloaded(data);
|
||
1278| } catch (err) {
|
||
1279| console.error("SSE parse error:", err);
|
||
1280| }
|
||
1281| });
|
||
1282|
|
||
1283| eventSource.addEventListener("vault_added", (e) => {
|
||
1284| try {
|
||
1285| const data = JSON.parse(e.data);
|
||
1286| _addEvent("vault_added", data);
|
||
1287| showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info");
|
||
1288| loadVaults();
|
||
1289| loadTags();
|
||
1290| } catch (err) {
|
||
1291| console.error("SSE parse error:", err);
|
||
1292| }
|
||
1293| });
|
||
1294|
|
||
1295| eventSource.addEventListener("vault_removed", (e) => {
|
||
1296| try {
|
||
1297| const data = JSON.parse(e.data);
|
||
1298| _addEvent("vault_removed", data);
|
||
1299| showToast(`Vault "${data.vault}" supprimé`, "info");
|
||
1300| loadVaults();
|
||
1301| loadTags();
|
||
1302| } catch (err) {
|
||
1303| console.error("SSE parse error:", err);
|
||
1304| }
|
||
1305| });
|
||
1306|
|
||
1307| eventSource.addEventListener("index_start", (e) => {
|
||
1308| try {
|
||
1309| const data = JSON.parse(e.data);
|
||
1310| _addEvent("index_start", data);
|
||
1311| connectionState = "syncing";
|
||
1312| _updateBadge();
|
||
1313| showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info");
|
||
1314| } catch (err) {
|
||
1315| console.error("SSE parse error:", err);
|
||
1316| }
|
||
1317| });
|
||
1318|
|
||
1319| eventSource.addEventListener("index_progress", (e) => {
|
||
1320| try {
|
||
1321| const data = JSON.parse(e.data);
|
||
1322| _addEvent("index_progress", data);
|
||
1323| connectionState = "syncing";
|
||
1324| _updateBadge();
|
||
1325| loadVaults();
|
||
1326| loadTags();
|
||
1327| } catch (err) {
|
||
1328| console.error("SSE parse error:", err);
|
||
1329| }
|
||
1330| });
|
||
1331|
|
||
1332| eventSource.addEventListener("index_complete", (e) => {
|
||
1333| try {
|
||
1334| const data = JSON.parse(e.data);
|
||
1335| _addEvent("index_complete", data);
|
||
1336| connectionState = "connected";
|
||
1337| _updateBadge();
|
||
1338| showToast(`Indexation terminée (${data.total_files} fichiers)`, "success");
|
||
1339| loadVaults();
|
||
1340| loadTags();
|
||
1341| } catch (err) {
|
||
1342| console.error("SSE parse error:", err);
|
||
1343| }
|
||
1344| });
|
||
1345|
|
||
1346| eventSource.onerror = () => {
|
||
1347| connectionState = "disconnected";
|
||
1348| _updateBadge();
|
||
1349| eventSource.close();
|
||
1350| eventSource = null;
|
||
1351| _scheduleReconnect();
|
||
1352| };
|
||
1353| }
|
||
1354|
|
||
1355| function _scheduleReconnect() {
|
||
1356| if (reconnectTimer) clearTimeout(reconnectTimer);
|
||
1357| reconnectTimer = setTimeout(() => {
|
||
1358| reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||
1359| connect();
|
||
1360| }, reconnectDelay);
|
||
1361| }
|
||
1362|
|
||
1363| function _addEvent(type, data) {
|
||
1364| recentEvents.unshift({
|
||
1365| type,
|
||
1366| data,
|
||
1367| timestamp: new Date().toISOString(),
|
||
1368| });
|
||
1369| if (recentEvents.length > MAX_RECENT_EVENTS) {
|
||
1370| recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
|
||
1371| }
|
||
1372| }
|
||
1373|
|
||
1374| async function _onIndexUpdated(data) {
|
||
1375| // Brief syncing state
|
||
1376| connectionState = "syncing";
|
||
1377| _updateBadge();
|
||
1378|
|
||
1379| const n = data.total_changes || 0;
|
||
1380| const vaults = (data.vaults || []).join(", ");
|
||
1381| // Toast removed: silent auto-indexing — no notification needed
|
||
1382|
|
||
1383| // Refresh sidebar and tags if affected vault matches current context
|
||
1384| const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault);
|
||
1385| if (affectsCurrentVault) {
|
||
1386| try {
|
||
1387| await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
|
||
1388| // Refresh current file if it was updated
|
||
1389| if (currentVault && state.currentPath) {
|
||
1390| const changed = (data.changes || []).some((c) => c.vault === currentVault && c.path === state.currentPath);
|
||
1391| if (changed) {
|
||
1392| openFile(state.currentVault, state.currentPath);
|
||
1393| }
|
||
1394| }
|
||
1395| } catch (err) {
|
||
1396| console.error("Error refreshing after index update:", err);
|
||
1397| }
|
||
1398| }
|
||
1399|
|
||
1400| // Refresh recent tab if it is active
|
||
1401| if (state.activeSidebarTab === "recent") {
|
||
1402| const vaultFilter = document.getElementById("recent-vault-filter");
|
||
1403| loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
|
||
1404| }
|
||
1405|
|
||
1406| setTimeout(() => {
|
||
1407| connectionState = "connected";
|
||
1408| _updateBadge();
|
||
1409| }, 1500);
|
||
1410| }
|
||
1411|
|
||
1412| async function _onIndexReloaded(data) {
|
||
1413| connectionState = "syncing";
|
||
1414| _updateBadge();
|
||
1415| showToast("Index complet rechargé", "info");
|
||
1416| try {
|
||
1417| await Promise.all([loadVaults(), loadTags()]);
|
||
1418| } catch (err) {
|
||
1419| console.error("Error refreshing after full reload:", err);
|
||
1420| }
|
||
1421| setTimeout(() => {
|
||
1422| connectionState = "connected";
|
||
1423| _updateBadge();
|
||
1424| }, 1500);
|
||
1425| }
|
||
1426|
|
||
1427| function _updateBadge() {
|
||
1428| const badge = document.getElementById("sync-badge");
|
||
1429| if (!badge) return;
|
||
1430| badge.className = "sync-badge sync-badge--" + connectionState;
|
||
1431| const labels = {
|
||
1432| disconnected: "Déconnecté",
|
||
1433| connecting: "Connexion...",
|
||
1434| connected: "Synchronisé",
|
||
1435| syncing: "Mise à jour...",
|
||
1436| };
|
||
1437| badge.title = labels[connectionState] || connectionState;
|
||
1438| }
|
||
1439|
|
||
1440| function disconnect() {
|
||
1441| if (eventSource) {
|
||
1442| eventSource.close();
|
||
1443| eventSource = null;
|
||
1444| }
|
||
1445| if (reconnectTimer) {
|
||
1446| clearTimeout(reconnectTimer);
|
||
1447| reconnectTimer = null;
|
||
1448| }
|
||
1449| connectionState = "disconnected";
|
||
1450| _updateBadge();
|
||
1451| }
|
||
1452|
|
||
1453| function getState() {
|
||
1454| return connectionState;
|
||
1455| }
|
||
1456|
|
||
1457| function getRecentEvents() {
|
||
1458| return recentEvents;
|
||
1459| }
|
||
1460|
|
||
1461| return { connect, disconnect, getState, getRecentEvents };
|
||
1462|})();
|
||
1463|
|
||
1464|function initSyncStatus() {
|
||
1465| const badge = document.getElementById("sync-badge");
|
||
1466| if (!badge) return;
|
||
1467|
|
||
1468| badge.addEventListener("click", (e) => {
|
||
1469| e.stopPropagation();
|
||
1470| toggleSyncPanel();
|
||
1471| });
|
||
1472|
|
||
1473| IndexUpdateManager.connect();
|
||
1474|}
|
||
1475|
|
||
1476|function toggleSyncPanel() {
|
||
1477| let panel = document.getElementById("sync-panel");
|
||
1478| if (panel) {
|
||
1479| panel.remove();
|
||
1480| return;
|
||
1481| }
|
||
1482| // Auto reconnect if disconnected when user opens the panel
|
||
1483| if (IndexUpdateManager.getState() === "disconnected") {
|
||
1484| IndexUpdateManager.connect();
|
||
1485| }
|
||
1486| panel = document.createElement("div");
|
||
1487| panel.id = "sync-panel";
|
||
1488| panel.className = "sync-panel";
|
||
1489| _renderSyncPanel(panel);
|
||
1490| document.body.appendChild(panel);
|
||
1491|
|
||
1492| // Close on outside click
|
||
1493| setTimeout(() => {
|
||
1494| document.addEventListener("click", _closeSyncPanelOutside, { once: true });
|
||
1495| }, 0);
|
||
1496|}
|
||
1497|
|
||
1498|function _closeSyncPanelOutside(e) {
|
||
1499| const panel = document.getElementById("sync-panel");
|
||
1500| if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
|
||
1501| panel.remove();
|
||
1502| }
|
||
1503|}
|
||
1504|
|
||
1505|function _renderSyncPanel(panel) {
|
||
1506| const state = IndexUpdateManager.getState();
|
||
1507| const events = IndexUpdateManager.getRecentEvents();
|
||
1508|
|
||
1509| const stateLabels = {
|
||
1510| disconnected: "Déconnecté",
|
||
1511| connecting: "Connexion...",
|
||
1512| connected: "Connecté",
|
||
1513| syncing: "Synchronisation...",
|
||
1514| };
|
||
1515|
|
||
1516| let html = `<div class="sync-panel__header">
|
||
1517| <span class="sync-panel__title">Synchronisation</span>
|
||
1518| <span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
|
||
1519| </div>`;
|
||
1520|
|
||
1521| if (events.length === 0) {
|
||
1522| html += `<div class="sync-panel__empty">Aucun événement récent</div>`;
|
||
1523| } else {
|
||
1524| html += `<div class="sync-panel__events">`;
|
||
1525| events.slice(0, 10).forEach((ev) => {
|
||
1526| const time = new Date(ev.timestamp).toLocaleTimeString();
|
||
1527| const typeLabels = {
|
||
1528| index_updated: "Mise à jour",
|
||
1529| index_reloaded: "Rechargement",
|
||
1530| vault_added: "Vault ajouté",
|
||
1531| vault_removed: "Vault supprimé",
|
||
1532| index_start: "Démarrage index.",
|
||
1533| index_progress: "Vault indexé",
|
||
1534| index_complete: "Indexation tech.",
|
||
1535| };
|
||
1536| const label = typeLabels[ev.type] || ev.type;
|
||
1537| let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || "";
|
||
1538| if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`;
|
||
1539| if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`;
|
||
1540| if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`;
|
||
1541| html += `<div class="sync-panel__event">
|
||
1542| <span class="sync-panel__event-type">${label}</span>
|
||
1543| <span class="sync-panel__event-detail">${detail}</span>
|
||
1544| <span class="sync-panel__event-time">${time}</span>
|
||
1545| </div>`;
|
||
1546| });
|
||
1547| html += `</div>`;
|
||
1548| }
|
||
1549|
|
||
1550| panel.innerHTML = html;
|
||
1551|}
|
||
1552|
|
||
1553|export { OutlineManager, ScrollSpyManager, ReadingProgressManager, openFile, buildFrontmatterCard, initEditor };
|
||
1554|
|
||
1555| |