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: 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")

View File

@ -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'<pre><code class="language-{lang}">{escaped}</code></pre>'
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")

View File

@ -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()]);

View File

@ -15,6 +15,10 @@
<!-- 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">
<i data-lucide="book-open" style="width:20px;height:20px"></i>
ObsiGate
@ -37,19 +41,34 @@
<!-- Main -->
<div class="main-body">
<!-- Mobile overlay -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- 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-section-title">Vaults</div>
<div id="vault-tree"></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" id="tag-cloud"></div>
</div>
</aside>
<!-- Sidebar resize handle -->
<div class="sidebar-resize-handle" id="sidebar-resize-handle"></div>
<!-- Content -->
<main class="content-area" id="content-area">
<div class="welcome" id="welcome">

View File

@ -24,6 +24,8 @@
--code-bg: #161b22;
--search-bg: #21262d;
--scrollbar: #30363d;
--resize-handle: #30363d;
--overlay-bg: rgba(0,0,0,0.5);
}
/* ===== THEME — LIGHT ===== */
@ -43,6 +45,8 @@
--code-bg: #f6f8fa;
--search-bg: #ffffff;
--scrollbar: #d0d7de;
--resize-handle: #d0d7de;
--overlay-bg: rgba(0,0,0,0.3);
}
/* ===== BASE ===== */
@ -89,6 +93,21 @@ a:hover {
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 {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
@ -164,24 +183,74 @@ a:hover {
display: flex;
flex: 1;
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 {
width: 280px;
min-width: 280px;
min-width: 200px;
max-width: 500px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
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 {
flex: 1;
overflow-y: auto;
padding: 12px 0;
min-height: 80px;
}
.sidebar-tree::-webkit-scrollbar {
@ -225,6 +294,9 @@ a:hover {
background: var(--bg-hover);
color: var(--accent);
}
.tree-item.filtered-out {
display: none;
}
.tree-item .icon {
width: 16px;
@ -246,13 +318,33 @@ a:hover {
.tree-children.collapsed {
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-section {
border-top: 1px solid var(--border);
padding: 12px 16px;
max-height: 180px;
height: 180px;
min-height: 60px;
max-height: 400px;
overflow-y: auto;
flex-shrink: 0;
}
.tag-cloud-section::-webkit-scrollbar {
width: 6px;
@ -292,13 +384,32 @@ a:hover {
.tag-item:hover {
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 {
flex: 1;
overflow-y: auto;
padding: 28px 40px 60px;
padding: clamp(16px, 3vw, 40px) clamp(16px, 4vw, 40px) 60px;
transition: background 200ms ease;
min-width: 0;
}
.content-area::-webkit-scrollbar {
width: 8px;
@ -384,8 +495,12 @@ a:hover {
}
.file-actions {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.btn-copy-path {
.btn-action {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
padding: 4px 10px;
@ -395,11 +510,19 @@ a:hover {
color: var(--text-secondary);
cursor: pointer;
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);
border-color: var(--accent);
}
.btn-action.active {
color: var(--accent);
border-color: var(--accent);
background: var(--tag-bg);
}
/* Frontmatter collapsible */
.frontmatter-toggle {
@ -432,6 +555,24 @@ a:hover {
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 --- */
.md-content h1, .md-content h2, .md-content h3,
.md-content h4, .md-content h5, .md-content h6 {
@ -600,22 +741,82 @@ a:hover {
padding: 1px 6px;
}
/* --- Responsive --- */
@media (max-width: 768px) {
/* --- No-select during resize --- */
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 {
width: 220px;
min-width: 220px;
}
.content-area {
padding: 20px;
width: 240px;
min-width: 200px;
}
}
@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 {
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;
}
.content-area {
padding: 16px;
padding: 16px 12px 60px;
}
.file-title {
font-size: 1.2rem;
}
.file-actions {
flex-wrap: wrap;
}
}