first commit

This commit is contained in:
Bruno Charest 2026-03-21 09:52:44 -04:00
commit e76e9ea962
14 changed files with 1848 additions and 0 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
__pycache__/
*.pyc
*.pyo
.env
test_vault/
.venv/
venv/
.git/
.gitignore
README.md
build.sh
docker-compose.yml

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
.env
test_vault/
.venv/
venv/
*.egg-info/
dist/
build/

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
# ObsiGate — Multi-platform Docker image
FROM python:3.11-alpine AS base
RUN apk add --no-cache gcc musl-dev libffi-dev
WORKDIR /app
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./backend/
COPY frontend/ ./frontend/
EXPOSE 8080
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8080"]

133
README.md Normal file
View File

@ -0,0 +1,133 @@
# ObsiGate
**Visionneur multi-vault Obsidian ultra-léger** — naviguez, recherchez et lisez vos notes Obsidian depuis n'importe quel appareil via une interface web moderne.
```
┌─────────────────────────────────────────────────────────┐
│ [🔍 Recherche...] [☀/🌙 Thème] ObsiGate │
├──────────────┬──────────────────────────────────────────┤
│ SIDEBAR │ CONTENT AREA │
│ ▼ Recettes │ 📄 Titre du fichier │
│ 📁 Soupes │ Tags: #recette #rapide
│ 📄 Pizza │ [Contenu Markdown rendu] │
│ ▼ IT │ │
│ 📁 Docker │ │
│ Tags Cloud │ │
└──────────────┴──────────────────────────────────────────┘
```
---
## Fonctionnalités
- **Multi-vault** : visualisez plusieurs vaults Obsidian simultanément
- **Navigation arborescente** : parcourez vos dossiers et fichiers dans la sidebar
- **Recherche fulltext** : recherche instantanée dans le contenu et les titres
- **Tag cloud** : filtrage par tags extraits des frontmatters YAML
- **Wikilinks** : les `[[liens internes]]` Obsidian sont cliquables
- **Syntax highlight** : coloration syntaxique des blocs de code
- **Thème clair/sombre** : toggle persisté en localStorage
- **Docker multi-platform** : linux/amd64, linux/arm64, linux/arm/v7, linux/386
- **Lecture seule** : aucune écriture sur vos vaults
---
## Prérequis
- **Docker** >= 20.10
- **docker-compose** >= 2.0
---
## Déploiement rapide
```bash
# 1. Build l'image
docker build -t obsigate:latest .
# 2. Adaptez les volumes dans docker-compose.yml
# 3. Lancez
docker-compose up -d
```
L'interface est accessible sur **http://localhost:8080**
---
## Variables d'environnement
Les vaults sont configurées par paires de variables `VAULT_N_NAME` / `VAULT_N_PATH` (N = 1, 2, 3…) :
| Variable | Description | Exemple |
|----------|-------------|---------|
| `VAULT_1_NAME` | Nom affiché de la vault | `Recettes` |
| `VAULT_1_PATH` | Chemin dans le conteneur | `/vaults/Obsidian-RECETTES` |
| `VAULT_2_NAME` | Nom affiché de la vault | `IT` |
| `VAULT_2_PATH` | Chemin dans le conteneur | `/vaults/Obsidian_IT` |
Ajoutez autant de paires `VAULT_N_*` que nécessaire.
---
## Ajouter une nouvelle vault
1. Ajoutez un volume dans `docker-compose.yml` :
```yaml
- /chemin/local/vers/vault:/vaults/MaVault:ro
```
2. Ajoutez les variables d'environnement :
```yaml
- VAULT_6_NAME=MaVault
- VAULT_6_PATH=/vaults/MaVault
```
3. Redémarrez :
```bash
docker-compose up -d
```
---
## Build multi-platform
```bash
chmod +x build.sh
./build.sh
```
Cela utilise `docker buildx` pour compiler l'image pour :
- `linux/amd64` (PC, serveurs)
- `linux/arm64` (Raspberry Pi 4, Apple Silicon)
- `linux/arm/v7` (Raspberry Pi 3)
- `linux/386` (Intel Atom i686)
> **Note** : `--load` ne fonctionne qu'en single-platform. Pour du multi-platform, utilisez `--push` vers un registry.
---
## Stack technique
- **Backend** : Python 3.11 + FastAPI + Uvicorn
- **Frontend** : Vanilla JS + HTML + CSS (zéro framework, zéro build)
- **Rendu Markdown** : mistune 3.x
- **Image Docker** : python:3.11-alpine (~150MB)
- **Pas de base de données** : index en mémoire uniquement
---
## API
| Endpoint | Description |
|----------|-------------|
| `GET /api/vaults` | Liste des vaults configurées |
| `GET /api/browse/{vault}?path=` | Navigation dans les dossiers |
| `GET /api/file/{vault}?path=` | Contenu rendu d'un fichier .md |
| `GET /api/search?q=&vault=&tag=` | Recherche fulltext |
| `GET /api/tags?vault=` | Tags uniques avec compteurs |
| `GET /api/index/reload` | Force un re-scan des vaults |
---
*Projet : ObsiGate | Auteur : Bruno Beloeil | Licence : MIT*

