Expand file type support beyond Markdown to include code, config, and build files with syntax highlighting and improved UI filtering

This commit is contained in:
Bruno Charest 2026-03-21 11:16:46 -04:00
parent 8a22c1db28
commit 2ed5f65a7a
5 changed files with 738 additions and 70 deletions

View File

@ -15,6 +15,18 @@ index: Dict[str, Dict[str, Any]] = {}
# Vault config: {name: path} # Vault config: {name: path}
vault_config: Dict[str, str] = {} 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]: def load_vault_config() -> Dict[str, str]:
"""Read VAULT_N_NAME / VAULT_N_PATH env vars and return {name: path}.""" """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}") logger.warning(f"Vault path does not exist: {vault_path}")
return {"files": [], "tags": {}, "path": 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: try:
relative = md_file.relative_to(vault_root) relative = fpath.relative_to(vault_root)
stat = md_file.stat() stat = fpath.stat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
raw = md_file.read_text(encoding="utf-8", errors="replace") raw = fpath.read_text(encoding="utf-8", errors="replace")
post = frontmatter.loads(raw)
tags = _extract_tags(post) tags: List[str] = []
title = _extract_title(post, md_file) title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = post.content[:200].strip() 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({ files.append({
"path": str(relative).replace("\\", "/"), "path": str(relative).replace("\\", "/"),
@ -80,13 +108,14 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
"content_preview": content_preview, "content_preview": content_preview,
"size": stat.st_size, "size": stat.st_size,
"modified": modified, "modified": modified,
"extension": ext,
}) })
for tag in tags: for tag in tags:
tag_counts[tag] = tag_counts.get(tag, 0) + 1 tag_counts[tag] = tag_counts.get(tag, 0) + 1
except Exception as e: except Exception as e:
logger.error(f"Error indexing {md_file}: {e}") logger.error(f"Error indexing {fpath}: {e}")
continue continue
logger.info(f"Vault '{vault_name}': indexed {len(files)} files, {len(tag_counts)} unique tags") logger.info(f"Vault '{vault_name}': indexed {len(files)} files, {len(tag_counts)} unique tags")

View File

@ -1,4 +1,5 @@
import re import re
import html as html_mod
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -7,7 +8,7 @@ import frontmatter
import mistune import mistune
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, PlainTextResponse
from backend.indexer import ( from backend.indexer import (
build_index, build_index,
@ -15,6 +16,7 @@ from backend.indexer import (
index, index,
get_vault_data, get_vault_data,
find_file_in_index, find_file_in_index,
SUPPORTED_EXTENSIONS,
) )
from backend.search import search, get_all_tags from backend.search import search, get_all_tags
@ -111,20 +113,26 @@ async def api_browse(vault_name: str, path: str = ""):
continue continue
rel = str(entry.relative_to(vault_root)).replace("\\", "/") rel = str(entry.relative_to(vault_root)).replace("\\", "/")
if entry.is_dir(): if entry.is_dir():
# Count .md files recursively # Count all supported files recursively (skip hidden dirs)
md_count = sum(1 for _ in entry.rglob("*.md")) 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({ items.append({
"name": entry.name, "name": entry.name,
"path": rel, "path": rel,
"type": "directory", "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({ items.append({
"name": entry.name, "name": entry.name,
"path": rel, "path": rel,
"type": "file", "type": "file",
"size": entry.stat().st_size, "size": entry.stat().st_size,
"extension": entry.suffix.lower(),
}) })
except PermissionError: except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied") 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} return {"vault": vault_name, "path": path, "items": items}
@app.get("/api/file/{vault_name}") # Map file extensions to highlight.js language hints
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to .md file")): EXT_TO_LANG = {
"""Return rendered HTML + metadata for a markdown file.""" ".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) vault_data = get_vault_data(vault_name)
if not vault_data: if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") 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}") raise HTTPException(status_code=404, detail=f"File not found: {path}")
raw = file_path.read_text(encoding="utf-8", errors="replace") 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", []) @app.get("/api/file/{vault_name}/download")
if isinstance(tags, str): async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file")):
tags = [t.strip().lstrip("#") for t in tags.split(",") if t.strip()] """Download a file as attachment."""
elif isinstance(tags, list): vault_data = get_vault_data(vault_name)
tags = [str(t).strip().lstrip("#") for t in tags] 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: 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'<pre><code class="language-{lang}">{escaped}</code></pre>'
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " ")) return {
"vault": vault_name,
html_content = _render_markdown(post.content, vault_name) "path": path,
"title": file_path.name,
return { "tags": [],
"vault": vault_name, "frontmatter": {},
"path": path, "html": html_content,
"title": str(title), "raw_length": len(raw),
"tags": tags, "extension": ext,
"frontmatter": dict(post.metadata) if post.metadata else {}, "is_markdown": False,
"html": html_content, }
"raw_length": len(raw),
}
@app.get("/api/search") @app.get("/api/search")

View File

@ -9,6 +9,60 @@
let currentVault = null; let currentVault = null;
let currentPath = null; let currentPath = null;
let searchTimeout = 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 // Safe CDN helpers
@ -66,11 +120,84 @@
return res.json(); 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 // Sidebar — Vault tree
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function loadVaults() { async function loadVaults() {
const vaults = await api("/api/vaults"); const vaults = await api("/api/vaults");
allVaults = vaults;
const container = document.getElementById("vault-tree"); const container = document.getElementById("vault-tree");
const filter = document.getElementById("vault-filter"); const filter = document.getElementById("vault-filter");
container.innerHTML = ""; container.innerHTML = "";
@ -156,11 +283,16 @@
} }
}); });
} else { } 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 }, [ const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [
icon("file-text", 16), icon(fileIconName, 16),
document.createTextNode(` ${item.name.replace(/\.md$/i, "")}`), document.createTextNode(` ${displayName}`),
]); ]);
fileItem.addEventListener("click", () => openFile(vaultName, item.path)); fileItem.addEventListener("click", () => {
openFile(vaultName, item.path);
closeMobileSidebar();
});
container.appendChild(fileItem); container.appendChild(fileItem);
} }
}); });
@ -168,15 +300,74 @@
safeCreateIcons(); 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 // Tags
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function loadTags() { async function loadTags() {
const data = await api("/api/tags"); const data = await api("/api/tags");
renderTagCloud(data.tags);
}
function renderTagCloud(tags) {
const cloud = document.getElementById("tag-cloud"); const cloud = document.getElementById("tag-cloud");
cloud.innerHTML = ""; cloud.innerHTML = "";
const tags = data.tags;
const counts = Object.values(tags); const counts = Object.values(tags);
if (counts.length === 0) return; if (counts.length === 0) return;
@ -198,7 +389,8 @@
function searchByTag(tag) { function searchByTag(tag) {
const input = document.getElementById("search-input"); const input = document.getElementById("search-input");
input.value = ""; input.value = "";
performSearch("", "all", tag); const vault = document.getElementById("vault-filter").value;
performSearch("", vault, tag);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -207,12 +399,16 @@
async function openFile(vaultName, filePath) { async function openFile(vaultName, filePath) {
currentVault = vaultName; currentVault = vaultName;
currentPath = filePath; currentPath = filePath;
showingSource = false;
cachedRawSource = null;
// Highlight active // Highlight active
document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
const selector = `.tree-item[data-vault="${vaultName}"][data-path="${filePath}"]`; const selector = `.tree-item[data-vault="${vaultName}"][data-path="${CSS.escape(filePath)}"]`;
const active = document.querySelector(selector); try {
if (active) active.classList.add("active"); 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 url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
const data = await api(url); const data = await api(url);
@ -248,15 +444,38 @@
tagsDiv.appendChild(t); tagsDiv.appendChild(t);
}); });
// Copy path button // Action buttons
const copyBtn = el("button", { class: "btn-copy-path" }, [document.createTextNode("Copier le chemin")]); const copyBtn = el("button", { class: "btn-action", title: "Copier le chemin" }, [
icon("copy", 14),
document.createTextNode("Copier"),
]);
copyBtn.addEventListener("click", () => { copyBtn.addEventListener("click", () => {
navigator.clipboard.writeText(`${data.vault}/${data.path}`).then(() => { navigator.clipboard.writeText(`${data.vault}/${data.path}`).then(() => {
copyBtn.textContent = "Copié !"; copyBtn.querySelector("span") || (copyBtn.lastChild.textContent = "Copié !");
setTimeout(() => (copyBtn.textContent = "Copier le chemin"), 1500); 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 // Frontmatter
let fmSection = null; let fmSection = null;
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) { if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
@ -273,20 +492,48 @@
fmSection = el("div", {}, [fmToggle, fmContent]); fmSection = el("div", {}, [fmToggle, fmContent]);
} }
// Markdown content // Content container (rendered HTML)
const mdDiv = el("div", { class: "md-content" }); const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" });
mdDiv.innerHTML = data.html; 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 // Assemble
area.innerHTML = ""; area.innerHTML = "";
area.appendChild(breadcrumb); area.appendChild(breadcrumb);
area.appendChild(el("div", { class: "file-header" }, [ area.appendChild(el("div", { class: "file-header" }, [
el("div", { class: "file-title" }, [document.createTextNode(data.title)]), el("div", { class: "file-title" }, [document.createTextNode(data.title)]),
tagsDiv, tagsDiv,
el("div", { class: "file-actions" }, [copyBtn]), el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn]),
])); ]));
if (fmSection) area.appendChild(fmSection); if (fmSection) area.appendChild(fmSection);
area.appendChild(mdDiv); area.appendChild(mdDiv);
area.appendChild(rawDiv);
// Highlight code blocks // Highlight code blocks
area.querySelectorAll("pre code").forEach((block) => { area.querySelectorAll("pre code").forEach((block) => {
@ -303,6 +550,7 @@
}); });
}); });
safeCreateIcons();
area.scrollTop = 0; area.scrollTop = 0;
} }
@ -375,6 +623,89 @@
area.appendChild(container); 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 // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -430,6 +761,11 @@
initTheme(); initTheme();
document.getElementById("theme-toggle").addEventListener("click", toggleTheme); document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
initSearch(); initSearch();
initMobile();
initVaultContext();
initSidebarFilter();
initSidebarResize();
initTagResize();
try { try {
await Promise.all([loadVaults(), loadTags()]); await Promise.all([loadVaults(), loadTags()]);

View File

@ -15,6 +15,10 @@
<!-- Header --> <!-- Header -->
<header class="header"> <header class="header">
<button class="hamburger-btn" id="hamburger-btn" title="Menu">
<i data-lucide="menu" style="width:20px;height:20px"></i>
</button>
<div class="header-logo"> <div class="header-logo">
<i data-lucide="book-open" style="width:20px;height:20px"></i> <i data-lucide="book-open" style="width:20px;height:20px"></i>
ObsiGate ObsiGate
@ -37,19 +41,34 @@
<!-- Main --> <!-- Main -->
<div class="main-body"> <div class="main-body">
<!-- Mobile overlay -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- Sidebar --> <!-- Sidebar -->
<aside class="sidebar"> <aside class="sidebar" id="sidebar">
<!-- Sidebar filter -->
<div class="sidebar-filter">
<i data-lucide="filter" class="sidebar-filter-icon" style="width:14px;height:14px"></i>
<input type="text" id="sidebar-filter-input" placeholder="Filtrer fichiers et tags..." autocomplete="off">
</div>
<div class="sidebar-tree" id="sidebar-tree"> <div class="sidebar-tree" id="sidebar-tree">
<div class="sidebar-section-title">Vaults</div> <div class="sidebar-section-title">Vaults</div>
<div id="vault-tree"></div> <div id="vault-tree"></div>
</div> </div>
<div class="tag-cloud-section"> <!-- Tag resize handle -->
<div class="tag-resize-handle" id="tag-resize-handle"></div>
<div class="tag-cloud-section" id="tag-cloud-section">
<div class="tag-cloud-title">Tags</div> <div class="tag-cloud-title">Tags</div>
<div class="tag-cloud" id="tag-cloud"></div> <div class="tag-cloud" id="tag-cloud"></div>
</div> </div>
</aside> </aside>
<!-- Sidebar resize handle -->
<div class="sidebar-resize-handle" id="sidebar-resize-handle"></div>
<!-- Content --> <!-- Content -->
<main class="content-area" id="content-area"> <main class="content-area" id="content-area">
<div class="welcome" id="welcome"> <div class="welcome" id="welcome">

View File

@ -24,6 +24,8 @@
--code-bg: #161b22; --code-bg: #161b22;
--search-bg: #21262d; --search-bg: #21262d;
--scrollbar: #30363d; --scrollbar: #30363d;
--resize-handle: #30363d;
--overlay-bg: rgba(0,0,0,0.5);
} }
/* ===== THEME — LIGHT ===== */ /* ===== THEME — LIGHT ===== */
@ -43,6 +45,8 @@
--code-bg: #f6f8fa; --code-bg: #f6f8fa;
--search-bg: #ffffff; --search-bg: #ffffff;
--scrollbar: #d0d7de; --scrollbar: #d0d7de;
--resize-handle: #d0d7de;
--overlay-bg: rgba(0,0,0,0.3);
} }
/* ===== BASE ===== */ /* ===== BASE ===== */
@ -89,6 +93,21 @@ a:hover {
transition: background 200ms ease; transition: background 200ms ease;
} }
.hamburger-btn {
display: none;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hamburger-btn:hover {
color: var(--accent);
}
.header-logo { .header-logo {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-weight: 700; font-weight: 700;
@ -164,24 +183,74 @@ a:hover {
display: flex; display: flex;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
position: relative;
}
/* --- Mobile overlay --- */
.sidebar-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--overlay-bg);
z-index: 199;
}
.sidebar-overlay.active {
display: block;
} }
/* --- Sidebar --- */ /* --- Sidebar --- */
.sidebar { .sidebar {
width: 280px; width: 280px;
min-width: 280px; min-width: 200px;
max-width: 500px;
background: var(--bg-sidebar); background: var(--bg-sidebar);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
transition: background 200ms ease; transition: background 200ms ease;
flex-shrink: 0;
}
/* --- Sidebar filter --- */
.sidebar-filter {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
position: relative;
flex-shrink: 0;
}
.sidebar-filter input {
width: 100%;
padding: 6px 10px 6px 32px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--search-bg);
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 0.78rem;
outline: none;
transition: border-color 200ms ease;
}
.sidebar-filter input:focus {
border-color: var(--accent);
}
.sidebar-filter-icon {
position: absolute;
left: 22px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
} }
.sidebar-tree { .sidebar-tree {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 12px 0; padding: 12px 0;
min-height: 80px;
} }
.sidebar-tree::-webkit-scrollbar { .sidebar-tree::-webkit-scrollbar {
@ -225,6 +294,9 @@ a:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--accent); color: var(--accent);
} }
.tree-item.filtered-out {
display: none;
}
.tree-item .icon { .tree-item .icon {
width: 16px; width: 16px;
@ -246,13 +318,33 @@ a:hover {
.tree-children.collapsed { .tree-children.collapsed {
display: none; display: none;
} }
.tree-children.filtered-out {
display: none;
}
/* --- Tag resize handle --- */
.tag-resize-handle {
height: 5px;
cursor: ns-resize;
background: transparent;
border-top: 1px solid var(--border);
flex-shrink: 0;
transition: background 150ms ease;
}
.tag-resize-handle:hover,
.tag-resize-handle.active {
background: var(--accent);
opacity: 0.5;
}
/* --- Tag Cloud --- */ /* --- Tag Cloud --- */
.tag-cloud-section { .tag-cloud-section {
border-top: 1px solid var(--border);
padding: 12px 16px; padding: 12px 16px;
max-height: 180px; height: 180px;
min-height: 60px;
max-height: 400px;
overflow-y: auto; overflow-y: auto;
flex-shrink: 0;
} }
.tag-cloud-section::-webkit-scrollbar { .tag-cloud-section::-webkit-scrollbar {
width: 6px; width: 6px;
@ -292,13 +384,32 @@ a:hover {
.tag-item:hover { .tag-item:hover {
opacity: 0.8; opacity: 0.8;
} }
.tag-item.filtered-out {
display: none;
}
/* --- Sidebar resize handle (horizontal) --- */
.sidebar-resize-handle {
width: 5px;
cursor: ew-resize;
background: transparent;
flex-shrink: 0;
transition: background 150ms ease;
z-index: 10;
}
.sidebar-resize-handle:hover,
.sidebar-resize-handle.active {
background: var(--accent);
opacity: 0.5;
}
/* --- Content Area --- */ /* --- Content Area --- */
.content-area { .content-area {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 28px 40px 60px; padding: clamp(16px, 3vw, 40px) clamp(16px, 4vw, 40px) 60px;
transition: background 200ms ease; transition: background 200ms ease;
min-width: 0;
} }
.content-area::-webkit-scrollbar { .content-area::-webkit-scrollbar {
width: 8px; width: 8px;
@ -384,8 +495,12 @@ a:hover {
} }
.file-actions { .file-actions {
margin-top: 8px; margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
} }
.btn-copy-path { .btn-action {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem; font-size: 0.75rem;
padding: 4px 10px; padding: 4px 10px;
@ -395,11 +510,19 @@ a:hover {
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
transition: color 150ms ease, border-color 150ms ease; transition: color 150ms ease, border-color 150ms ease;
display: inline-flex;
align-items: center;
gap: 4px;
} }
.btn-copy-path:hover { .btn-action:hover {
color: var(--accent); color: var(--accent);
border-color: var(--accent); border-color: var(--accent);
} }
.btn-action.active {
color: var(--accent);
border-color: var(--accent);
background: var(--tag-bg);
}
/* Frontmatter collapsible */ /* Frontmatter collapsible */
.frontmatter-toggle { .frontmatter-toggle {
@ -432,6 +555,24 @@ a:hover {
display: block; display: block;
} }
/* --- Raw source view --- */
.raw-source-view {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-top: 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.82rem;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
line-height: 1.55;
max-height: 70vh;
overflow-y: auto;
}
/* --- Markdown Rendered Content --- */ /* --- Markdown Rendered Content --- */
.md-content h1, .md-content h2, .md-content h3, .md-content h1, .md-content h2, .md-content h3,
.md-content h4, .md-content h5, .md-content h6 { .md-content h4, .md-content h5, .md-content h6 {
@ -600,22 +741,82 @@ a:hover {
padding: 1px 6px; padding: 1px 6px;
} }
/* --- Responsive --- */ /* --- No-select during resize --- */
@media (max-width: 768px) { body.resizing {
user-select: none;
-webkit-user-select: none;
cursor: ew-resize;
}
body.resizing-v {
user-select: none;
-webkit-user-select: none;
cursor: ns-resize;
}
/* --- Responsive — Tablet --- */
@media (max-width: 1024px) {
.sidebar { .sidebar {
width: 220px; width: 240px;
min-width: 220px; min-width: 200px;
}
.content-area {
padding: 20px;
} }
} }
@media (max-width: 600px) { /* --- Responsive — Mobile --- */
@media (max-width: 768px) {
.hamburger-btn {
display: flex;
}
.header {
gap: 8px;
padding: 8px 12px;
}
.header-logo {
font-size: 1rem;
}
.search-wrapper {
max-width: none;
flex: 1;
}
.search-vault-filter {
font-size: 0.72rem;
padding: 5px 6px;
}
.sidebar { .sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 200;
width: 280px !important;
min-width: 280px !important;
max-width: 85vw !important;
transform: translateX(-100%);
transition: transform 250ms ease, background 200ms ease;
box-shadow: none;
}
.sidebar.mobile-open {
transform: translateX(0);
box-shadow: 4px 0 20px rgba(0,0,0,0.3);
}
.sidebar-resize-handle {
display: none; display: none;
} }
.content-area { .content-area {
padding: 16px; padding: 16px 12px 60px;
}
.file-title {
font-size: 1.2rem;
}
.file-actions {
flex-wrap: wrap;
} }
} }