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:
parent
8a22c1db28
commit
2ed5f65a7a
@ -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")
|
||||
|
||||
141
backend/main.py
141
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'<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")
|
||||
|
||||
366
frontend/app.js
366
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()]);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user