diff --git a/backend/indexer.py b/backend/indexer.py index 5b2ca68..d1c0bf6 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -15,6 +15,18 @@ index: Dict[str, Dict[str, Any]] = {} # Vault config: {name: path} vault_config: Dict[str, str] = {} +# Supported text-based file extensions +SUPPORTED_EXTENSIONS = { + ".md", ".txt", ".log", ".py", ".js", ".ts", ".jsx", ".tsx", + ".sh", ".bash", ".zsh", ".fish", ".bat", ".cmd", ".ps1", + ".json", ".yaml", ".yml", ".toml", ".xml", ".csv", + ".cfg", ".ini", ".conf", ".env", + ".html", ".css", ".scss", ".less", + ".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb", + ".php", ".sql", ".r", ".m", ".swift", ".kt", + ".dockerfile", ".makefile", ".cmake", +} + def load_vault_config() -> Dict[str, str]: """Read VAULT_N_NAME / VAULT_N_PATH env vars and return {name: path}.""" @@ -60,18 +72,34 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]: logger.warning(f"Vault path does not exist: {vault_path}") return {"files": [], "tags": {}, "path": vault_path} - for md_file in vault_root.rglob("*.md"): + for fpath in vault_root.rglob("*"): + if not fpath.is_file(): + continue + # Skip hidden files and files inside hidden directories + rel_parts = fpath.relative_to(vault_root).parts + if any(part.startswith(".") for part in rel_parts): + continue + ext = fpath.suffix.lower() + # Also match extensionless files named like Dockerfile, Makefile + basename_lower = fpath.name.lower() + if ext not in SUPPORTED_EXTENSIONS and basename_lower not in ("dockerfile", "makefile", "cmakelists.txt"): + continue try: - relative = md_file.relative_to(vault_root) - stat = md_file.stat() + relative = fpath.relative_to(vault_root) + stat = fpath.stat() modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() - raw = md_file.read_text(encoding="utf-8", errors="replace") - post = frontmatter.loads(raw) + raw = fpath.read_text(encoding="utf-8", errors="replace") - tags = _extract_tags(post) - title = _extract_title(post, md_file) - content_preview = post.content[:200].strip() + tags: List[str] = [] + title = fpath.stem.replace("-", " ").replace("_", " ") + content_preview = raw[:200].strip() + + if ext == ".md": + post = frontmatter.loads(raw) + tags = _extract_tags(post) + title = _extract_title(post, fpath) + content_preview = post.content[:200].strip() files.append({ "path": str(relative).replace("\\", "/"), @@ -80,13 +108,14 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]: "content_preview": content_preview, "size": stat.st_size, "modified": modified, + "extension": ext, }) for tag in tags: tag_counts[tag] = tag_counts.get(tag, 0) + 1 except Exception as e: - logger.error(f"Error indexing {md_file}: {e}") + logger.error(f"Error indexing {fpath}: {e}") continue logger.info(f"Vault '{vault_name}': indexed {len(files)} files, {len(tag_counts)} unique tags") diff --git a/backend/main.py b/backend/main.py index f79d0eb..3cfc170 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,4 +1,5 @@ import re +import html as html_mod import logging from pathlib import Path from typing import Optional @@ -7,7 +8,7 @@ import frontmatter import mistune from fastapi import FastAPI, HTTPException, Query from fastapi.staticfiles import StaticFiles -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, PlainTextResponse from backend.indexer import ( build_index, @@ -15,6 +16,7 @@ from backend.indexer import ( index, get_vault_data, find_file_in_index, + SUPPORTED_EXTENSIONS, ) from backend.search import search, get_all_tags @@ -111,20 +113,26 @@ async def api_browse(vault_name: str, path: str = ""): continue rel = str(entry.relative_to(vault_root)).replace("\\", "/") if entry.is_dir(): - # Count .md files recursively - md_count = sum(1 for _ in entry.rglob("*.md")) + # Count all supported files recursively (skip hidden dirs) + file_count = sum( + 1 for f in entry.rglob("*") + if f.is_file() + and not any(p.startswith(".") for p in f.relative_to(entry).parts) + and (f.suffix.lower() in SUPPORTED_EXTENSIONS or f.name.lower() in ("dockerfile", "makefile")) + ) items.append({ "name": entry.name, "path": rel, "type": "directory", - "children_count": md_count, + "children_count": file_count, }) - elif entry.suffix.lower() == ".md": + elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"): items.append({ "name": entry.name, "path": rel, "type": "file", "size": entry.stat().st_size, + "extension": entry.suffix.lower(), }) except PermissionError: raise HTTPException(status_code=403, detail="Permission denied") @@ -132,9 +140,26 @@ async def api_browse(vault_name: str, path: str = ""): return {"vault": vault_name, "path": path, "items": items} -@app.get("/api/file/{vault_name}") -async def api_file(vault_name: str, path: str = Query(..., description="Relative path to .md file")): - """Return rendered HTML + metadata for a markdown file.""" +# Map file extensions to highlight.js language hints +EXT_TO_LANG = { + ".py": "python", ".js": "javascript", ".ts": "typescript", + ".jsx": "jsx", ".tsx": "tsx", ".sh": "bash", ".bash": "bash", + ".zsh": "bash", ".fish": "fish", ".bat": "batch", ".cmd": "batch", + ".ps1": "powershell", ".json": "json", ".yaml": "yaml", ".yml": "yaml", + ".toml": "toml", ".xml": "xml", ".csv": "plaintext", + ".cfg": "ini", ".ini": "ini", ".conf": "ini", ".env": "bash", + ".html": "html", ".css": "css", ".scss": "scss", ".less": "less", + ".java": "java", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp", + ".cs": "csharp", ".go": "go", ".rs": "rust", ".rb": "ruby", + ".php": "php", ".sql": "sql", ".r": "r", ".swift": "swift", + ".kt": "kotlin", ".txt": "plaintext", ".log": "plaintext", + ".dockerfile": "dockerfile", ".makefile": "makefile", ".cmake": "cmake", +} + + +@app.get("/api/file/{vault_name}/raw") +async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file")): + """Return raw file content.""" vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") @@ -146,30 +171,88 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative raise HTTPException(status_code=404, detail=f"File not found: {path}") raw = file_path.read_text(encoding="utf-8", errors="replace") - post = frontmatter.loads(raw) + return {"vault": vault_name, "path": path, "raw": raw} - # Extract metadata - tags = post.metadata.get("tags", []) - if isinstance(tags, str): - tags = [t.strip().lstrip("#") for t in tags.split(",") if t.strip()] - elif isinstance(tags, list): - tags = [str(t).strip().lstrip("#") for t in tags] + +@app.get("/api/file/{vault_name}/download") +async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file")): + """Download a file as attachment.""" + vault_data = get_vault_data(vault_name) + if not vault_data: + raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") + + vault_root = Path(vault_data["path"]) + file_path = vault_root / path + + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail=f"File not found: {path}") + + return FileResponse( + path=str(file_path), + filename=file_path.name, + media_type="application/octet-stream", + ) + + +@app.get("/api/file/{vault_name}") +async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file")): + """Return rendered HTML + metadata for a file.""" + vault_data = get_vault_data(vault_name) + if not vault_data: + raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") + + vault_root = Path(vault_data["path"]) + file_path = vault_root / path + + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail=f"File not found: {path}") + + raw = file_path.read_text(encoding="utf-8", errors="replace") + ext = file_path.suffix.lower() + + if ext == ".md": + post = frontmatter.loads(raw) + + # Extract metadata + tags = post.metadata.get("tags", []) + if isinstance(tags, str): + tags = [t.strip().lstrip("#") for t in tags.split(",") if t.strip()] + elif isinstance(tags, list): + tags = [str(t).strip().lstrip("#") for t in tags] + else: + tags = [] + + title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " ")) + html_content = _render_markdown(post.content, vault_name) + + return { + "vault": vault_name, + "path": path, + "title": str(title), + "tags": tags, + "frontmatter": dict(post.metadata) if post.metadata else {}, + "html": html_content, + "raw_length": len(raw), + "extension": ext, + "is_markdown": True, + } else: - tags = [] + # Non-markdown: wrap in syntax-highlighted code block + lang = EXT_TO_LANG.get(ext, "plaintext") + escaped = html_mod.escape(raw) + html_content = f'
{escaped}'
- title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
-
- html_content = _render_markdown(post.content, vault_name)
-
- return {
- "vault": vault_name,
- "path": path,
- "title": str(title),
- "tags": tags,
- "frontmatter": dict(post.metadata) if post.metadata else {},
- "html": html_content,
- "raw_length": len(raw),
- }
+ return {
+ "vault": vault_name,
+ "path": path,
+ "title": file_path.name,
+ "tags": [],
+ "frontmatter": {},
+ "html": html_content,
+ "raw_length": len(raw),
+ "extension": ext,
+ "is_markdown": False,
+ }
@app.get("/api/search")
diff --git a/frontend/app.js b/frontend/app.js
index 7eddb8c..14d3c69 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -9,6 +9,60 @@
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
@@ -66,11 +120,84 @@
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 = "";
@@ -156,11 +283,16 @@
}
});
} 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("file-text", 16),
- document.createTextNode(` ${item.name.replace(/\.md$/i, "")}`),
+ icon(fileIconName, 16),
+ document.createTextNode(` ${displayName}`),
]);
- fileItem.addEventListener("click", () => openFile(vaultName, item.path));
+ fileItem.addEventListener("click", () => {
+ openFile(vaultName, item.path);
+ closeMobileSidebar();
+ });
container.appendChild(fileItem);
}
});
@@ -168,15 +300,74 @@
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 tags = data.tags;
const counts = Object.values(tags);
if (counts.length === 0) return;
@@ -198,7 +389,8 @@
function searchByTag(tag) {
const input = document.getElementById("search-input");
input.value = "";
- performSearch("", "all", tag);
+ const vault = document.getElementById("vault-filter").value;
+ performSearch("", vault, tag);
}
// ---------------------------------------------------------------------------
@@ -207,12 +399,16 @@
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="${filePath}"]`;
- const active = document.querySelector(selector);
- if (active) active.classList.add("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);
@@ -248,15 +444,38 @@
tagsDiv.appendChild(t);
});
- // Copy path button
- const copyBtn = el("button", { class: "btn-copy-path" }, [document.createTextNode("Copier le chemin")]);
+ // 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.textContent = "Copié !";
- setTimeout(() => (copyBtn.textContent = "Copier le chemin"), 1500);
+ 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) {
@@ -273,20 +492,48 @@
fmSection = el("div", {}, [fmToggle, fmContent]);
}
- // Markdown content
- const mdDiv = el("div", { class: "md-content" });
+ // 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]),
+ 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) => {
@@ -303,6 +550,7 @@
});
});
+ safeCreateIcons();
area.scrollTop = 0;
}
@@ -375,6 +623,89 @@
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
// ---------------------------------------------------------------------------
@@ -430,6 +761,11 @@
initTheme();
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
initSearch();
+ initMobile();
+ initVaultContext();
+ initSidebarFilter();
+ initSidebarResize();
+ initTagResize();
try {
await Promise.all([loadVaults(), loadTags()]);
diff --git a/frontend/index.html b/frontend/index.html
index cba25eb..9bc0249 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -15,6 +15,10 @@