0
backend/__init__.py Normal file
View File

166
backend/indexer.py Normal file
View File

@ -0,0 +1,166 @@
import os
import asyncio
import logging
from pathlib import Path
from datetime import datetime, timezone
from typing import Dict, List, Optional, Any
import frontmatter
logger = logging.getLogger("obsigate.indexer")
# Global in-memory index
index: Dict[str, Dict[str, Any]] = {}
# Vault config: {name: path}
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}."""
vaults: Dict[str, str] = {}
n = 1
while True:
name = os.environ.get(f"VAULT_{n}_NAME")
path = os.environ.get(f"VAULT_{n}_PATH")
if not name or not path:
break
vaults[name] = path
n += 1
return vaults
def _extract_tags(post: frontmatter.Post) -> List[str]:
"""Extract tags from frontmatter 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 = []
return tags
def _extract_title(post: frontmatter.Post, filepath: Path) -> str:
"""Extract title from frontmatter or derive from filename."""
title = post.metadata.get("title", "")
if not title:
title = filepath.stem.replace("-", " ").replace("_", " ")
return str(title)
def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
"""Synchronously scan a single vault directory."""
vault_root = Path(vault_path)
files: List[Dict[str, Any]] = []
tag_counts: Dict[str, int] = {}
if not vault_root.exists():
logger.warning(f"Vault path does not exist: {vault_path}")
return {"files": [], "tags": {}, "path": vault_path}
for md_file in vault_root.rglob("*.md"):
try:
relative = md_file.relative_to(vault_root)
stat = md_file.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)
tags = _extract_tags(post)
title = _extract_title(post, md_file)
content_preview = post.content[:200].strip()
files.append({
"path": str(relative).replace("\\", "/"),
"title": title,
"tags": tags,
"content_preview": content_preview,
"size": stat.st_size,
"modified": modified,
})
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}")
continue
logger.info(f"Vault '{vault_name}': indexed {len(files)} files, {len(tag_counts)} unique tags")
return {"files": files, "tags": tag_counts, "path": vault_path}
async def build_index() -> None:
"""Build the full in-memory index for all configured vaults."""
global index, vault_config
vault_config = load_vault_config()
if not vault_config:
logger.warning("No vaults configured. Set VAULT_N_NAME / VAULT_N_PATH env vars.")
return
loop = asyncio.get_event_loop()
new_index: Dict[str, Dict[str, Any]] = {}
tasks = []
for name, path in vault_config.items():
tasks.append((name, loop.run_in_executor(None, _scan_vault, name, path)))
for name, task in tasks:
new_index[name] = await task
index.clear()
index.update(new_index)
total_files = sum(len(v["files"]) for v in index.values())
logger.info(f"Index built: {len(index)} vaults, {total_files} total files")
async def reload_index() -> Dict[str, Any]:
"""Force a full re-index and return stats."""
await build_index()
stats = {}
for name, data in index.items():
stats[name] = {"file_count": len(data["files"]), "tag_count": len(data["tags"])}
return stats
def get_vault_names() -> List[str]:
return list(index.keys())
def get_vault_data(vault_name: str) -> Optional[Dict[str, Any]]:
return index.get(vault_name)
def find_file_in_index(link_target: str, current_vault: str) -> Optional[Dict[str, str]]:
"""Find a file matching a wikilink target. Search current vault first, then all."""
target_lower = link_target.lower().strip()
if not target_lower.endswith(".md"):
target_lower += ".md"
def _search_vault(vname: str, vdata: Dict[str, Any]):
for f in vdata["files"]:
fpath = f["path"].lower()
fname = fpath.rsplit("/", 1)[-1]
if fname == target_lower or fpath == target_lower:
return {"vault": vname, "path": f["path"]}
return None
# Search current vault first
if current_vault in index:
result = _search_vault(current_vault, index[current_vault])
if result:
return result
# Search all other vaults
for vname, vdata in index.items():
if vname == current_vault:
continue
result = _search_vault(vname, vdata)
if result:
return result
return None

213
backend/main.py Normal file
View File

