Files
ObsiGate/frontend/js/viewer.js
T
bruno 2ba7138881
CI / lint (push) Successful in 23s
CI / security (push) Successful in 14s
CI / test (push) Successful in 27s
CI / build (push) Successful in 8s
CI / e2e (push) Failing after 1m29s
Desktop Build / build-windows (push) Has been cancelled
Desktop Build / build-linux (push) Has been cancelled
feat: affichage PDF avec TOC + frame-src CSP
- CSP: ajout frame-src 'self' http://127.0.0.1:* (permet iframe PDF)
- pdf_reader.py: extract_pdf_toc() — extrait les bookmarks/outline (pypdf + pymupdf)
- main.py: inclut pdf_toc dans la réponse /api/file pour les PDF
- viewer.js: sidebar TOC avec liens vers les pages (si bookmarks présents)
- style.css: layout .pdf-body flex + .pdf-toc sidebar 240px
2026-07-27 12:32:11 -04:00

1566 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ObsiGate — Viewer module */
import { api } from './auth.js';
import { state } from './state.js';
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard } from './utils.js';
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
import { initDashboardTabs } from './sync.js';
import { renderMermaidBlocks } from './mermaid-viewer.js';
import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js';
import { openShareDialog } from './config.js';
import { cacheViewedFile, getCachedFile } from './offline.js';
import { t } from './i18n.js';
// ---------------------------------------------------------------------------
// 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);
});
state.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 (state.activeHeadingId === headingId) return;
state.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();
state.headingsCache = [];
state.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
// ---------------------------------------------------------------------------
export async function openFile(vaultName, filePath) {
state.currentVault = vaultName;
state.currentPath = filePath;
state.showingSource = false;
state.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);
// Cache the file for offline use
if (data && !data.unsupported && data.content) {
cacheViewedFile(vaultName, filePath, data.content, data.modified);
}
renderFile(data);
} catch (err) {
// Try IndexedDB fallback
console.log('[Viewer] Network failed, trying offline cache:', err);
const cached = await getCachedFile(vaultName, filePath);
if (cached && cached.content) {
const fakeData = {
vault: vaultName,
path: filePath,
title: filePath.split('/').pop().replace(/\.md$/, ''),
content: cached.content,
html: null,
raw_size: cached.content.length,
modified: cached.modified || '',
tags: [],
offline: true
};
renderFile(fakeData);
showToast(t('viewer.loaded_from_cache'), 'info');
} else {
area.innerHTML = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier — vous etes hors-ligne et ce fichier n\'est pas en cache.</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);
}
}
export function renderFile(data) {
const area = document.getElementById("content-area");
// Handle PDF files — render in iframe with TOC sidebar
if (data.is_pdf) {
const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf/stream?path=${encodeURIComponent(data.path)}`;
const pages = data.pdf_metadata && data.pdf_metadata.pages ? data.pdf_metadata.pages : '?';
const toc = data.pdf_toc || [];
let tocHtml = '';
if (toc.length > 0) {
tocHtml = '<div class="pdf-toc"><h3>Table des matières</h3><ul>';
for (const item of toc) {
const indent = (item.level - 1) * 16;
tocHtml += `<li style="padding-left:${indent}px"><a href="#" onclick="document.querySelector('.pdf-iframe').contentWindow.location.hash='page=${item.page}';return false">${escapeHtml(item.title)}</a> <span class="toc-page">p.${item.page}</span></li>`;
}
tocHtml += '</ul></div>';
}
area.innerHTML = `
<div class="pdf-viewer-container">
<div class="pdf-toolbar">
<span class="pdf-info">PDF — ${pages} pages</span>
<button class="btn-action" onclick="window.open('${pdfUrl}', '_blank')">
<i data-lucide="external-link" style="width:14px;height:14px"></i> Plein écran
</button>
<button class="btn-action" onclick="window.open('/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}', '_blank')">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
</button>
</div>
<div class="pdf-body">
${tocHtml}
<iframe src="${pdfUrl}" class="pdf-iframe" title="${escapeHtml(data.title)}"></iframe>
</div>
</div>`;
lucide.createIcons();
return;
}
// 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 (!state.cachedRawSource) {
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
const rawData = await api(rawUrl);
state.cachedRawSource = rawData.raw;
}
if (navigator.clipboard) {
await navigator.clipboard.writeText(state.cachedRawSource);
} else {
// Fallback for non-secure contexts
const ta = document.createElement("textarea");
ta.value = state.cachedRawSource;
ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
copyBtn.lastChild.textContent = t('viewer.copied');
setTimeout(() => (copyBtn.lastChild.textContent = t('viewer.copy')), 1500);
} catch (err) {
console.error("Copy error:", err);
showToast(t("viewer.copy_error"), "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("viewer.download_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("viewer.download_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: "Editer" }, [icon("edit", 14), document.createTextNode("Editer")]);
editBtn.addEventListener("click", () => {
openEditor(data.vault, data.path);
});
const forgeBtn = el("button", { class: "btn-action btn-forge", title: t("viewer.forge_title") }, [icon("hammer", 14), document.createTextNode("Forge")]);
forgeBtn.addEventListener("click", () => {
var modal = document.getElementById("editor-modal");
var container = modal.querySelector(".editor-container");
var bodyEl = document.getElementById("editor-body");
// Set editor state so parent buttons work
state.editorVault = data.vault;
state.editorPath = data.path;
// Update header: show "Forge" brand + filename
var brand = modal.querySelector(".editor-brand");
var titleInp = document.getElementById("editor-title-input");
var fileInp = document.getElementById("editor-filename-input");
if (brand) brand.textContent = t("viewer.forge_brand");
if (titleInp) { titleInp.value = data.title || ''; titleInp.placeholder = t('forge.title_placeholder'); }
if (fileInp) fileInp.value = data.path.split('/').pop();
bodyEl.innerHTML = "";
bodyEl.style.overflow = "hidden";
var iframe = document.createElement("iframe");
iframe.id = "forge-iframe";
iframe.src = "/editor-poc?vault=" + encodeURIComponent(data.vault) + "&path=" + encodeURIComponent(data.path) + "&_ts=" + Date.now();
iframe.style.cssText = "width:100%;height:82vh;border:none;display:block;";
bodyEl.appendChild(iframe);
modal.classList.add("active");
safeCreateIcons();
});
const openNewWindowBtn = el("button", { class: "btn-action", title: t("forge.popout") }, [icon("external-link", 14), document.createTextNode(t("forge.popout_label"))]);
openNewWindowBtn.addEventListener("click", () => {
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}?theme=${theme}`;
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 = t('viewer.shared_doc_title');
}
} 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: t("viewer.bookmark_toggle") }, [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 = t("viewer.bookmark_remove");
}
} 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 ? t("viewer.bookmark_remove") : t("viewer.bookmark_add");
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;
state.showingSource = !state.showingSource;
if (state.showingSource) {
sourceBtn.classList.add("active");
if (!state.cachedRawSource) {
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
const rawData = await api(rawUrl);
state.cachedRawSource = rawData.raw;
}
raw.textContent = state.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, forgeBtn, 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);
});
// Add copy buttons to code blocks
area.querySelectorAll("pre").forEach((pre) => {
if (pre.parentElement?.classList.contains("code-block-wrapper")) return;
const wrapper = el("div", { class: "code-block-wrapper" }, []);
wrapper.style.position = "relative";
pre.replaceWith(wrapper);
wrapper.appendChild(pre);
const btn = el("button", { class: "copy-code-btn", title: "Copier le code" }, [document.createTextNode("Copier")]);
btn.addEventListener("click", (e) => {
e.stopPropagation();
const code = pre.querySelector("code")?.textContent || pre.textContent;
const copied = copyToClipboard(code);
if (copied) {
btn.textContent = "✓ Copié";
btn.classList.add("copied");
if (typeof showToast === "function") showToast("Code copié dans le presse-papiers", "success");
setTimeout(() => { btn.textContent = "Copier"; btn.classList.remove("copied"); }, 2000);
} else {
if (typeof showToast === "function") showToast("Erreur lors de la copie", "error");
}
});
wrapper.appendChild(btn);
});
// Render Mermaid diagrams
renderMermaidBlocks(area);
// 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) TabManager.openPreview(v, p);
});
});
safeCreateIcons();
area.scrollTop = 0;
// Initialize outline/TOC for this document
OutlineManager.init();
}
// ---------------------------------------------------------------------------
// Helpers (escapeHtml imported from utils.js)
// ---------------------------------------------------------------------------
export 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;
}
export 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;
}
export 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),
};
}
export 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);
}
export 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);
}
export function getVaultIcon(vaultName, size = 16) {
const v = state.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;
}
export 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)));
}
}
export 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)));
}
}
export 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" data-i18n="dashboard.stats">
<i data-lucide="bar-chart-3" style="width:14px;height:14px"></i> ${t('dashboard.stats')}
</button>
<button class="dashboard-tab" data-tab="bookmarks" data-i18n="dashboard.bookmarks">
<i data-lucide="bookmark" style="width:14px;height:14px"></i> ${t('dashboard.bookmarks')}
</button>
<button class="dashboard-tab" data-tab="recent" data-i18n="dashboard.tab_recent">
<i data-lucide="clock" style="width:14px;height:14px"></i> ${t('dashboard.tab_recent')}
</button>
<button class="dashboard-tab" data-tab="shared" data-i18n="dashboard.tab_shared">
<i data-lucide="share-2" style="width:14px;height:14px"></i> ${t('dashboard.tab_shared')}
</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" data-i18n="common.loading">${t('common.loading')}</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 data-i18n="dashboard.no_bookmarks">${t('dashboard.no_bookmarks')}</span>
<p data-i18n="dashboard.pin_hint">${t('dashboard.pin_hint')}</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 data-i18n="dashboard.no_recent">${t('dashboard.no_recent')}</span>
<p data-i18n="dashboard.recent_hint">${t('dashboard.recent_hint')}</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 data-i18n="dashboard.no_shares">${t('dashboard.no_shares')}</span>
<p data-i18n="dashboard.shared_hint">${t('dashboard.shared_hint')}</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(state.selectedContextVault);
}
if (typeof DashboardBookmarkWidget !== "undefined") {
DashboardBookmarkWidget.load(state.selectedContextVault);
}
if (typeof DashboardSharedWidget !== "undefined") {
DashboardSharedWidget.load();
}
// Load saved searches sidebar
loadSavedSearches();
}
export 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 => {
// Detect directory-based saved search (empty query + include_paths)
var isDirSearch = (!s.query || s.query.trim() === '') && s.include_paths;
var displayQuery = isDirSearch ? (s.include_paths || 'Dossier') : escapeHtml(s.query);
// Icon: folder-search for directories, search for regular queries
var typeIcon = isDirSearch
? '<i data-lucide="folder-search" style="width:14px;height:14px;color:var(--accent)" class="saved-search-type-icon"></i>'
: '<i data-lucide="search" style="width:14px;height:14px;color:var(--text-muted)" class="saved-search-type-icon"></i>';
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${isDirSearch ? ' saved-search-dir' : ''}" data-type="${isDirSearch ? 'directory' : 'search'}">
<div class="saved-search-query">${displayQuery}</div>
<div class="saved-search-meta">
<span class="saved-search-badges">${badges.join("")} ${vaultStr}</span>
${typeIcon}
</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("");
// Apply saved filter
_applySavedFilter();
// Set up filter bar (once)
_initSavedFilter();
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;
state.searchCaseSensitive = s.case_sensitive || false;
state.searchWholeWord = s.whole_word || false;
state.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(state.searchTimeout);
state.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 */ }
}
export 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();
}
export function showProgressBar() {
const bar = document.getElementById("search-progress-bar");
if (bar) bar.classList.add("active");
}
export 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"));
state.currentVault = null;
state.currentPath = null;
state.showingSource = false;
state.cachedRawSource = null;
closeMobileSidebar();
showWelcome();
}
// ---------------------------------------------------------------------------
// 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 = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault);
if (affectsCurrentVault) {
try {
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
// Refresh current file if it was updated
if (state.currentVault && state.currentPath) {
const changed = (data.changes || []).some((c) => c.vault === state.currentVault && c.path === state.currentPath);
if (changed) {
openFile(state.currentVault, state.currentPath);
}
}
} catch (err) {
console.error("Error refreshing after index update:", err);
}
}
// Refresh recent tab if it is active
if (state.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(t("sync.index_reloaded"), "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: t("viewer.syncing"),
};
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: t("viewer.index_updated"),
index_reloaded: "Rechargement",
vault_added: t("sync.vault_added_label"),
vault_removed: t("sync.vault_removed_label"),
index_start: t("viewer.index_start"),
index_progress: t("sync.vault_indexed_label"),
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>`;
}
}
// ── Saved searches filter ──
var _savedFilterType = 'all';
var _savedFilterInitDone = false;
function _initSavedFilter() {
if (_savedFilterInitDone) return;
var bar = document.getElementById('saved-filter-bar');
if (!bar) return;
bar.querySelectorAll('.saved-filter-pill').forEach(function(btn) {
btn.addEventListener('click', function() {
bar.querySelectorAll('.saved-filter-pill').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
_savedFilterType = btn.dataset.filter;
_applySavedFilter();
});
});
_savedFilterInitDone = true;
}
function _applySavedFilter() {
var items = document.querySelectorAll('.saved-search-item');
var hasVisible = false;
items.forEach(function(item) {
var type = item.dataset.type;
var show = _savedFilterType === 'all' || type === _savedFilterType;
item.style.display = show ? '' : 'none';
if (show) hasVisible = true;
});
// Show/hide empty state
var empty = document.getElementById('saved-searches-empty');
if (empty) empty.style.display = hasVisible ? 'none' : '';
}