frontend/js/ structure: state.js (55 lines) — Shared mutable state, constants utils.js (510 lines) — EXT_ICONS, getFileIcon, escapeHtml, safeCreateIcons auth.js (547 lines) — api(), AuthManager, initLoginForm, AdminPanel search.js (1106 lines)— SearchHistory, QueryParser, Autocomplete, performSearch sidebar.js (1091 lines)— Vault tree, sidebar filter, TagFilterService, loadTags viewer.js (1554 lines)— openFile, Outline, ScrollSpy, Frontmatter, Editor ui.js (2250 lines)— Theme, Toast, Sidebar, Dropdowns, Tabs, ContextMenu dashboard.js (461 lines) — Dashboard widgets (Recent, Stats, Bookmarks) config.js (999 lines) — Config panel, Hidden files, About, Sidebar tabs sync.js (436 lines) — SSE/IndexUpdateManager, PWA registration graph.js (401 lines) — GraphViewManager (force-directed canvas graph) legacy.js (550 lines) — Remaining bridge functions (goHome, showWelcome, initSearch) app.js (80 lines) — Thin orchestrator: imports all modules, calls init() index.html: switched from <script src="app.js"> to <script type="module" src="js/app.js"> Original app.js preserved for backward compatibility. All 14 modules pass node --check syntax validation.
1555 lines
52 KiB
JavaScript
1555 lines
52 KiB
JavaScript
/* ObsiGate — Viewer module: Outline, ScrollSpy, ReadingProgress, file viewer, frontmatter card, editor init */
|
||
import * as State from "./state.js";
|
||
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon } from "./utils.js";
|
||
|
||
// initEditor is defined in utils.js — re-exported below.
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Outline/TOC Manager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const OutlineManager = {
|
||
/**
|
||
* Slugify text to create valid IDs
|
||
*/
|
||
slugify(text) {
|
||
return (
|
||
text
|
||
.toLowerCase()
|
||
.normalize("NFD")
|
||
.replace(/[\u0300-\u036f]/g, "")
|
||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||
.replace(/\s+/g, "-")
|
||
.replace(/-+/g, "-")
|
||
.trim() || "heading"
|
||
);
|
||
},
|
||
|
||
/**
|
||
* Parse headings from markdown content
|
||
*/
|
||
parseHeadings() {
|
||
const contentArea = document.querySelector(".md-content");
|
||
if (!contentArea) return [];
|
||
|
||
const headings = [];
|
||
const h2s = contentArea.querySelectorAll("h2");
|
||
const h3s = contentArea.querySelectorAll("h3");
|
||
const allHeadings = [...h2s, ...h3s].sort((a, b) => {
|
||
return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
|
||
});
|
||
|
||
const usedIds = new Map();
|
||
|
||
allHeadings.forEach((heading) => {
|
||
const text = heading.textContent.trim();
|
||
if (!text) return;
|
||
|
||
const level = parseInt(heading.tagName[1]);
|
||
let id = this.slugify(text);
|
||
|
||
// Handle duplicate IDs
|
||
if (usedIds.has(id)) {
|
||
const count = usedIds.get(id) + 1;
|
||
usedIds.set(id, count);
|
||
id = `${id}-${count}`;
|
||
} else {
|
||
usedIds.set(id, 1);
|
||
}
|
||
|
||
// Inject ID into heading if not present
|
||
if (!heading.id) {
|
||
heading.id = id;
|
||
} else {
|
||
id = heading.id;
|
||
}
|
||
|
||
headings.push({
|
||
id,
|
||
level,
|
||
text,
|
||
element: heading,
|
||
});
|
||
});
|
||
|
||
return headings;
|
||
},
|
||
|
||
/**
|
||
* Render outline list
|
||
*/
|
||
renderOutline(headings) {
|
||
const outlineList = document.getElementById("outline-list");
|
||
const outlineEmpty = document.getElementById("outline-empty");
|
||
|
||
if (!outlineList) return;
|
||
|
||
outlineList.innerHTML = "";
|
||
|
||
if (!headings || headings.length === 0) {
|
||
outlineList.hidden = true;
|
||
if (outlineEmpty) {
|
||
outlineEmpty.hidden = false;
|
||
safeCreateIcons();
|
||
}
|
||
return;
|
||
}
|
||
|
||
outlineList.hidden = false;
|
||
if (outlineEmpty) outlineEmpty.hidden = true;
|
||
|
||
headings.forEach((heading) => {
|
||
const item = el(
|
||
"a",
|
||
{
|
||
class: `outline-item level-${heading.level}`,
|
||
href: `#${heading.id}`,
|
||
"data-heading-id": heading.id,
|
||
role: "link",
|
||
},
|
||
[document.createTextNode(heading.text)],
|
||
);
|
||
|
||
item.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
this.scrollToHeading(heading.id);
|
||
});
|
||
|
||
outlineList.appendChild(item);
|
||
});
|
||
|
||
headingsCache = headings;
|
||
},
|
||
|
||
/**
|
||
* Scroll to heading with smooth behavior
|
||
*/
|
||
scrollToHeading(headingId) {
|
||
const heading = document.getElementById(headingId);
|
||
if (!heading) return;
|
||
|
||
const contentArea = document.getElementById("content-area");
|
||
if (!contentArea) return;
|
||
|
||
// Calculate offset for fixed header (if any)
|
||
const headerHeight = 80;
|
||
const headingTop = heading.offsetTop;
|
||
|
||
contentArea.scrollTo({
|
||
top: headingTop - headerHeight,
|
||
behavior: "smooth",
|
||
});
|
||
|
||
// Update active state immediately
|
||
this.setActiveHeading(headingId);
|
||
},
|
||
|
||
/**
|
||
* Set active heading in outline
|
||
*/
|
||
setActiveHeading(headingId) {
|
||
if (activeHeadingId === headingId) return;
|
||
|
||
activeHeadingId = headingId;
|
||
|
||
const items = document.querySelectorAll(".outline-item");
|
||
items.forEach((item) => {
|
||
if (item.getAttribute("data-heading-id") === headingId) {
|
||
item.classList.add("active");
|
||
item.setAttribute("aria-current", "location");
|
||
// Scroll outline item into view
|
||
item.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||
} else {
|
||
item.classList.remove("active");
|
||
item.removeAttribute("aria-current");
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Initialize outline for current document
|
||
*/
|
||
init() {
|
||
const headings = this.parseHeadings();
|
||
this.renderOutline(headings);
|
||
ScrollSpyManager.init(headings);
|
||
ReadingProgressManager.init();
|
||
},
|
||
|
||
/**
|
||
* Cleanup
|
||
*/
|
||
destroy() {
|
||
ScrollSpyManager.destroy();
|
||
ReadingProgressManager.destroy();
|
||
headingsCache = [];
|
||
activeHeadingId = null;
|
||
},
|
||
};
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Scroll Spy Manager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ScrollSpyManager = {
|
||
observer: null,
|
||
headings: [],
|
||
|
||
init(headings) {
|
||
this.destroy();
|
||
this.headings = headings;
|
||
|
||
if (!headings || headings.length === 0) return;
|
||
|
||
const contentArea = document.getElementById("content-area");
|
||
if (!contentArea) return;
|
||
|
||
const options = {
|
||
root: contentArea,
|
||
rootMargin: "-20% 0px -70% 0px",
|
||
threshold: [0, 0.3, 0.5, 1.0],
|
||
};
|
||
|
||
this.observer = new IntersectionObserver((entries) => {
|
||
// Find the most visible heading
|
||
let mostVisible = null;
|
||
let maxRatio = 0;
|
||
|
||
entries.forEach((entry) => {
|
||
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
|
||
maxRatio = entry.intersectionRatio;
|
||
mostVisible = entry.target;
|
||
}
|
||
});
|
||
|
||
if (mostVisible && mostVisible.id) {
|
||
OutlineManager.setActiveHeading(mostVisible.id);
|
||
}
|
||
}, options);
|
||
|
||
// Observe all headings
|
||
headings.forEach((heading) => {
|
||
if (heading.element) {
|
||
this.observer.observe(heading.element);
|
||
}
|
||
});
|
||
},
|
||
|
||
destroy() {
|
||
if (this.observer) {
|
||
this.observer.disconnect();
|
||
this.observer = null;
|
||
}
|
||
this.headings = [];
|
||
},
|
||
};
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reading Progress Manager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ReadingProgressManager = {
|
||
scrollHandler: null,
|
||
|
||
init() {
|
||
this.destroy();
|
||
|
||
const contentArea = document.getElementById("content-area");
|
||
if (!contentArea) return;
|
||
|
||
this.scrollHandler = this.throttle(() => {
|
||
this.updateProgress();
|
||
}, 100);
|
||
|
||
contentArea.addEventListener("scroll", this.scrollHandler);
|
||
this.updateProgress();
|
||
},
|
||
|
||
updateProgress() {
|
||
const contentArea = document.getElementById("content-area");
|
||
const progressFill = document.getElementById("reading-progress-fill");
|
||
const progressText = document.getElementById("reading-progress-text");
|
||
|
||
if (!contentArea || !progressFill || !progressText) return;
|
||
|
||
const scrollTop = contentArea.scrollTop;
|
||
const scrollHeight = contentArea.scrollHeight;
|
||
const clientHeight = contentArea.clientHeight;
|
||
|
||
const maxScroll = scrollHeight - clientHeight;
|
||
const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0;
|
||
|
||
progressFill.style.width = `${percentage}%`;
|
||
progressText.textContent = `${percentage}%`;
|
||
},
|
||
|
||
throttle(func, delay) {
|
||
let lastCall = 0;
|
||
return function (...args) {
|
||
const now = Date.now();
|
||
if (now - lastCall >= delay) {
|
||
lastCall = now;
|
||
func.apply(this, args);
|
||
}
|
||
};
|
||
},
|
||
|
||
destroy() {
|
||
const contentArea = document.getElementById("content-area");
|
||
if (contentArea && this.scrollHandler) {
|
||
contentArea.removeEventListener("scroll", this.scrollHandler);
|
||
}
|
||
this.scrollHandler = null;
|
||
|
||
// Reset progress
|
||
const progressFill = document.getElementById("reading-progress-fill");
|
||
const progressText = document.getElementById("reading-progress-text");
|
||
if (progressFill) progressFill.style.width = "0%";
|
||
if (progressText) progressText.textContent = "0%";
|
||
},
|
||
};
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// File viewer
|
||
// ---------------------------------------------------------------------------
|
||
async function openFile(vaultName, filePath) {
|
||
currentVault = vaultName;
|
||
currentPath = filePath;
|
||
showingSource = false;
|
||
cachedRawSource = null;
|
||
|
||
// Highlight active
|
||
syncActiveFileTreeItem(vaultName, filePath);
|
||
|
||
// Show loading state while fetching
|
||
const area = document.getElementById("content-area");
|
||
area.innerHTML = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
|
||
|
||
try {
|
||
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
|
||
const data = await api(url);
|
||
renderFile(data);
|
||
} catch (err) {
|
||
area.innerHTML = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier.</p></div>';
|
||
}
|
||
}
|
||
|
||
async function renderBacklinksPanel(vault, path, container) {
|
||
try {
|
||
const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`);
|
||
if (!data.backlinks || data.backlinks.length === 0) return;
|
||
|
||
const panel = el("div", { class: "backlinks-panel" });
|
||
const header = el("div", { class: "backlinks-header" }, [
|
||
icon("link", 14),
|
||
document.createTextNode(` ${data.total} lien(s) entrant(s)`),
|
||
]);
|
||
panel.appendChild(header);
|
||
|
||
const list = el("div", { class: "backlinks-list" });
|
||
data.backlinks.forEach((bl) => {
|
||
const item = el("div", { class: "backlink-item" });
|
||
const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]);
|
||
const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]);
|
||
item.appendChild(icon(getFileIcon(bl.path), 12));
|
||
item.appendChild(vaultBadge);
|
||
item.appendChild(titleEl);
|
||
item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path));
|
||
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); });
|
||
list.appendChild(item);
|
||
});
|
||
panel.appendChild(list);
|
||
container.appendChild(panel);
|
||
} catch (err) {
|
||
// Silently ignore — backlinks are optional
|
||
console.debug("Backlinks fetch failed:", err);
|
||
}
|
||
}
|
||
|
||
function renderFile(data) {
|
||
const area = document.getElementById("content-area");
|
||
|
||
// Handle unsupported (binary) files
|
||
if (data.unsupported) {
|
||
const sizeStr = data.size_bytes
|
||
? data.size_bytes < 1024 ? `${data.size_bytes} o`
|
||
: data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko`
|
||
: `${(data.size_bytes / 1048576).toFixed(1)} Mo`
|
||
: "";
|
||
area.innerHTML = `
|
||
<div class="unsupported-file">
|
||
<i data-lucide="file" style="width:48px;height:48px"></i>
|
||
<div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
|
||
<div>Ce fichier est binaire et ne peut pas être affiché.</div>
|
||
${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
|
||
<button class="btn-action" id="unsupported-download-btn">
|
||
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
|
||
</button>
|
||
</div>`;
|
||
lucide.createIcons();
|
||
document.getElementById("unsupported-download-btn").addEventListener("click", () => {
|
||
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
|
||
window.open(dlUrl, "_blank");
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Breadcrumb
|
||
const parts = data.path.split("/");
|
||
const breadcrumbEls = [];
|
||
breadcrumbEls.push(
|
||
makeBreadcrumbSpan(data.vault, () => {
|
||
focusPathInSidebar(data.vault, "", { alignToTop: "center" });
|
||
}),
|
||
);
|
||
let accumulated = "";
|
||
parts.forEach((part, i) => {
|
||
breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")]));
|
||
accumulated += (accumulated ? "/" : "") + part;
|
||
const p = accumulated;
|
||
if (i < parts.length - 1) {
|
||
breadcrumbEls.push(
|
||
makeBreadcrumbSpan(part, () => {
|
||
focusPathInSidebar(data.vault, p, { alignToTop: "center" });
|
||
}),
|
||
);
|
||
} else {
|
||
breadcrumbEls.push(
|
||
makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => {
|
||
focusPathInSidebar(data.vault, data.path, { alignToTop: "center" });
|
||
}),
|
||
);
|
||
}
|
||
});
|
||
|
||
const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls);
|
||
|
||
// Tags
|
||
const tagsDiv = el("div", { class: "file-tags" });
|
||
(data.tags || []).forEach((tag) => {
|
||
if (!TagFilterService.isTagFiltered(tag)) {
|
||
const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
|
||
t.addEventListener("click", () => searchByTag(tag));
|
||
tagsDiv.appendChild(t);
|
||
}
|
||
});
|
||
|
||
// Action buttons
|
||
const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]);
|
||
copyBtn.addEventListener("click", async () => {
|
||
try {
|
||
// Fetch raw content if not already cached
|
||
if (!cachedRawSource) {
|
||
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
||
const rawData = await api(rawUrl);
|
||
cachedRawSource = rawData.raw;
|
||
}
|
||
await navigator.clipboard.writeText(cachedRawSource);
|
||
copyBtn.lastChild.textContent = "Copié !";
|
||
setTimeout(() => (copyBtn.lastChild.textContent = "Copier"), 1500);
|
||
} catch (err) {
|
||
console.error("Copy error:", err);
|
||
showToast("Erreur lors de la copie", "error");
|
||
}
|
||
});
|
||
|
||
const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]);
|
||
|
||
// MD download button
|
||
const mdBtn = el("button", { class: "btn-action", title: "Télécharger en .md" }, [icon("file-text", 14), document.createTextNode(".md")]);
|
||
mdBtn.addEventListener("click", () => {
|
||
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
|
||
const a = document.createElement("a");
|
||
a.href = dlUrl;
|
||
a.download = data.path.split("/").pop();
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
});
|
||
|
||
// PDF download button
|
||
const pdfBtn = el("button", { class: "btn-action", title: "Télécharger en PDF" }, [icon("file", 14), document.createTextNode("PDF")]);
|
||
pdfBtn.addEventListener("click", () => {
|
||
const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`;
|
||
window.open(pdfUrl, "_blank");
|
||
});
|
||
|
||
const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [icon("edit", 14), document.createTextNode("Éditer")]);
|
||
editBtn.addEventListener("click", () => {
|
||
openEditor(data.vault, data.path);
|
||
});
|
||
|
||
const openNewWindowBtn = el("button", { class: "btn-action", title: "Ouvrir dans une nouvelle fenêtre" }, [icon("external-link", 14), document.createTextNode("pop-out")]);
|
||
openNewWindowBtn.addEventListener("click", () => {
|
||
const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}`;
|
||
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");
|
||
});
|
||
|
||
const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]);
|
||
tocBtn.addEventListener("click", () => {
|
||
RightSidebarManager.toggle();
|
||
});
|
||
|
||
// Share button — check if already shared
|
||
const shareBtn = el("button", { class: "btn-action btn-share", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]);
|
||
// Check if already shared and color the button
|
||
(async () => {
|
||
try {
|
||
const shares = await api("/api/shares");
|
||
if (shares.some(s => s.vault === data.vault && s.path === data.path)) {
|
||
shareBtn.classList.add("shared");
|
||
shareBtn.title = "Document partagé — cliquer pour gérer";
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
})();
|
||
shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path));
|
||
|
||
// Bookmark button — check if already bookmarked
|
||
const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: "Ajouter/Retirer des bookmarks" }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]);
|
||
// Check bookmark status and color the button
|
||
(async () => {
|
||
try {
|
||
const bms = await api("/api/bookmarks");
|
||
if (Array.isArray(bms) && bms.some(b => b.vault === data.vault && b.path === data.path)) {
|
||
bookmarkBtn.classList.add("active");
|
||
bookmarkBtn.title = "Retirer des bookmarks";
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
})();
|
||
bookmarkBtn.addEventListener("click", async () => {
|
||
try {
|
||
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 }) });
|
||
bookmarkBtn.classList.toggle("active", res.bookmarked);
|
||
bookmarkBtn.title = res.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks";
|
||
showToast(res.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success");
|
||
if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load();
|
||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||
});
|
||
|
||
// Frontmatter — Accent Card
|
||
let fmSection = null;
|
||
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
|
||
fmSection = buildFrontmatterCard(data.frontmatter);
|
||
}
|
||
|
||
// Content container (rendered HTML)
|
||
const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" });
|
||
mdDiv.innerHTML = data.html;
|
||
|
||
// Raw source container (hidden initially)
|
||
const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" });
|
||
|
||
// Source button toggle logic
|
||
sourceBtn.addEventListener("click", async () => {
|
||
const rendered = document.getElementById("file-rendered-content");
|
||
const raw = document.getElementById("file-raw-content");
|
||
if (!rendered || !raw) return;
|
||
|
||
showingSource = !showingSource;
|
||
if (showingSource) {
|
||
sourceBtn.classList.add("active");
|
||
if (!cachedRawSource) {
|
||
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
||
const rawData = await api(rawUrl);
|
||
cachedRawSource = rawData.raw;
|
||
}
|
||
raw.textContent = cachedRawSource;
|
||
rendered.style.display = "none";
|
||
raw.style.display = "block";
|
||
} else {
|
||
sourceBtn.classList.remove("active");
|
||
rendered.style.display = "block";
|
||
raw.style.display = "none";
|
||
}
|
||
});
|
||
|
||
// Assemble
|
||
area.innerHTML = "";
|
||
area.appendChild(breadcrumb);
|
||
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])]));
|
||
if (fmSection) area.appendChild(fmSection);
|
||
area.appendChild(mdDiv);
|
||
area.appendChild(rawDiv);
|
||
|
||
// Backlinks panel
|
||
if (data.is_markdown) {
|
||
renderBacklinksPanel(data.vault, data.path, area);
|
||
}
|
||
|
||
// Highlight code blocks
|
||
area.querySelectorAll("pre code").forEach((block) => {
|
||
safeHighlight(block);
|
||
});
|
||
|
||
// Wire up wikilinks
|
||
area.querySelectorAll(".wikilink").forEach((link) => {
|
||
link.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
const v = link.getAttribute("data-vault");
|
||
const p = link.getAttribute("data-path");
|
||
if (v && p) openFile(v, p);
|
||
});
|
||
});
|
||
|
||
safeCreateIcons();
|
||
area.scrollTop = 0;
|
||
|
||
// Initialize outline/TOC for this document
|
||
OutlineManager.init();
|
||
}
|
||
|
||
|
||
function buildFrontmatterCard(frontmatter) {
|
||
// Helper: format date
|
||
function formatDate(iso) {
|
||
if (!iso) return "—";
|
||
const d = new Date(iso);
|
||
const date = d.toISOString().slice(0, 10);
|
||
const time = d.toTimeString().slice(0, 5);
|
||
return `${date} · ${time}`;
|
||
}
|
||
|
||
// Extract boolean flags
|
||
const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] }));
|
||
|
||
// Toggle state
|
||
let isOpen = true;
|
||
|
||
// Build header with chevron
|
||
const chevron = el("span", { class: "fm-chevron open" });
|
||
chevron.innerHTML = '<i data-lucide="chevron-down" style="width:14px;height:14px"></i>';
|
||
|
||
const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]);
|
||
|
||
// ZONE 1: Top strip
|
||
const topBadges = [];
|
||
|
||
// Title badge
|
||
const title = frontmatter.titre || frontmatter.title || "";
|
||
if (title) {
|
||
topBadges.push(el("span", { class: "ac-title" }, [document.createTextNode(`"${title}"`)]));
|
||
}
|
||
|
||
// Status badge
|
||
if (frontmatter.statut) {
|
||
const statusBadge = el("span", { class: "ac-badge green" }, [el("span", { class: "ac-dot" }), document.createTextNode(frontmatter.statut)]);
|
||
topBadges.push(statusBadge);
|
||
}
|
||
|
||
// Category badge
|
||
if (frontmatter.catégorie || frontmatter.categorie) {
|
||
const cat = frontmatter.catégorie || frontmatter.categorie;
|
||
const catBadge = el("span", { class: "ac-badge blue" }, [document.createTextNode(cat)]);
|
||
topBadges.push(catBadge);
|
||
}
|
||
|
||
// Publish badge
|
||
if (frontmatter.publish) {
|
||
topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("publié")]));
|
||
}
|
||
|
||
// Favoris badge
|
||
if (frontmatter.favoris) {
|
||
topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("favori")]));
|
||
}
|
||
|
||
const acTop = el("div", { class: "ac-top" }, topBadges);
|
||
|
||
// ZONE 2: Body 2 columns
|
||
const leftCol = el("div", { class: "ac-col" }, [
|
||
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("auteur")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.auteur || "—")])]),
|
||
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 || "—")])]),
|
||
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("statut")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.statut || "—")])]),
|
||
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(", ") : "[]")])]),
|
||
]);
|
||
|
||
const rightCol = el("div", { class: "ac-col" }, [
|
||
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))])]),
|
||
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))])]),
|
||
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("publish")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.publish || false))])]),
|
||
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("favoris")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.favoris || false))])]),
|
||
]);
|
||
|
||
const acBody = el("div", { class: "ac-body" }, [leftCol, rightCol]);
|
||
|
||
// ZONE 3: Tags row
|
||
const tagPills = [];
|
||
if (frontmatter.tags && frontmatter.tags.length > 0) {
|
||
frontmatter.tags.forEach((tag) => {
|
||
tagPills.push(el("span", { class: "ac-tag" }, [document.createTextNode(tag)]));
|
||
});
|
||
}
|
||
|
||
const acTagsRow = el("div", { class: "ac-tags-row" }, [el("span", { class: "ac-tags-k" }, [document.createTextNode("tags")]), el("div", { class: "ac-tags-wrap" }, tagPills)]);
|
||
|
||
// ZONE 4: Flags row
|
||
const flagChips = [];
|
||
booleanFlags.forEach((flag) => {
|
||
const chipClass = flag.value ? "flag-chip on" : "flag-chip off";
|
||
flagChips.push(el("span", { class: chipClass }, [el("span", { class: "flag-dot" }), document.createTextNode(flag.key)]));
|
||
});
|
||
|
||
const acFlagsRow = el("div", { class: "ac-flags-row" }, [el("span", { class: "ac-flags-k" }, [document.createTextNode("flags")]), ...flagChips]);
|
||
|
||
// Assemble the card
|
||
const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]);
|
||
|
||
// Toggle functionality
|
||
fmHeader.addEventListener("click", () => {
|
||
isOpen = !isOpen;
|
||
if (isOpen) {
|
||
acCard.style.display = "block";
|
||
chevron.classList.remove("closed");
|
||
chevron.classList.add("open");
|
||
} else {
|
||
acCard.style.display = "none";
|
||
chevron.classList.remove("open");
|
||
chevron.classList.add("closed");
|
||
}
|
||
safeCreateIcons();
|
||
});
|
||
|
||
// Wrap in section
|
||
const fmSection = el("div", { class: "fm-section" }, [fmHeader, acCard]);
|
||
|
||
return fmSection;
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
// escapeHtml imported from utils.js above
|
||
|
||
function el(tag, attrs, children) {
|
||
const e = document.createElement(tag);
|
||
if (attrs) {
|
||
Object.entries(attrs).forEach(([k, v]) => {
|
||
// Skip boolean false for standard HTML boolean attributes to avoid setAttribute("checked", "false") bug
|
||
if (v === false && (k === "checked" || k === "disabled" || k === "hidden" || k === "required" || k === "readonly")) {
|
||
return;
|
||
}
|
||
e.setAttribute(k, v);
|
||
});
|
||
}
|
||
if (children) {
|
||
children.forEach((c) => {
|
||
if (c) e.appendChild(c);
|
||
});
|
||
}
|
||
return e;
|
||
}
|
||
|
||
function icon(name, size) {
|
||
const i = document.createElement("i");
|
||
i.setAttribute("data-lucide", name);
|
||
i.style.width = size + "px";
|
||
i.style.height = size + "px";
|
||
i.classList.add("icon");
|
||
return i;
|
||
}
|
||
|
||
function smallBadge(count) {
|
||
const s = document.createElement("span");
|
||
s.className = "badge-small";
|
||
s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px";
|
||
s.textContent = `(${count})`;
|
||
return s;
|
||
}
|
||
|
||
function getContextMenuPositionFromElement(target) {
|
||
const rect = target.getBoundingClientRect();
|
||
return {
|
||
x: Math.min(rect.right - 8, window.innerWidth - 16),
|
||
y: Math.min(rect.top + rect.height / 2, window.innerHeight - 16),
|
||
};
|
||
}
|
||
|
||
function attachTreeItemActionButton(itemEl, vault, path, type, isReadonly) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "tree-item-action-btn";
|
||
button.setAttribute("aria-label", "Afficher le menu d’actions");
|
||
button.setAttribute("title", "Actions");
|
||
const iconEl = icon("more-vertical", 16);
|
||
button.appendChild(iconEl);
|
||
button.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const pos = getContextMenuPositionFromElement(button);
|
||
ContextMenuManager.show(pos.x, pos.y, vault, path, type, isReadonly);
|
||
});
|
||
itemEl.appendChild(button);
|
||
// Ensure Lucide icons are rendered for the button
|
||
setTimeout(() => {
|
||
safeCreateIcons();
|
||
}, 0);
|
||
}
|
||
|
||
function attachTreeItemLongPress(itemEl, getMenuData) {
|
||
let pressTimer = null;
|
||
let pressHandled = false;
|
||
let startX = 0;
|
||
let startY = 0;
|
||
const longPressDelay = 550;
|
||
const moveThreshold = 10;
|
||
|
||
const clearPressTimer = () => {
|
||
if (pressTimer) {
|
||
clearTimeout(pressTimer);
|
||
pressTimer = null;
|
||
}
|
||
};
|
||
|
||
itemEl.addEventListener("touchstart", (e) => {
|
||
if (!e.touches || e.touches.length !== 1) return;
|
||
pressHandled = false;
|
||
startX = e.touches[0].clientX;
|
||
startY = e.touches[0].clientY;
|
||
clearPressTimer();
|
||
pressTimer = setTimeout(() => {
|
||
const data = getMenuData();
|
||
if (!data) return;
|
||
pressHandled = true;
|
||
ContextMenuManager.show(startX, startY, data.vault, data.path, data.type, data.isReadonly);
|
||
}, longPressDelay);
|
||
}, { passive: true });
|
||
|
||
itemEl.addEventListener("touchmove", (e) => {
|
||
if (!e.touches || e.touches.length !== 1) return;
|
||
const dx = Math.abs(e.touches[0].clientX - startX);
|
||
const dy = Math.abs(e.touches[0].clientY - startY);
|
||
if (dx > moveThreshold || dy > moveThreshold) {
|
||
clearPressTimer();
|
||
}
|
||
}, { passive: true });
|
||
|
||
itemEl.addEventListener("touchend", () => {
|
||
clearPressTimer();
|
||
}, { passive: true });
|
||
|
||
itemEl.addEventListener("touchcancel", () => {
|
||
clearPressTimer();
|
||
}, { passive: true });
|
||
|
||
itemEl.addEventListener("click", (e) => {
|
||
if (pressHandled) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setTimeout(() => {
|
||
pressHandled = false;
|
||
}, 0);
|
||
}
|
||
}, true);
|
||
}
|
||
|
||
function getVaultIcon(vaultName, size = 16) {
|
||
const v = allVaults.find((val) => val.name === vaultName);
|
||
const type = v ? v.type : "VAULT";
|
||
|
||
if (type === "DIR") {
|
||
const i = icon("folder", size);
|
||
i.style.color = "#eab308"; // yellow tint
|
||
return i;
|
||
} else {
|
||
const purple = "#8b5cf6";
|
||
const svgNS = "http://www.w3.org/2000/svg";
|
||
const svg = document.createElementNS(svgNS, "svg");
|
||
svg.setAttribute("xmlns", svgNS);
|
||
svg.setAttribute("width", size);
|
||
svg.setAttribute("height", size);
|
||
svg.setAttribute("viewBox", "0 0 24 24");
|
||
svg.setAttribute("fill", "none");
|
||
svg.setAttribute("stroke", purple);
|
||
svg.setAttribute("stroke-width", "2");
|
||
svg.setAttribute("stroke-linecap", "round");
|
||
svg.setAttribute("stroke-linejoin", "round");
|
||
svg.classList.add("icon");
|
||
|
||
const path1 = document.createElementNS(svgNS, "path");
|
||
path1.setAttribute("d", "M6 3h12l4 6-10 12L2 9z");
|
||
const path2 = document.createElementNS(svgNS, "path");
|
||
path2.setAttribute("d", "M11 3 8 9l4 12");
|
||
const path3 = document.createElementNS(svgNS, "path");
|
||
path3.setAttribute("d", "M12 21l4-12-3-6");
|
||
const path4 = document.createElementNS(svgNS, "path");
|
||
path4.setAttribute("d", "M2 9h20");
|
||
|
||
svg.appendChild(path1);
|
||
svg.appendChild(path2);
|
||
svg.appendChild(path3);
|
||
svg.appendChild(path4);
|
||
return svg;
|
||
}
|
||
}
|
||
|
||
function makeBreadcrumbSpan(text, onClick) {
|
||
const s = document.createElement("span");
|
||
s.textContent = text;
|
||
if (onClick) {
|
||
s.addEventListener("click", async (event) => {
|
||
event.preventDefault();
|
||
if (s.dataset.busy === "true") return;
|
||
s.dataset.busy = "true";
|
||
s.style.pointerEvents = "none";
|
||
try {
|
||
await onClick(event);
|
||
} finally {
|
||
s.dataset.busy = "false";
|
||
s.style.pointerEvents = "";
|
||
}
|
||
});
|
||
}
|
||
return s;
|
||
}
|
||
|
||
function appendHighlightedText(container, text, query, caseSensitive) {
|
||
container.textContent = "";
|
||
if (!query) {
|
||
container.appendChild(document.createTextNode(text));
|
||
return;
|
||
}
|
||
|
||
const source = caseSensitive ? text : text.toLowerCase();
|
||
const needle = caseSensitive ? query : query.toLowerCase();
|
||
let start = 0;
|
||
let index = source.indexOf(needle, start);
|
||
|
||
if (index === -1) {
|
||
container.appendChild(document.createTextNode(text));
|
||
return;
|
||
}
|
||
|
||
while (index !== -1) {
|
||
if (index > start) {
|
||
container.appendChild(document.createTextNode(text.slice(start, index)));
|
||
}
|
||
const mark = el("mark", { class: "filter-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
|
||
container.appendChild(mark);
|
||
start = index + query.length;
|
||
index = source.indexOf(needle, start);
|
||
}
|
||
|
||
if (start < text.length) {
|
||
container.appendChild(document.createTextNode(text.slice(start)));
|
||
}
|
||
}
|
||
|
||
function highlightSearchText(container, text, query, caseSensitive) {
|
||
container.textContent = "";
|
||
if (!query || !text) {
|
||
container.appendChild(document.createTextNode(text || ""));
|
||
return;
|
||
}
|
||
|
||
const source = caseSensitive ? text : text.toLowerCase();
|
||
const needle = caseSensitive ? query : query.toLowerCase();
|
||
let start = 0;
|
||
let index = source.indexOf(needle, start);
|
||
|
||
if (index === -1) {
|
||
container.appendChild(document.createTextNode(text));
|
||
return;
|
||
}
|
||
|
||
while (index !== -1) {
|
||
if (index > start) {
|
||
container.appendChild(document.createTextNode(text.slice(start, index)));
|
||
}
|
||
const mark = el("mark", { class: "search-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
|
||
container.appendChild(mark);
|
||
start = index + query.length;
|
||
index = source.indexOf(needle, start);
|
||
}
|
||
|
||
if (start < text.length) {
|
||
container.appendChild(document.createTextNode(text.slice(start)));
|
||
}
|
||
}
|
||
|
||
function showWelcome() {
|
||
hideProgressBar();
|
||
|
||
// Restore or rebuild the dashboard with tabbed sections
|
||
const area = document.getElementById("content-area");
|
||
const home = document.getElementById("dashboard-home");
|
||
|
||
if (area && !home) {
|
||
area.innerHTML = `
|
||
<div id="dashboard-home" class="dashboard-home" role="region" aria-label="Tableau de bord">
|
||
<!-- Dashboard Tabs -->
|
||
<div class="dashboard-tabs">
|
||
<button class="dashboard-tab active" data-tab="stats">
|
||
<i data-lucide="bar-chart-3" style="width:14px;height:14px"></i> Statistiques
|
||
</button>
|
||
<button class="dashboard-tab" data-tab="bookmarks">
|
||
<i data-lucide="bookmark" style="width:14px;height:14px"></i> Bookmarks
|
||
</button>
|
||
<button class="dashboard-tab" data-tab="recent">
|
||
<i data-lucide="clock" style="width:14px;height:14px"></i> Récents
|
||
</button>
|
||
<button class="dashboard-tab" data-tab="shared">
|
||
<i data-lucide="share-2" style="width:14px;height:14px"></i> Partagés
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Stats Panel -->
|
||
<div id="dashboard-panel-stats" class="dashboard-panel active">
|
||
<div id="dashboard-stats-grid" class="dashboard-stats-grid">
|
||
<div class="dashboard-stats-loading">Chargement...</div>
|
||
</div>
|
||
<div id="dashboard-conflicts-container" style="margin-top:16px"></div>
|
||
</div>
|
||
|
||
<!-- Bookmarks Panel -->
|
||
<div id="dashboard-panel-bookmarks" class="dashboard-panel">
|
||
<div id="dashboard-bookmarks-grid" class="dashboard-recent-grid"></div>
|
||
<div id="dashboard-bookmarks-empty" class="dashboard-recent-empty">
|
||
<i data-lucide="pin"></i>
|
||
<span>Aucun bookmark</span>
|
||
<p>Épinglez des fichiers pour les retrouver ici.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recent Panel -->
|
||
<div id="dashboard-panel-recent" class="dashboard-panel">
|
||
<div class="dashboard-header">
|
||
<div class="dashboard-title-row">
|
||
<span id="dashboard-count" class="dashboard-badge"></span>
|
||
</div>
|
||
</div>
|
||
<div id="dashboard-recent-grid" class="dashboard-recent-grid"></div>
|
||
<div id="dashboard-loading" class="dashboard-loading">
|
||
<div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
|
||
<div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
|
||
</div>
|
||
<div id="dashboard-recent-empty" class="dashboard-recent-empty hidden">
|
||
<i data-lucide="inbox"></i>
|
||
<span>Aucun fichier récent</span>
|
||
<p>Ouvrez un fichier pour le voir apparaître ici</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Shared Panel -->
|
||
<div id="dashboard-panel-shared" class="dashboard-panel">
|
||
<div id="dashboard-shared-grid" class="dashboard-recent-grid"></div>
|
||
<div id="dashboard-shared-empty" class="dashboard-recent-empty">
|
||
<i data-lucide="share-2"></i>
|
||
<span>Aucun document partagé</span>
|
||
<p>Partagez un document pour le voir apparaître ici</p>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Re-initialize widgets and dashboard tabs
|
||
if (typeof DashboardRecentWidget !== "undefined") {
|
||
DashboardRecentWidget.init();
|
||
}
|
||
initDashboardTabs();
|
||
safeCreateIcons();
|
||
} else if (home) {
|
||
// Dashboard already exists, show it with default tab
|
||
home.style.display = "";
|
||
// Reset tabs to default
|
||
document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
|
||
document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
|
||
const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]');
|
||
const defaultPanel = document.getElementById("dashboard-panel-stats");
|
||
if (defaultTab) defaultTab.classList.add("active");
|
||
if (defaultPanel) defaultPanel.classList.add("active");
|
||
}
|
||
|
||
// Load all widgets (they handle missing elements gracefully)
|
||
if (typeof DashboardStatsWidget !== "undefined") {
|
||
DashboardStatsWidget.load();
|
||
}
|
||
if (typeof DashboardConflictsWidget !== "undefined") {
|
||
DashboardConflictsWidget.load();
|
||
}
|
||
if (typeof DashboardRecentWidget !== "undefined") {
|
||
DashboardRecentWidget.load(selectedContextVault);
|
||
}
|
||
if (typeof DashboardBookmarkWidget !== "undefined") {
|
||
DashboardBookmarkWidget.load(selectedContextVault);
|
||
}
|
||
if (typeof DashboardSharedWidget !== "undefined") {
|
||
DashboardSharedWidget.load();
|
||
}
|
||
|
||
// Load saved searches sidebar
|
||
loadSavedSearches();
|
||
}
|
||
|
||
async function loadSavedSearches() {
|
||
const list = document.getElementById("saved-searches-list");
|
||
const empty = document.getElementById("saved-searches-empty");
|
||
if (!list) return;
|
||
try {
|
||
const searches = await api("/api/saved-searches");
|
||
if (!searches.length) {
|
||
list.innerHTML = "";
|
||
if (empty) empty.style.display = "";
|
||
return;
|
||
}
|
||
if (empty) empty.style.display = "none";
|
||
list.innerHTML = searches.map(s => {
|
||
const badges = [];
|
||
if (s.case_sensitive) badges.push('<span class="search-filter-badge">Aa</span>');
|
||
if (s.whole_word) badges.push('<span class="search-filter-badge">wd</span>');
|
||
if (s.regex) badges.push('<span class="search-filter-badge">.*</span>');
|
||
const pathFilters = [];
|
||
if (s.include_paths) pathFilters.push(`<span class="saved-search-path" title="Inclure: ${escapeHtml(s.include_paths)}">📥 ${escapeHtml(s.include_paths)}</span>`);
|
||
if (s.exclude_paths) pathFilters.push(`<span class="saved-search-path" title="Exclure: ${escapeHtml(s.exclude_paths)}">📤 ${escapeHtml(s.exclude_paths)}</span>`);
|
||
const vaultStr = s.vault && s.vault !== "all" ? `<span class="saved-search-vault">📁 ${escapeHtml(s.vault)}</span>` : "";
|
||
return `
|
||
<div class="saved-search-item">
|
||
<div class="saved-search-query">${escapeHtml(s.query)}</div>
|
||
<div class="saved-search-meta">
|
||
${badges.join("")}
|
||
${vaultStr}
|
||
</div>
|
||
${pathFilters.length ? '<div class="saved-search-filters">' + pathFilters.join(" ") + '</div>' : ""}
|
||
<button class="saved-search-delete" data-id="${s.id}" title="Supprimer">✕</button>
|
||
</div>
|
||
`}).join("");
|
||
list.querySelectorAll(".saved-search-item").forEach(item => {
|
||
item.addEventListener("click", (e) => {
|
||
if (e.target.classList.contains("saved-search-delete")) return;
|
||
const idx = Array.from(list.children).indexOf(item);
|
||
const s = searches[idx];
|
||
if (!s) return;
|
||
// Apply the saved search
|
||
const input = document.getElementById("search-input");
|
||
if (input) input.value = s.query;
|
||
searchCaseSensitive = s.case_sensitive || false;
|
||
searchWholeWord = s.whole_word || false;
|
||
searchRegex = s.regex || false;
|
||
if (typeof _updateToggleUI === "function") _updateToggleUI();
|
||
if (s.include_paths) {
|
||
const incl = document.getElementById("search-include-input");
|
||
if (incl) incl.value = s.include_paths;
|
||
}
|
||
if (s.exclude_paths) {
|
||
const excl = document.getElementById("search-exclude-input");
|
||
if (excl) excl.value = s.exclude_paths;
|
||
}
|
||
// Execute the search — suppress dropdown from appearing
|
||
AutocompleteDropdown.hide();
|
||
AutocompleteDropdown._suppressNext = true;
|
||
const vault = s.vault || "all";
|
||
if (input) { input.dispatchEvent(new Event("input")); }
|
||
clearTimeout(searchTimeout);
|
||
advancedSearchOffset = 0;
|
||
performAdvancedSearch(s.query, vault, null);
|
||
});
|
||
});
|
||
list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" });
|
||
loadSavedSearches();
|
||
}));
|
||
safeCreateIcons();
|
||
} catch (err) { /* silently ignore */ }
|
||
}
|
||
|
||
function showLoading() {
|
||
const area = document.getElementById("content-area");
|
||
area.innerHTML = `
|
||
<div class="loading-indicator">
|
||
<div class="loading-spinner"></div>
|
||
<div>Recherche en cours...</div>
|
||
</div>`;
|
||
showProgressBar();
|
||
}
|
||
|
||
function showProgressBar() {
|
||
const bar = document.getElementById("search-progress-bar");
|
||
if (bar) bar.classList.add("active");
|
||
}
|
||
|
||
function hideProgressBar() {
|
||
const bar = document.getElementById("search-progress-bar");
|
||
if (bar) bar.classList.remove("active");
|
||
}
|
||
|
||
function goHome() {
|
||
const searchInput = document.getElementById("search-input");
|
||
if (searchInput) searchInput.value = "";
|
||
|
||
document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
|
||
|
||
currentVault = null;
|
||
currentPath = null;
|
||
showingSource = false;
|
||
cachedRawSource = null;
|
||
|
||
closeMobileSidebar();
|
||
showWelcome();
|
||
}
|
||
|
||
|
||
// initEditor wires up the editor modal — editor functions (openEditor, closeEditor, saveFile, deleteFile) are in utils.js
|
||
function initEditor() {
|
||
const cancelBtn = document.getElementById("editor-cancel");
|
||
const deleteBtn = document.getElementById("editor-delete");
|
||
const saveBtn = document.getElementById("editor-save");
|
||
const modal = document.getElementById("editor-modal");
|
||
|
||
cancelBtn.addEventListener("click", closeEditor);
|
||
deleteBtn.addEventListener("click", deleteFile);
|
||
saveBtn.addEventListener("click", saveFile);
|
||
|
||
// Close on overlay click
|
||
modal.addEventListener("click", (e) => {
|
||
if (e.target === modal) {
|
||
closeEditor();
|
||
}
|
||
});
|
||
|
||
// ESC to close
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && modal.classList.contains("active")) {
|
||
closeEditor();
|
||
}
|
||
});
|
||
|
||
// Fix mouse wheel scrolling in editor
|
||
modal.addEventListener(
|
||
"wheel",
|
||
(e) => {
|
||
const editorBody = document.getElementById("editor-body");
|
||
if (editorBody && editorBody.contains(e.target)) {
|
||
// Let the editor handle the scroll
|
||
return;
|
||
}
|
||
// Prevent modal from scrolling if not in editor area
|
||
e.preventDefault();
|
||
},
|
||
{ passive: false },
|
||
);
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SSE Client — IndexUpdateManager
|
||
// ---------------------------------------------------------------------------
|
||
const IndexUpdateManager = (() => {
|
||
let eventSource = null;
|
||
let reconnectTimer = null;
|
||
let reconnectDelay = 1000;
|
||
const MAX_RECONNECT_DELAY = 30000;
|
||
let recentEvents = [];
|
||
const MAX_RECENT_EVENTS = 20;
|
||
let connectionState = "disconnected"; // disconnected | connecting | connected
|
||
|
||
function connect() {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
}
|
||
connectionState = "connecting";
|
||
_updateBadge();
|
||
|
||
eventSource = new EventSource("/api/events");
|
||
|
||
eventSource.addEventListener("connected", (e) => {
|
||
connectionState = "connected";
|
||
reconnectDelay = 1000;
|
||
_updateBadge();
|
||
});
|
||
|
||
eventSource.addEventListener("index_updated", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_updated", data);
|
||
_onIndexUpdated(data);
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_reloaded", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_reloaded", data);
|
||
_onIndexReloaded(data);
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("vault_added", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("vault_added", data);
|
||
showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info");
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("vault_removed", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("vault_removed", data);
|
||
showToast(`Vault "${data.vault}" supprimé`, "info");
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_start", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_start", data);
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info");
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_progress", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_progress", data);
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_complete", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_complete", data);
|
||
connectionState = "connected";
|
||
_updateBadge();
|
||
showToast(`Indexation terminée (${data.total_files} fichiers)`, "success");
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.onerror = () => {
|
||
connectionState = "disconnected";
|
||
_updateBadge();
|
||
eventSource.close();
|
||
eventSource = null;
|
||
_scheduleReconnect();
|
||
};
|
||
}
|
||
|
||
function _scheduleReconnect() {
|
||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||
reconnectTimer = setTimeout(() => {
|
||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||
connect();
|
||
}, reconnectDelay);
|
||
}
|
||
|
||
function _addEvent(type, data) {
|
||
recentEvents.unshift({
|
||
type,
|
||
data,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
if (recentEvents.length > MAX_RECENT_EVENTS) {
|
||
recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
|
||
}
|
||
}
|
||
|
||
async function _onIndexUpdated(data) {
|
||
// Brief syncing state
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
|
||
const n = data.total_changes || 0;
|
||
const vaults = (data.vaults || []).join(", ");
|
||
// Toast removed: silent auto-indexing — no notification needed
|
||
|
||
// Refresh sidebar and tags if affected vault matches current context
|
||
const affectsCurrentVault = selectedContextVault === "all" || (data.vaults || []).includes(selectedContextVault);
|
||
if (affectsCurrentVault) {
|
||
try {
|
||
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
|
||
// Refresh current file if it was updated
|
||
if (currentVault && currentPath) {
|
||
const changed = (data.changes || []).some((c) => c.vault === currentVault && c.path === currentPath);
|
||
if (changed) {
|
||
openFile(currentVault, currentPath);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Error refreshing after index update:", err);
|
||
}
|
||
}
|
||
|
||
// Refresh recent tab if it is active
|
||
if (activeSidebarTab === "recent") {
|
||
const vaultFilter = document.getElementById("recent-vault-filter");
|
||
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
|
||
}
|
||
|
||
setTimeout(() => {
|
||
connectionState = "connected";
|
||
_updateBadge();
|
||
}, 1500);
|
||
}
|
||
|
||
async function _onIndexReloaded(data) {
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
showToast("Index complet rechargé", "info");
|
||
try {
|
||
await Promise.all([loadVaults(), loadTags()]);
|
||
} catch (err) {
|
||
console.error("Error refreshing after full reload:", err);
|
||
}
|
||
setTimeout(() => {
|
||
connectionState = "connected";
|
||
_updateBadge();
|
||
}, 1500);
|
||
}
|
||
|
||
function _updateBadge() {
|
||
const badge = document.getElementById("sync-badge");
|
||
if (!badge) return;
|
||
badge.className = "sync-badge sync-badge--" + connectionState;
|
||
const labels = {
|
||
disconnected: "Déconnecté",
|
||
connecting: "Connexion...",
|
||
connected: "Synchronisé",
|
||
syncing: "Mise à jour...",
|
||
};
|
||
badge.title = labels[connectionState] || connectionState;
|
||
}
|
||
|
||
function disconnect() {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
if (reconnectTimer) {
|
||
clearTimeout(reconnectTimer);
|
||
reconnectTimer = null;
|
||
}
|
||
connectionState = "disconnected";
|
||
_updateBadge();
|
||
}
|
||
|
||
function getState() {
|
||
return connectionState;
|
||
}
|
||
|
||
function getRecentEvents() {
|
||
return recentEvents;
|
||
}
|
||
|
||
return { connect, disconnect, getState, getRecentEvents };
|
||
})();
|
||
|
||
function initSyncStatus() {
|
||
const badge = document.getElementById("sync-badge");
|
||
if (!badge) return;
|
||
|
||
badge.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
toggleSyncPanel();
|
||
});
|
||
|
||
IndexUpdateManager.connect();
|
||
}
|
||
|
||
function toggleSyncPanel() {
|
||
let panel = document.getElementById("sync-panel");
|
||
if (panel) {
|
||
panel.remove();
|
||
return;
|
||
}
|
||
// Auto reconnect if disconnected when user opens the panel
|
||
if (IndexUpdateManager.getState() === "disconnected") {
|
||
IndexUpdateManager.connect();
|
||
}
|
||
panel = document.createElement("div");
|
||
panel.id = "sync-panel";
|
||
panel.className = "sync-panel";
|
||
_renderSyncPanel(panel);
|
||
document.body.appendChild(panel);
|
||
|
||
// Close on outside click
|
||
setTimeout(() => {
|
||
document.addEventListener("click", _closeSyncPanelOutside, { once: true });
|
||
}, 0);
|
||
}
|
||
|
||
function _closeSyncPanelOutside(e) {
|
||
const panel = document.getElementById("sync-panel");
|
||
if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
|
||
panel.remove();
|
||
}
|
||
}
|
||
|
||
function _renderSyncPanel(panel) {
|
||
const state = IndexUpdateManager.getState();
|
||
const events = IndexUpdateManager.getRecentEvents();
|
||
|
||
const stateLabels = {
|
||
disconnected: "Déconnecté",
|
||
connecting: "Connexion...",
|
||
connected: "Connecté",
|
||
syncing: "Synchronisation...",
|
||
};
|
||
|
||
let html = `<div class="sync-panel__header">
|
||
<span class="sync-panel__title">Synchronisation</span>
|
||
<span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
|
||
</div>`;
|
||
|
||
if (events.length === 0) {
|
||
html += `<div class="sync-panel__empty">Aucun événement récent</div>`;
|
||
} else {
|
||
html += `<div class="sync-panel__events">`;
|
||
events.slice(0, 10).forEach((ev) => {
|
||
const time = new Date(ev.timestamp).toLocaleTimeString();
|
||
const typeLabels = {
|
||
index_updated: "Mise à jour",
|
||
index_reloaded: "Rechargement",
|
||
vault_added: "Vault ajouté",
|
||
vault_removed: "Vault supprimé",
|
||
index_start: "Démarrage index.",
|
||
index_progress: "Vault indexé",
|
||
index_complete: "Indexation tech.",
|
||
};
|
||
const label = typeLabels[ev.type] || ev.type;
|
||
let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || "";
|
||
if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`;
|
||
if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`;
|
||
if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`;
|
||
html += `<div class="sync-panel__event">
|
||
<span class="sync-panel__event-type">${label}</span>
|
||
<span class="sync-panel__event-detail">${detail}</span>
|
||
<span class="sync-panel__event-time">${time}</span>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
}
|
||
|
||
panel.innerHTML = html;
|
||
}
|
||
|
||
export { OutlineManager, ScrollSpyManager, ReadingProgressManager, openFile, buildFrontmatterCard, initEditor };
|
||
|