@ -0,0 +1,213 @@
import re
import logging
from pathlib import Path
from typing import Optional
import frontmatter
import mistune
from fastapi import FastAPI, HTTPException, Query
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse
from backend.indexer import (
build_index,
reload_index,
index,
get_vault_data,
find_file_in_index,
)
from backend.search import search, get_all_tags
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
logger = logging.getLogger("obsigate")
app = FastAPI(title="ObsiGate", version="1.0.0")
# Resolve frontend path relative to this file
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
# ---------------------------------------------------------------------------
# Startup
# ---------------------------------------------------------------------------
@app.on_event("startup")
async def startup_event():
logger.info("ObsiGate starting — building index...")
await build_index()
logger.info("ObsiGate ready.")
# ---------------------------------------------------------------------------
# Markdown rendering helpers
# ---------------------------------------------------------------------------
def _convert_wikilinks(content: str, current_vault: str) -> str:
"""Convert [[wikilinks]] and [[target|display]] to HTML links."""
def _replace(match):
target = match.group(1).strip()
display = match.group(2).strip() if match.group(2) else target
found = find_file_in_index(target, current_vault)
if found:
return (
f'<a class="wikilink" href="#" '
f'data-vault="{found["vault"]}" '
f'data-path="{found["path"]}">{display}</a>'
)
return f'<span class="wikilink-missing">{display}</span>'
pattern = r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
return re.sub(pattern, _replace, content)
def _render_markdown(raw_md: str, vault_name: str) -> str:
"""Render markdown string to HTML with wikilink support."""
converted = _convert_wikilinks(raw_md, vault_name)
md = mistune.create_markdown(
escape=False,
plugins=["table", "strikethrough", "footnotes", "task_lists"],
)
return md(converted)
# ---------------------------------------------------------------------------
# API Endpoints
# ---------------------------------------------------------------------------
@app.get("/api/vaults")
async def api_vaults():
"""List configured vaults with file counts."""
result = []
for name, data in index.items():
result.append({
"name": name,
"file_count": len(data["files"]),
"tag_count": len(data["tags"]),
})
return result
@app.get("/api/browse/{vault_name}")
async def api_browse(vault_name: str, path: str = ""):
"""Browse directories and files in a vault at a given path level."""
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"])
target = vault_root / path if path else vault_root
if not target.exists():
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
items = []
try:
for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
# Skip hidden files/dirs
if entry.name.startswith("."):
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"))
items.append({
"name": entry.name,
"path": rel,
"type": "directory",
"children_count": md_count,
})
elif entry.suffix.lower() == ".md":
items.append({
"name": entry.name,
"path": rel,
"type": "file",
"size": entry.stat().st_size,
})
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
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."""
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")
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),
}
@app.get("/api/search")
async def api_search(
q: str = Query("", description="Search query"),
vault: str = Query("all", description="Vault filter"),
tag: Optional[str] = Query(None, description="Tag filter"),
):
"""Full-text search across vaults."""
results = search(q, vault_filter=vault, tag_filter=tag)
return {"query": q, "vault_filter": vault, "tag_filter": tag, "count": len(results), "results": results}
@app.get("/api/tags")
async def api_tags(vault: Optional[str] = Query(None, description="Vault filter")):
"""Return all unique tags with counts."""
tags = get_all_tags(vault_filter=vault)
return {"vault_filter": vault, "tags": tags}
@app.get("/api/index/reload")
async def api_reload():
"""Force a re-index of all vaults."""
stats = await reload_index()
return {"status": "ok", "vaults": stats}
# ---------------------------------------------------------------------------
# Static files & SPA fallback
# ---------------------------------------------------------------------------
if FRONTEND_DIR.exists():
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Serve the SPA index.html for all non-API routes."""
index_file = FRONTEND_DIR / "index.html"
if index_file.exists():
return HTMLResponse(content=index_file.read_text(encoding="utf-8"))
raise HTTPException(status_code=404, detail="Frontend not found")

6
backend/requirements.txt Normal file
View File

@ -0,0 +1,6 @@
fastapi==0.111.0
uvicorn[standard]==0.30.0
python-frontmatter==1.1.0
mistune==3.0.2
python-multipart==0.0.9
aiofiles==23.2.1

109
backend/search.py Normal file
View File

