781 lines
27 KiB
JavaScript

/* ObsiGate — Vanilla JS SPA */
(function () {
"use strict";
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let currentVault = null;
let currentPath = null;
let searchTimeout = null;
let showingSource = false;
let cachedRawSource = null;
let allVaults = [];
let selectedContextVault = "all";
// ---------------------------------------------------------------------------
// File extension → Lucide icon mapping
// ---------------------------------------------------------------------------
const EXT_ICONS = {
".md": "file-text",
".txt": "file-text",
".log": "file-text",
".py": "file-code",
".js": "file-code",
".ts": "file-code",
".jsx": "file-code",
".tsx": "file-code",
".html": "file-code",
".css": "file-code",
".scss": "file-code",
".less": "file-code",
".json": "file-json",
".yaml": "file-cog",
".yml": "file-cog",
".toml": "file-cog",
".xml": "file-code",
".sh": "terminal",
".bash": "terminal",
".zsh": "terminal",
".bat": "terminal",
".cmd": "terminal",
".ps1": "terminal",
".java": "file-code",
".c": "file-code",
".cpp": "file-code",
".h": "file-code",
".hpp": "file-code",
".cs": "file-code",
".go": "file-code",
".rs": "file-code",
".rb": "file-code",
".php": "file-code",
".sql": "database",
".csv": "table",
".ini": "file-cog",
".cfg": "file-cog",
".conf": "file-cog",
".env": "file-cog",
};
function getFileIcon(name) {
const ext = "." + name.split(".").pop().toLowerCase();
return EXT_ICONS[ext] || "file";
}
// ---------------------------------------------------------------------------
// 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();
}
// ---------------------------------------------------------------------------
// Mobile sidebar
// ---------------------------------------------------------------------------
function initMobile() {
const hamburger = document.getElementById("hamburger-btn");
const overlay = document.getElementById("sidebar-overlay");
const sidebar = document.getElementById("sidebar");
hamburger.addEventListener("click", () => {
sidebar.classList.toggle("mobile-open");
overlay.classList.toggle("active");
});
overlay.addEventListener("click", () => {
sidebar.classList.remove("mobile-open");
overlay.classList.remove("active");
});
}
function closeMobileSidebar() {
const sidebar = document.getElementById("sidebar");
const overlay = document.getElementById("sidebar-overlay");
if (sidebar) sidebar.classList.remove("mobile-open");
if (overlay) overlay.classList.remove("active");
}
// ---------------------------------------------------------------------------
// Vault context switching
// ---------------------------------------------------------------------------
function initVaultContext() {
const filter = document.getElementById("vault-filter");
filter.addEventListener("change", async () => {
selectedContextVault = filter.value;
showingSource = false;
cachedRawSource = null;
await refreshSidebarForContext();
await refreshTagsForContext();
showWelcome();
});
}
async function refreshSidebarForContext() {
const container = document.getElementById("vault-tree");
container.innerHTML = "";
const vaultsToShow = selectedContextVault === "all"
? allVaults
: allVaults.filter((v) => v.name === selectedContextVault);
vaultsToShow.forEach((v) => {
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);
});
safeCreateIcons();
}
async function refreshTagsForContext() {
const vaultParam = selectedContextVault === "all" ? "" : `?vault=${encodeURIComponent(selectedContextVault)}`;
const data = await api(`/api/tags${vaultParam}`);
renderTagCloud(data.tags);
}
// ---------------------------------------------------------------------------
// Sidebar — Vault tree
// ---------------------------------------------------------------------------
async function loadVaults() {
const vaults = await api("/api/vaults");
allVaults = 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 fileIconName = getFileIcon(item.name);
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [
icon(fileIconName, 16),
document.createTextNode(` ${displayName}`),
]);
fileItem.addEventListener("click", () => {
openFile(vaultName, item.path);
closeMobileSidebar();
});
container.appendChild(fileItem);
}
});
safeCreateIcons();
}
// ---------------------------------------------------------------------------
// Sidebar filter
// ---------------------------------------------------------------------------
function initSidebarFilter() {
const input = document.getElementById("sidebar-filter-input");
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
filterSidebarTree(q);
filterTagCloud(q);
});
}
function filterSidebarTree(query) {
const tree = document.getElementById("vault-tree");
const items = tree.querySelectorAll(".tree-item");
if (!query) {
items.forEach((item) => item.classList.remove("filtered-out"));
tree.querySelectorAll(".tree-children").forEach((c) => c.classList.remove("filtered-out"));
return;
}
// First pass: mark all as filtered out
items.forEach((item) => item.classList.add("filtered-out"));
tree.querySelectorAll(".tree-children").forEach((c) => c.classList.add("filtered-out"));
// Second pass: show matching items and their ancestors
items.forEach((item) => {
const text = item.textContent.toLowerCase();
if (text.includes(query)) {
item.classList.remove("filtered-out");
// Show all ancestor containers
let parent = item.parentElement;
while (parent && parent !== tree) {
parent.classList.remove("filtered-out");
if (parent.classList.contains("tree-children")) {
parent.classList.remove("collapsed");
}
parent = parent.parentElement;
}
}
});
}
function filterTagCloud(query) {
const tags = document.querySelectorAll("#tag-cloud .tag-item");
tags.forEach((tag) => {
const text = tag.textContent.toLowerCase();
if (!query || text.includes(query)) {
tag.classList.remove("filtered-out");
} else {
tag.classList.add("filtered-out");
}
});
}
// ---------------------------------------------------------------------------
// Tags
// ---------------------------------------------------------------------------
async function loadTags() {
const data = await api("/api/tags");
renderTagCloud(data.tags);
}
function renderTagCloud(tags) {
const cloud = document.getElementById("tag-cloud");
cloud.innerHTML = "";
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 = "";
const vault = document.getElementById("vault-filter").value;
performSearch("", vault, tag);
}
// ---------------------------------------------------------------------------
// File viewer
// ---------------------------------------------------------------------------
async function openFile(vaultName, filePath) {
currentVault = vaultName;
currentPath = filePath;
showingSource = false;
cachedRawSource = null;
// Highlight active
document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
const selector = `.tree-item[data-vault="${vaultName}"][data-path="${CSS.escape(filePath)}"]`;
try {
const active = document.querySelector(selector);
if (active) active.classList.add("active");
} catch (e) { /* selector might fail on special chars */ }
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);
});
// Action buttons
const copyBtn = el("button", { class: "btn-action", title: "Copier le chemin" }, [
icon("copy", 14),
document.createTextNode("Copier"),
]);
copyBtn.addEventListener("click", () => {
navigator.clipboard.writeText(`${data.vault}/${data.path}`).then(() => {
copyBtn.querySelector("span") || (copyBtn.lastChild.textContent = "Copié !");
copyBtn.lastChild.textContent = "Copié !";
setTimeout(() => (copyBtn.lastChild.textContent = "Copier"), 1500);
});
});
const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [
icon("code", 14),
document.createTextNode("Source"),
]);
const downloadBtn = el("button", { class: "btn-action", title: "Télécharger" }, [
icon("download", 14),
document.createTextNode("Télécharger"),
]);
downloadBtn.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);
});
// 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]);
}
// 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, downloadBtn]),
]));
if (fmSection) area.appendChild(fmSection);
area.appendChild(mdDiv);
area.appendChild(rawDiv);
// 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;
}
// ---------------------------------------------------------------------------
// 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);
}
// ---------------------------------------------------------------------------
// Resizable sidebar (horizontal)
// ---------------------------------------------------------------------------
function initSidebarResize() {
const handle = document.getElementById("sidebar-resize-handle");
const sidebar = document.getElementById("sidebar");
if (!handle || !sidebar) return;
// Restore saved width
const savedWidth = localStorage.getItem("obsigate-sidebar-width");
if (savedWidth) {
sidebar.style.width = savedWidth + "px";
}
let startX = 0;
let startWidth = 0;
function onMouseMove(e) {
const newWidth = Math.min(500, Math.max(200, startWidth + (e.clientX - startX)));
sidebar.style.width = newWidth + "px";
}
function onMouseUp() {
document.body.classList.remove("resizing");
handle.classList.remove("active");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
localStorage.setItem("obsigate-sidebar-width", parseInt(sidebar.style.width));
}
handle.addEventListener("mousedown", (e) => {
e.preventDefault();
startX = e.clientX;
startWidth = sidebar.getBoundingClientRect().width;
document.body.classList.add("resizing");
handle.classList.add("active");
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
}
// ---------------------------------------------------------------------------
// Resizable tag section (vertical)
// ---------------------------------------------------------------------------
function initTagResize() {
const handle = document.getElementById("tag-resize-handle");
const tagSection = document.getElementById("tag-cloud-section");
if (!handle || !tagSection) return;
// Restore saved height
const savedHeight = localStorage.getItem("obsigate-tag-height");
if (savedHeight) {
tagSection.style.height = savedHeight + "px";
}
let startY = 0;
let startHeight = 0;
function onMouseMove(e) {
// Dragging up increases height, dragging down decreases
const newHeight = Math.min(400, Math.max(60, startHeight - (e.clientY - startY)));
tagSection.style.height = newHeight + "px";
}
function onMouseUp() {
document.body.classList.remove("resizing-v");
handle.classList.remove("active");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
localStorage.setItem("obsigate-tag-height", parseInt(tagSection.style.height));
}
handle.addEventListener("mousedown", (e) => {
e.preventDefault();
startY = e.clientY;
startHeight = tagSection.getBoundingClientRect().height;
document.body.classList.add("resizing-v");
handle.classList.add("active");
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
}
// ---------------------------------------------------------------------------
// 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();
initMobile();
initVaultContext();
initSidebarFilter();
initSidebarResize();
initTagResize();
try {
await Promise.all([loadVaults(), loadTags()]);
} catch (err) {
console.error("Failed to initialize ObsiGate:", err);
}
safeCreateIcons();
}
document.addEventListener("DOMContentLoaded", init);
})();