From e76e9ea96278ad763489d06ea2bea3dd405342ba Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 21 Mar 2026 09:52:44 -0400 Subject: [PATCH] first commit --- .dockerignore | 12 + .gitignore | 10 + Dockerfile | 16 + README.md | 133 +++++++++ backend/__init__.py | 0 backend/indexer.py | 166 +++++++++++ backend/main.py | 213 ++++++++++++++ backend/requirements.txt | 6 + backend/search.py | 109 +++++++ build.sh | 25 ++ docker-compose.yml | 26 ++ frontend/app.js | 444 ++++++++++++++++++++++++++++ frontend/index.html | 67 +++++ frontend/style.css | 621 +++++++++++++++++++++++++++++++++++++++ 14 files changed, 1848 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 backend/__init__.py create mode 100644 backend/indexer.py create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 backend/search.py create mode 100644 build.sh create mode 100644 docker-compose.yml create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/style.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6c9c89c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +__pycache__/ +*.pyc +*.pyo +.env +test_vault/ +.venv/ +venv/ +.git/ +.gitignore +README.md +build.sh +docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31106d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.pyo +.env +test_vault/ +.venv/ +venv/ +*.egg-info/ +dist/ +build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0546fc5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..386bf0a --- /dev/null +++ b/README.md @@ -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* diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/indexer.py b/backend/indexer.py new file mode 100644 index 0000000..5b2ca68 --- /dev/null +++ b/backend/indexer.py @@ -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 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..f79d0eb --- /dev/null +++ b/backend/main.py @@ -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'{display}' + ) + return f'{display}' + + 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") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c306314 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/search.py b/backend/search.py new file mode 100644 index 0000000..df4b5fc --- /dev/null +++ b/backend/search.py @@ -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])) diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..fe417cd --- /dev/null +++ b/build.sh @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6c1784b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..7eddb8c --- /dev/null +++ b/frontend/app.js @@ -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 = ` +
+ +

ObsiGate

+

Sélectionnez un fichier dans la sidebar ou utilisez la recherche pour commencer.

+
`; + 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); +})(); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..cba25eb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,67 @@ + + + + + + ObsiGate + + + + + + + +
+ + +
+ + +
+ + +
+ + + + +
+ + +
+ + + + + +
+
+ +

ObsiGate

+

Sélectionnez un fichier dans la sidebar ou utilisez la recherche pour commencer.

+
+
+ +
+
+ + + + diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..5b04c19 --- /dev/null +++ b/frontend/style.css @@ -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; + } +}