@ -0,0 +1,109 @@
import re
import logging
from pathlib import Path
from typing import List, Dict, Any, Optional
from backend.indexer import index, get_vault_data
logger = logging.getLogger("obsigate.search")
def _read_file_content(vault_name: str, file_path: str) -> str:
"""Read raw markdown content of a file from disk."""
vault_data = get_vault_data(vault_name)
if not vault_data:
return ""
vault_root = Path(vault_data["path"])
full_path = vault_root / file_path
try:
return full_path.read_text(encoding="utf-8", errors="replace")
except Exception:
return ""
def _extract_snippet(content: str, query: str, context_chars: int = 120) -> str:
"""Extract a text snippet around the first occurrence of query."""
lower_content = content.lower()
lower_query = query.lower()
pos = lower_content.find(lower_query)
if pos == -1:
return content[:200].strip()
start = max(0, pos - context_chars)
end = min(len(content), pos + len(query) + context_chars)
snippet = content[start:end].strip()
if start > 0:
snippet = "..." + snippet
if end < len(content):
snippet = snippet + "..."
return snippet
def search(
query: str,
vault_filter: str = "all",
tag_filter: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
Full-text search across indexed vaults.
Returns scored results with snippets.
"""
query = query.strip() if query else ""
has_query = len(query) > 0
if not has_query and not tag_filter:
return []
results: List[Dict[str, Any]] = []
for vault_name, vault_data in index.items():
if vault_filter != "all" and vault_name != vault_filter:
continue
for file_info in vault_data["files"]:
if tag_filter and tag_filter not in file_info["tags"]:
continue
score = 0
snippet = file_info.get("content_preview", "")
if has_query:
# Title match (high weight)
if query.lower() in file_info["title"].lower():
score += 10
# Content match
content = _read_file_content(vault_name, file_info["path"])
if query.lower() in content.lower():
score += 1
snippet = _extract_snippet(content, query)
else:
# Tag-only filter: all matching files get score 1
score = 1
if score > 0:
results.append({
"vault": vault_name,
"path": file_info["path"],
"title": file_info["title"],
"tags": file_info["tags"],
"score": score,
"snippet": snippet,
"modified": file_info["modified"],
})
results.sort(key=lambda x: -x["score"])
return results
def get_all_tags(vault_filter: Optional[str] = None) -> Dict[str, int]:
"""Aggregate tag counts across vaults."""
merged: Dict[str, int] = {}
for vault_name, vault_data in index.items():
if vault_filter and vault_name != vault_filter:
continue
for tag, count in vault_data.get("tags", {}).items():
merged[tag] = merged.get(tag, 0) + count
return dict(sorted(merged.items(), key=lambda x: -x[1]))

25
build.sh Normal file
View File

@ -0,0 +1,25 @@
#!/bin/bash
# Build multi-platform ObsiGate Docker image
set -e
echo "=== ObsiGate Multi-Platform Build ==="
docker buildx create --use --name obsigate-builder 2>/dev/null || true
# Build for all target platforms
# Note: --load only works for single platform; use --push for multi-platform registry push.
# For local testing, build one platform at a time:
# docker buildx build --platform linux/amd64 --load -t obsigate:latest .
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/386 \
--tag obsigate:latest \
--tag obsigate:1.0.0 \
.
echo ""
echo "Build terminé."
echo "Pour un push vers un registry : ajoutez --push au build."
echo "Pour un test local (amd64) :"
echo " docker buildx build --platform linux/amd64 --load -t obsigate:latest ."
echo " docker-compose up -d"

26
docker-compose.yml Normal file
View File

@ -0,0 +1,26 @@
version: "3.9"
services:
obsigate:
image: obsigate:latest
container_name: obsigate
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /NFS/OBSIDIAN_DOC/Obsidian-RECETTES:/vaults/Obsidian-RECETTES:ro
- /NFS/OBSIDIAN_DOC/Obsidian_IT:/vaults/Obsidian_IT:ro
- /NFS/OBSIDIAN_DOC/Obsidian_MAIN:/vaults/Obsidian_MAIN:ro
- /NFS/OBSIDIAN_DOC/Obsidian_WORKOUT:/vaults/Obsidian_WORKOUT:ro
- /NFS/OBSIDIAN_DOC/SessionsManager:/vaults/SessionsManager:ro
environment:
- VAULT_1_NAME=Recettes
- VAULT_1_PATH=/vaults/Obsidian-RECETTES
- VAULT_2_NAME=IT
- VAULT_2_PATH=/vaults/Obsidian_IT
- VAULT_3_NAME=Main
- VAULT_3_PATH=/vaults/Obsidian_MAIN
- VAULT_4_NAME=Workout
- VAULT_4_PATH=/vaults/Obsidian_WORKOUT
- VAULT_5_NAME=Sessions
- VAULT_5_PATH=/vaults/SessionsManager

444
frontend/app.js Normal file
View File

@ -0,0 +1,444 @@
/* ObsiGate — Vanilla JS SPA */
(function () {
"use strict";
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let currentVault = null;
let currentPath = null;
let searchTimeout = null;
// ---------------------------------------------------------------------------
// Safe CDN helpers
// ---------------------------------------------------------------------------
function safeCreateIcons() {
if (typeof lucide !== "undefined" && lucide.createIcons) {
try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ }
}
}
function safeHighlight(block) {
if (typeof hljs !== "undefined" && hljs.highlightElement) {
try { hljs.highlightElement(block); } catch (e) { /* CDN not loaded */ }
}
}
// ---------------------------------------------------------------------------
// Theme
// ---------------------------------------------------------------------------
function initTheme() {
const saved = localStorage.getItem("obsigate-theme") || "dark";
applyTheme(saved);
}
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("obsigate-theme", theme);
const icon = document.getElementById("theme-icon");
if (icon) {
icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun");
safeCreateIcons();
}
// Swap highlight.js theme
const darkSheet = document.getElementById("hljs-theme-dark");
const lightSheet = document.getElementById("hljs-theme-light");
if (darkSheet && lightSheet) {
darkSheet.disabled = theme !== "dark";
lightSheet.disabled = theme !== "light";
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme");
applyTheme(current === "dark" ? "light" : "dark");
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
async function api(path) {
const res = await fetch(path);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
// ---------------------------------------------------------------------------
// Sidebar — Vault tree
// ---------------------------------------------------------------------------
async function loadVaults() {
const vaults = await api("/api/vaults");
const container = document.getElementById("vault-tree");
const filter = document.getElementById("vault-filter");
container.innerHTML = "";
vaults.forEach((v) => {
// Sidebar tree entry
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [
icon("chevron-right", 14),
icon("database", 16),
document.createTextNode(` ${v.name} `),
smallBadge(v.file_count),
]);
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
container.appendChild(vaultItem);
const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` });
container.appendChild(childContainer);
// Vault filter dropdown
const opt = document.createElement("option");
opt.value = v.name;
opt.textContent = v.name;
filter.appendChild(opt);
});
safeCreateIcons();
}
async function toggleVault(itemEl, vaultName) {
const childContainer = document.getElementById(`vault-children-${vaultName}`);
if (!childContainer) return;
if (childContainer.classList.contains("collapsed")) {
// Expand — load children if empty
if (childContainer.children.length === 0) {
await loadDirectory(vaultName, "", childContainer);
}
childContainer.classList.remove("collapsed");
// Swap chevron
const chevron = itemEl.querySelector("[data-lucide]");
if (chevron) chevron.setAttribute("data-lucide", "chevron-down");
safeCreateIcons();
} else {
childContainer.classList.add("collapsed");
const chevron = itemEl.querySelector("[data-lucide]");
if (chevron) chevron.setAttribute("data-lucide", "chevron-right");
safeCreateIcons();
}
}
async function loadDirectory(vaultName, dirPath, container) {
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
const data = await api(url);
container.innerHTML = "";
data.items.forEach((item) => {
if (item.type === "directory") {
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [
icon("chevron-right", 14),
icon("folder", 16),
document.createTextNode(` ${item.name} `),
smallBadge(item.children_count),
]);
container.appendChild(dirItem);
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
container.appendChild(subContainer);
dirItem.addEventListener("click", async () => {
if (subContainer.classList.contains("collapsed")) {
if (subContainer.children.length === 0) {
await loadDirectory(vaultName, item.path, subContainer);
}
subContainer.classList.remove("collapsed");
const chev = dirItem.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-down");
safeCreateIcons();
} else {
subContainer.classList.add("collapsed");
const chev = dirItem.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-right");
safeCreateIcons();
}
});
} else {
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [
icon("file-text", 16),
document.createTextNode(` ${item.name.replace(/\.md$/i, "")}`),
]);
fileItem.addEventListener("click", () => openFile(vaultName, item.path));
container.appendChild(fileItem);
}
});
safeCreateIcons();
}
// ---------------------------------------------------------------------------
// Tags
// ---------------------------------------------------------------------------
async function loadTags() {
const data = await api("/api/tags");
const cloud = document.getElementById("tag-cloud");
cloud.innerHTML = "";
const tags = data.tags;
const counts = Object.values(tags);
if (counts.length === 0) return;
const maxCount = Math.max(...counts);
const minSize = 0.7;
const maxSize = 1.25;
Object.entries(tags).forEach(([tag, count]) => {
const ratio = maxCount > 1 ? (count - 1) / (maxCount - 1) : 0;
const size = minSize + ratio * (maxSize - minSize);
const tagEl = el("span", { class: "tag-item", style: `font-size:${size}rem` }, [
document.createTextNode(`#${tag}`),
]);
tagEl.addEventListener("click", () => searchByTag(tag));
cloud.appendChild(tagEl);
});
}
function searchByTag(tag) {
const input = document.getElementById("search-input");
input.value = "";
performSearch("", "all", tag);
}
// ---------------------------------------------------------------------------
// File viewer
// ---------------------------------------------------------------------------
async function openFile(vaultName, filePath) {
currentVault = vaultName;
currentPath = filePath;
// 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 url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
const data = await api(url);
renderFile(data);
}
function renderFile(data) {
const area = document.getElementById("content-area");
// Breadcrumb
const parts = data.path.split("/");
const breadcrumbEls = [];
breadcrumbEls.push(makeBreadcrumbSpan(data.vault, () => {}));
let accumulated = "";
parts.forEach((part, i) => {
breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")]));
accumulated += (accumulated ? "/" : "") + part;
const p = accumulated;
if (i < parts.length - 1) {
breadcrumbEls.push(makeBreadcrumbSpan(part, () => {}));
} else {
breadcrumbEls.push(el("span", {}, [document.createTextNode(part.replace(/\.md$/i, ""))]));
}
});
const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls);
// Tags
const tagsDiv = el("div", { class: "file-tags" });
(data.tags || []).forEach((tag) => {
const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
t.addEventListener("click", () => searchByTag(tag));
tagsDiv.appendChild(t);
});
// Copy path button
const copyBtn = el("button", { class: "btn-copy-path" }, [document.createTextNode("Copier le chemin")]);
copyBtn.addEventListener("click", () => {
navigator.clipboard.writeText(`${data.vault}/${data.path}`).then(() => {
copyBtn.textContent = "Copié !";
setTimeout(() => (copyBtn.textContent = "Copier le chemin"), 1500);
});
});
// Frontmatter
let fmSection = null;
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
const fmToggle = el("div", { class: "frontmatter-toggle" }, [
document.createTextNode("▶ Frontmatter"),
]);
const fmContent = el("div", { class: "frontmatter-content" }, [
document.createTextNode(JSON.stringify(data.frontmatter, null, 2)),
]);
fmToggle.addEventListener("click", () => {
fmContent.classList.toggle("open");
fmToggle.textContent = fmContent.classList.contains("open") ? "▼ Frontmatter" : "▶ Frontmatter";
});
fmSection = el("div", {}, [fmToggle, fmContent]);
}
// Markdown content
const mdDiv = el("div", { class: "md-content" });
mdDiv.innerHTML = data.html;
// 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]),
]));
if (fmSection) area.appendChild(fmSection);
area.appendChild(mdDiv);
// Highlight code blocks
area.querySelectorAll("pre code").forEach((block) => {
safeHighlight(block);
});
// Wire up wikilinks
area.querySelectorAll(".wikilink").forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault();
const v = link.getAttribute("data-vault");
const p = link.getAttribute("data-path");
if (v && p) openFile(v, p);
});
});
area.scrollTop = 0;
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
function initSearch() {
const input = document.getElementById("search-input");
input.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const q = input.value.trim();
const vault = document.getElementById("vault-filter").value;
if (q.length > 0) {
performSearch(q, vault, null);
} else {
showWelcome();
}
}, 300);
});
}
async function performSearch(query, vaultFilter, tagFilter) {
let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`;
if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
const data = await api(url);
renderSearchResults(data, query, tagFilter);
}
function renderSearchResults(data, query, tagFilter) {
const area = document.getElementById("content-area");
area.innerHTML = "";
const header = el("div", { class: "search-results-header" });
if (query) {
header.textContent = `${data.count} résultat(s) pour "${query}"`;
} else if (tagFilter) {
header.textContent = `${data.count} fichier(s) avec le tag #${tagFilter}`;
}
area.appendChild(header);
if (data.results.length === 0) {
area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [
document.createTextNode("Aucun résultat trouvé."),
]));
return;
}
const container = el("div", { class: "search-results" });
data.results.forEach((r) => {
const item = el("div", { class: "search-result-item" }, [
el("div", { class: "search-result-title" }, [document.createTextNode(r.title)]),
el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path)]),
el("div", { class: "search-result-snippet" }, [document.createTextNode(r.snippet || "")]),
]);
if (r.tags && r.tags.length > 0) {
const tagsDiv = el("div", { class: "search-result-tags" });
r.tags.forEach((tag) => {
tagsDiv.appendChild(el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]));
});
item.appendChild(tagsDiv);
}
item.addEventListener("click", () => openFile(r.vault, r.path));
container.appendChild(item);
});
area.appendChild(container);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function el(tag, attrs, children) {
const e = document.createElement(tag);
if (attrs) {
Object.entries(attrs).forEach(([k, v]) => e.setAttribute(k, v));
}
if (children) {
children.forEach((c) => { if (c) e.appendChild(c); });
}
return e;
}
function icon(name, size) {
const i = document.createElement("i");
i.setAttribute("data-lucide", name);
i.style.width = size + "px";
i.style.height = size + "px";
i.classList.add("icon");
return i;
}
function smallBadge(count) {
const s = document.createElement("span");
s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px";
s.textContent = `(${count})`;
return s;
}
function makeBreadcrumbSpan(text, onClick) {
const s = document.createElement("span");
s.textContent = text;
if (onClick) s.addEventListener("click", onClick);
return s;
}
function showWelcome() {
const area = document.getElementById("content-area");
area.innerHTML = `
<div class="welcome">
<i data-lucide="library" style="width:48px;height:48px;color:var(--text-muted)"></i>
<h2>ObsiGate</h2>
<p>Sélectionnez un fichier dans la sidebar ou utilisez la recherche pour commencer.</p>
</div>`;
safeCreateIcons();
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
async function init() {
initTheme();
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
initSearch();
try {
await Promise.all([loadVaults(), loadTags()]);
} catch (err) {
console.error("Failed to initialize ObsiGate:", err);
}
safeCreateIcons();
}
document.addEventListener("DOMContentLoaded", init);
})();

67
frontend/index.html Normal file
View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ObsiGate</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" id="hljs-theme-dark">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-theme-light" disabled>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="header">
<div class="header-logo">
<i data-lucide="book-open" style="width:20px;height:20px"></i>
ObsiGate
</div>
<div class="search-wrapper">
<i data-lucide="search" class="search-icon" style="width:16px;height:16px"></i>
<input type="text" id="search-input" placeholder="Recherche..." autocomplete="off">
</div>
<select id="vault-filter" class="search-vault-filter">
<option value="all">Toutes les vaults</option>
</select>
<button class="theme-toggle" id="theme-toggle" title="Changer le thème">
<i data-lucide="moon" id="theme-icon" style="width:18px;height:18px"></i>
</button>
</header>
<!-- Main -->
<div class="main-body">
<!-- Sidebar -->
<aside class="sidebar">
<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">
<div class="tag-cloud-title">Tags</div>
<div class="tag-cloud" id="tag-cloud"></div>
</div>
</aside>
<!-- Content -->
<main class="content-area" id="content-area">
<div class="welcome" id="welcome">
<i data-lucide="library" style="width:48px;height:48px;color:var(--text-muted)"></i>
<h2>ObsiGate</h2>
<p>Sélectionnez un fichier dans la sidebar ou utilisez la recherche pour commencer.</p>
</div>
</main>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

621
frontend/style.css Normal file
View File

@ -0,0 +1,621 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Lora:ital,wght@0,400;0,600;1,400&display=swap');
/* ===== RESET ===== */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* ===== THEME — DARK (default) ===== */
:root[data-theme="dark"] {
--bg-primary: #0f1117;
--bg-secondary: #161b22;
--bg-sidebar: #13161d;
--bg-hover: #1f2430;
--border: #21262d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #484f58;
--accent: #58a6ff;
--accent-green: #3fb950;
--tag-bg: #1f2a3a;
--tag-text: #79c0ff;
--code-bg: #161b22;
--search-bg: #21262d;
--scrollbar: #30363d;
}
/* ===== THEME — LIGHT ===== */
:root[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-sidebar: #f0f2f5;
--bg-hover: #e8ecf0;
--border: #d0d7de;
--text-primary: #1f2328;
--text-secondary: #57606a;
--text-muted: #9198a1;
--accent: #0969da;
--accent-green: #1a7f37;
--tag-bg: #ddf4ff;
--tag-text: #0969da;
--code-bg: #f6f8fa;
--search-bg: #ffffff;
--scrollbar: #d0d7de;
}
/* ===== BASE ===== */
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: 'Lora', Georgia, serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.7;
transition: background 200ms ease, color 200ms ease;
overflow: hidden;
height: 100vh;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ===== LAYOUT ===== */
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
}
/* --- Header --- */
.header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
z-index: 100;
transition: background 200ms ease;
}
.header-logo {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 1.15rem;
color: var(--accent);
white-space: nowrap;
margin-right: auto;
display: flex;
align-items: center;
gap: 8px;
}
.search-wrapper {
flex: 1;
max-width: 520px;
position: relative;
}
.search-wrapper input {
width: 100%;
padding: 8px 14px 8px 38px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--search-bg);
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
outline: none;
transition: border-color 200ms ease, background 200ms ease;
}
.search-wrapper input:focus {
border-color: var(--accent);
}
.search-wrapper .search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
.search-vault-filter {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--search-bg);
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
outline: none;
cursor: pointer;
}
.theme-toggle {
background: none;
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
color: var(--text-secondary);
display: flex;
align-items: center;
transition: color 200ms ease, border-color 200ms ease;
}
.theme-toggle:hover {
color: var(--accent);
border-color: var(--accent);
}
/* --- Main body --- */
.main-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* --- Sidebar --- */
.sidebar {
width: 280px;
min-width: 280px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
transition: background 200ms ease;
}
.sidebar-tree {
flex: 1;
overflow-y: auto;
padding: 12px 0;
}
.sidebar-tree::-webkit-scrollbar {
width: 6px;
}
.sidebar-tree::-webkit-scrollbar-thumb {
background: var(--scrollbar);
border-radius: 3px;
}
.sidebar-section-title {
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
padding: 8px 16px 4px;
}
.tree-item {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.82rem;
color: var(--text-secondary);
cursor: pointer;
border-radius: 0;
transition: background 120ms ease, color 120ms ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.tree-item.active {
background: var(--bg-hover);
color: var(--accent);
}
.tree-item .icon {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--text-muted);
}
.tree-item.active .icon {
color: var(--accent);
}
.tree-item.vault-item .icon {
color: var(--accent-green);
}
.tree-children {
padding-left: 16px;
}
.tree-children.collapsed {
display: none;
}
/* --- Tag Cloud --- */
.tag-cloud-section {
border-top: 1px solid var(--border);
padding: 12px 16px;
max-height: 180px;
overflow-y: auto;
}
.tag-cloud-section::-webkit-scrollbar {
width: 6px;
}
.tag-cloud-section::-webkit-scrollbar-thumb {
background: var(--scrollbar);
border-radius: 3px;
}
.tag-cloud-title {
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-bottom: 8px;
}
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-item {
display: inline-block;
padding: 2px 8px;
background: var(--tag-bg);
color: var(--tag-text);
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
cursor: pointer;
transition: opacity 150ms ease;
line-height: 1.4;
}
.tag-item:hover {
opacity: 0.8;
}
/* --- Content Area --- */
.content-area {
flex: 1;
overflow-y: auto;
padding: 28px 40px 60px;
transition: background 200ms ease;
}
.content-area::-webkit-scrollbar {
width: 8px;
}
.content-area::-webkit-scrollbar-thumb {
background: var(--scrollbar);
border-radius: 4px;
}
/* Welcome */
.welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
text-align: center;
gap: 12px;
}
.welcome h2 {
font-family: 'JetBrains Mono', monospace;
font-size: 1.6rem;
color: var(--text-secondary);
}
.welcome p {
font-size: 0.95rem;
max-width: 400px;
}
/* Breadcrumb */
.breadcrumb {
font-family: 'JetBrains Mono', monospace;
font-size: 0.78rem;
color: var(--text-muted);
margin-bottom: 12px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.breadcrumb span {
cursor: pointer;
color: var(--text-secondary);
transition: color 120ms ease;
}
.breadcrumb span:hover {
color: var(--accent);
}
.breadcrumb .sep {
cursor: default;
color: var(--text-muted);
}
.breadcrumb .sep:hover {
color: var(--text-muted);
}
/* File header */
.file-header {
margin-bottom: 20px;
}
.file-title {
font-family: 'JetBrains Mono', monospace;
font-size: 1.6rem;
font-weight: 700;
margin-bottom: 8px;
}
.file-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
.file-tag {
display: inline-block;
padding: 2px 8px;
background: var(--tag-bg);
color: var(--tag-text);
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.78rem;
cursor: pointer;
}
.file-actions {
margin-top: 8px;
}
.btn-copy-path {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: color 150ms ease, border-color 150ms ease;
}
.btn-copy-path:hover {
color: var(--accent);
border-color: var(--accent);
}
/* Frontmatter collapsible */
.frontmatter-toggle {
font-family: 'JetBrains Mono', monospace;
font-size: 0.78rem;
color: var(--text-muted);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
margin-bottom: 12px;
user-select: none;
}
.frontmatter-toggle:hover {
color: var(--text-secondary);
}
.frontmatter-content {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 20px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
color: var(--text-secondary);
white-space: pre-wrap;
display: none;
}
.frontmatter-content.open {
display: block;
}
/* --- Markdown Rendered Content --- */
.md-content h1, .md-content h2, .md-content h3,
.md-content h4, .md-content h5, .md-content h6 {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
margin: 1.4em 0 0.5em;
color: var(--text-primary);
}
.md-content h1 { font-size: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.md-content h2 { font-size: 1.25rem; }
.md-content h3 { font-size: 1.1rem; }
.md-content p {
margin: 0.6em 0;
}
.md-content ul, .md-content ol {
padding-left: 1.6em;
margin: 0.5em 0;
}
.md-content li {
margin: 0.25em 0;
}
.md-content blockquote {
border-left: 3px solid var(--accent);
padding: 4px 16px;
margin: 0.8em 0;
color: var(--text-secondary);
background: var(--bg-secondary);
border-radius: 0 6px 6px 0;
}
.md-content code {
font-family: 'JetBrains Mono', monospace;
background: var(--code-bg);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.88em;
border: 1px solid var(--border);
}
.md-content pre {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
overflow-x: auto;
margin: 0.8em 0;
}
.md-content pre code {
background: none;
border: none;
padding: 0;
font-size: 0.85rem;
line-height: 1.55;
}
.md-content table {
width: 100%;
border-collapse: collapse;
margin: 0.8em 0;
font-size: 0.92rem;
}
.md-content th, .md-content td {
border: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
}
.md-content th {
background: var(--bg-secondary);
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
font-size: 0.85rem;
}
.md-content img {
max-width: 100%;
border-radius: 8px;
margin: 0.6em 0;
}
.md-content hr {
border: none;
border-top: 1px solid var(--border);
margin: 1.5em 0;
}
.md-content .task-list-item {
list-style: none;
margin-left: -1.2em;
}
.md-content .task-list-item input[type="checkbox"] {
margin-right: 6px;
}
/* Wikilinks */
.wikilink {
color: var(--accent);
border-bottom: 1px dashed var(--accent);
cursor: pointer;
}
.wikilink:hover {
opacity: 0.8;
}
.wikilink-missing {
color: var(--text-muted);
border-bottom: 1px dashed var(--text-muted);
cursor: default;
}
/* --- Search Results --- */
.search-results {
padding: 0;
}
.search-results-header {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 16px;
}
.search-result-item {
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 10px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.search-result-item:hover {
background: var(--bg-hover);
border-color: var(--accent);
}
.search-result-title {
font-family: 'JetBrains Mono', monospace;
font-size: 0.92rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.search-result-vault {
font-family: 'JetBrains Mono', monospace;
font-size: 0.72rem;
color: var(--accent-green);
margin-bottom: 4px;
}
.search-result-snippet {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
}
.search-result-tags {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.search-result-tags .file-tag {
font-size: 0.7rem;
padding: 1px 6px;
}
/* --- Responsive --- */
@media (max-width: 768px) {
.sidebar {
width: 220px;
min-width: 220px;
}
.content-area {
padding: 20px;
}
}
@media (max-width: 600px) {
.sidebar {
display: none;
}
.content-area {
padding: 16px;
}
}