2026-03-21 09:52:44 -04:00

445 lines
16 KiB
JavaScript

/* ObsiGate — Vanilla JS SPA */
(function () {
"use strict";
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let currentVault = null;
let currentPath = null;
let searchTimeout = null;
// ---------------------------------------------------------------------------
// Safe CDN helpers
// ---------------------------------------------------------------------------
function safeCreateIcons() {
if (typeof lucide !== "undefined" && lucide.createIcons) {
try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ }
}
}
function safeHighlight(block) {
if (typeof hljs !== "undefined" && hljs.highlightElement) {
try { hljs.highlightElement(block); } catch (e) { /* CDN not loaded */ }
}
}
// ---------------------------------------------------------------------------
// Theme
// ---------------------------------------------------------------------------
function initTheme() {
const saved = localStorage.getItem("obsigate-theme") || "dark";
applyTheme(saved);
}
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("obsigate-theme", theme);
const icon = document.getElementById("theme-icon");
if (icon) {
icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun");
safeCreateIcons();
}
// Swap highlight.js theme
const darkSheet = document.getElementById("hljs-theme-dark");
const lightSheet = document.getElementById("hljs-theme-light");
if (darkSheet && lightSheet) {
darkSheet.disabled = theme !== "dark";
lightSheet.disabled = theme !== "light";
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme");
applyTheme(current === "dark" ? "light" : "dark");
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
async function api(path) {
const res = await fetch(path);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
// ---------------------------------------------------------------------------
// Sidebar — Vault tree
// ---------------------------------------------------------------------------
async function loadVaults() {
const vaults = await api("/api/vaults");
const container = document.getElementById("vault-tree");
const filter = document.getElementById("vault-filter");
container.innerHTML = "";
vaults.forEach((v) => {
// Sidebar tree entry
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [
icon("chevron-right", 14),
icon("database", 16),
document.createTextNode(` ${v.name} `),
smallBadge(v.file_count),
]);
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
container.appendChild(vaultItem);
const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` });
container.appendChild(childContainer);
// Vault filter dropdown
const opt = document.createElement("option");
opt.value = v.name;
opt.textContent = v.name;
filter.appendChild(opt);
});
safeCreateIcons();
}
async function toggleVault(itemEl, vaultName) {
const childContainer = document.getElementById(`vault-children-${vaultName}`);
if (!childContainer) return;
if (childContainer.classList.contains("collapsed")) {
// Expand — load children if empty
if (childContainer.children.length === 0) {
await loadDirectory(vaultName, "", childContainer);
}
childContainer.classList.remove("collapsed");
// Swap chevron
const chevron = itemEl.querySelector("[data-lucide]");
if (chevron) chevron.setAttribute("data-lucide", "chevron-down");
safeCreateIcons();
} else {
childContainer.classList.add("collapsed");
const chevron = itemEl.querySelector("[data-lucide]");
if (chevron) chevron.setAttribute("data-lucide", "chevron-right");
safeCreateIcons();
}
}
async function loadDirectory(vaultName, dirPath, container) {
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
const data = await api(url);
container.innerHTML = "";
data.items.forEach((item) => {
if (item.type === "directory") {
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [
icon("chevron-right", 14),
icon("folder", 16),
document.createTextNode(` ${item.name} `),
smallBadge(item.children_count),
]);
container.appendChild(dirItem);
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
container.appendChild(subContainer);
dirItem.addEventListener("click", async () => {
if (subContainer.classList.contains("collapsed")) {
if (subContainer.children.length === 0) {
await loadDirectory(vaultName, item.path, subContainer);
}
subContainer.classList.remove("collapsed");
const chev = dirItem.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-down");
safeCreateIcons();
} else {
subContainer.classList.add("collapsed");
const chev = dirItem.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-right");
safeCreateIcons();
}
});
} else {
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [
icon("file-text", 16),
document.createTextNode(` ${item.name.replace(/\.md$/i, "")}`),
]);
fileItem.addEventListener("click", () => openFile(vaultName, item.path));
container.appendChild(fileItem);
}
});
safeCreateIcons();
}
// ---------------------------------------------------------------------------
// Tags
// ---------------------------------------------------------------------------
async function loadTags() {
const data = await api("/api/tags");
const cloud = document.getElementById("tag-cloud");
cloud.innerHTML = "";
const tags = data.tags;
const counts = Object.values(tags);
if (counts.length === 0) return;
const maxCount = Math.max(...counts);
const minSize = 0.7;
const maxSize = 1.25;
Object.entries(tags).forEach(([tag, count]) => {
const ratio = maxCount > 1 ? (count - 1) / (maxCount - 1) : 0;
const size = minSize + ratio * (maxSize - minSize);
const tagEl = el("span", { class: "tag-item", style: `font-size:${size}rem` }, [
document.createTextNode(`#${tag}`),
]);
tagEl.addEventListener("click", () => searchByTag(tag));
cloud.appendChild(tagEl);
});
}
function searchByTag(tag) {
const input = document.getElementById("search-input");
input.value = "";
performSearch("", "all", tag);
}
// ---------------------------------------------------------------------------
// File viewer
// ---------------------------------------------------------------------------
async function openFile(vaultName, filePath) {
currentVault = vaultName;
currentPath = filePath;
// Highlight active
document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
const selector = `.tree-item[data-vault="${vaultName}"][data-path="${filePath}"]`;
const active = document.querySelector(selector);
if (active) active.classList.add("active");
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
const data = await api(url);
renderFile(data);
}
function renderFile(data) {
const area = document.getElementById("content-area");
// Breadcrumb
const parts = data.path.split("/");
const breadcrumbEls = [];
breadcrumbEls.push(makeBreadcrumbSpan(data.vault, () => {}));
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, () => {}));
} else {
breadcrumbEls.push(el("span", {}, [document.createTextNode(part.replace(/\.md$/i, ""))]));
}
});
const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls);
// Tags
const tagsDiv = el("div", { class: "file-tags" });
(data.tags || []).forEach((tag) => {
const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
t.addEventListener("click", () => searchByTag(tag));
tagsDiv.appendChild(t);
});
// Copy path button
const copyBtn = el("button", { class: "btn-copy-path" }, [document.createTextNode("Copier le chemin")]);
copyBtn.addEventListener("click", () => {
navigator.clipboard.writeText(`${data.vault}/${data.path}`).then(() => {
copyBtn.textContent = "Copié !";
setTimeout(() => (copyBtn.textContent = "Copier le chemin"), 1500);
});
});
// Frontmatter
let fmSection = null;
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
const fmToggle = el("div", { class: "frontmatter-toggle" }, [
document.createTextNode("▶ Frontmatter"),
]);
const fmContent = el("div", { class: "frontmatter-content" }, [
document.createTextNode(JSON.stringify(data.frontmatter, null, 2)),
]);
fmToggle.addEventListener("click", () => {
fmContent.classList.toggle("open");
fmToggle.textContent = fmContent.classList.contains("open") ? "▼ Frontmatter" : "▶ Frontmatter";
});
fmSection = el("div", {}, [fmToggle, fmContent]);
}
// Markdown content
const mdDiv = el("div", { class: "md-content" });
mdDiv.innerHTML = data.html;
// 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]),
]));
if (fmSection) area.appendChild(fmSection);
area.appendChild(mdDiv);
// 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);
});
});
area.scrollTop = 0;
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
function initSearch() {
const input = document.getElementById("search-input");
input.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const q = input.value.trim();
const vault = document.getElementById("vault-filter").value;
if (q.length > 0) {
performSearch(q, vault, null);
} else {
showWelcome();
}
}, 300);
});
}
async function performSearch(query, vaultFilter, tagFilter) {
let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`;
if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
const data = await api(url);
renderSearchResults(data, query, tagFilter);
}
function renderSearchResults(data, query, tagFilter) {
const area = document.getElementById("content-area");
area.innerHTML = "";
const header = el("div", { class: "search-results-header" });
if (query) {
header.textContent = `${data.count} résultat(s) pour "${query}"`;
} else if (tagFilter) {
header.textContent = `${data.count} fichier(s) avec le tag #${tagFilter}`;
}
area.appendChild(header);
if (data.results.length === 0) {
area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [
document.createTextNode("Aucun résultat trouvé."),
]));
return;
}
const container = el("div", { class: "search-results" });
data.results.forEach((r) => {
const item = el("div", { class: "search-result-item" }, [
el("div", { class: "search-result-title" }, [document.createTextNode(r.title)]),
el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path)]),
el("div", { class: "search-result-snippet" }, [document.createTextNode(r.snippet || "")]),
]);
if (r.tags && r.tags.length > 0) {
const tagsDiv = el("div", { class: "search-result-tags" });
r.tags.forEach((tag) => {
tagsDiv.appendChild(el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]));
});
item.appendChild(tagsDiv);
}
item.addEventListener("click", () => openFile(r.vault, r.path));
container.appendChild(item);
});
area.appendChild(container);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function el(tag, attrs, children) {
const e = document.createElement(tag);
if (attrs) {
Object.entries(attrs).forEach(([k, v]) => 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.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px";
s.textContent = `(${count})`;
return s;
}
function makeBreadcrumbSpan(text, onClick) {
const s = document.createElement("span");
s.textContent = text;
if (onClick) s.addEventListener("click", onClick);
return s;
}
function showWelcome() {
const area = document.getElementById("content-area");
area.innerHTML = `
<div class="welcome">
<i data-lucide="library" style="width:48px;height:48px;color:var(--text-muted)"></i>
<h2>ObsiGate</h2>
<p>Sélectionnez un fichier dans la sidebar ou utilisez la recherche pour commencer.</p>
</div>`;
safeCreateIcons();
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
async function init() {
initTheme();
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
initSearch();
try {
await Promise.all([loadVaults(), loadTags()]);
} catch (err) {
console.error("Failed to initialize ObsiGate:", err);
}
safeCreateIcons();
}
document.addEventListener("DOMContentLoaded", init);
})();