From feeb46b87302d3f311deafe2f0cecf05e7faa087 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 14 Jun 2026 20:27:37 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20finalisation=20=C3=A9diteur=20Forge=20+?= =?UTF-8?q?=20mode=20hors-ligne=20PWA=20+=20consolidation=20roadmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Forge: Tab indent, Ctrl+K, auto-focus, AI status, messages erreur FR - Forge: slash commands clean, cache-bust iframe, preview serveur - Forge: iframe 82vh, hauteur auto, table inline, sélecteur langage - PWA hors-ligne: IndexedDB (offline-db.js + offline.js) - Mermaid: thème dark complet, highlight.js preview - Backend: màj CSP, rename chain, bookmarks/history/shares - Roadmap: consolidation dans docs/ROADMAP.md (75 items) - Infra: docker-compose.podman.yml, suppression test-vault - .gitignore: exclusion odysseus/ --- .gitignore | 2 + backend/ai.py | 65 ++- backend/ai_routes.py | 4 +- backend/history.py | 43 ++ backend/main.py | 122 +++++- backend/share.py | 13 + docker-compose.podman.yml | 23 ++ docker-compose.test.yml | 7 +- frontend/editor-poc.html | 460 ++++++++++++++++++++-- frontend/index.html | 120 +++--- frontend/js/config.js | 82 ++++ frontend/js/offline-db.js | 377 ++++++++++++++++++ frontend/js/offline.js | 209 ++++++++++ frontend/js/search.js | 22 ++ frontend/js/sync.js | 40 +- frontend/js/ui.js | 13 +- frontend/js/utils.js | 78 ++-- frontend/js/viewer.js | 10 +- frontend/style.css | 134 +++++++ frontend/sw.js | 241 +++++------- test-vault/Accueil.md | 22 -- test-vault/Recettes/Pâtes carbonara.md | 29 -- test-vault/Recettes/Tiramisu.md | 28 -- test-vault/mermaid/test-all-mermaid.md | 442 --------------------- test-vault/notes/Configuration serveur.md | 32 -- test-vault/notes/Docker tips.md | 22 -- test-vault/notes/Performance.md | 20 - test-vault/notes/Sécurité.md | 31 -- test-vault/projets/Projet Alpha.md | 31 -- test-vault/projets/Projet Beta.md | 21 - test-vault/subfolder/Document caché.md | 7 - 31 files changed, 1769 insertions(+), 981 deletions(-) create mode 100644 docker-compose.podman.yml create mode 100644 frontend/js/offline-db.js create mode 100644 frontend/js/offline.js delete mode 100644 test-vault/Accueil.md delete mode 100644 test-vault/Recettes/Pâtes carbonara.md delete mode 100644 test-vault/Recettes/Tiramisu.md delete mode 100644 test-vault/mermaid/test-all-mermaid.md delete mode 100644 test-vault/notes/Configuration serveur.md delete mode 100644 test-vault/notes/Docker tips.md delete mode 100644 test-vault/notes/Performance.md delete mode 100644 test-vault/notes/Sécurité.md delete mode 100644 test-vault/projets/Projet Alpha.md delete mode 100644 test-vault/projets/Projet Beta.md delete mode 100644 test-vault/subfolder/Document caché.md diff --git a/.gitignore b/.gitignore index 1ba088e..bb7bad6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ .env.* !.env.example test_vault/ +test_dir/ .venv/ venv/ *.egg-info/ @@ -14,3 +15,4 @@ config.json data/ .coverage .coverage +odysseus/ diff --git a/backend/ai.py b/backend/ai.py index 07ad0e3..845b264 100644 --- a/backend/ai.py +++ b/backend/ai.py @@ -5,7 +5,9 @@ Configured via environment variables. """ import os +import json import logging +from pathlib import Path import httpx from typing import Optional, Literal @@ -13,27 +15,48 @@ logger = logging.getLogger("obsigate.ai") ProviderName = Literal["deepseek", "openrouter", "gemini"] -# Provider configurations -PROVIDERS = { - "deepseek": { - "api_key": os.getenv("DEEPSEEK_API_KEY", "").strip(), - "base_url": "https://api.deepseek.com/v1", - "model": os.getenv("DEEPSEEK_MODEL", "deepseek-chat"), - "auth_header": "Bearer {api_key}", - }, - "openrouter": { - "api_key": os.getenv("OPENROUTER_API_KEY", "").strip(), - "base_url": "https://openrouter.ai/api/v1", - "model": os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini"), - "auth_header": "Bearer {api_key}", - }, - "gemini": { - "api_key": os.getenv("GEMINI_API_KEY", "").strip(), - "base_url": "https://generativelanguage.googleapis.com/v1beta", - "model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash"), - "auth_header": None, # Uses query param ?key= - }, -} +# Provider configurations — keys loaded from file or .env +AI_KEYS_FILE = Path("data/api_keys.json") + +def _read_ai_keys() -> dict: + if not AI_KEYS_FILE.exists(): + return {} + try: + return json.loads(AI_KEYS_FILE.read_text(encoding="utf-8")) + except Exception: + return {} + +def get_ai_key(env_name: str) -> str: + """Get AI key: stored file first, then .env fallback.""" + keys = _read_ai_keys() + if env_name in keys and keys[env_name]: + return keys[env_name] + return os.getenv(env_name, "").strip() + +def _load_provider_keys(): + """Load AI keys from stored file, falling back to .env.""" + return { + "deepseek": { + "api_key": get_ai_key("DEEPSEEK_API_KEY"), + "base_url": "https://api.deepseek.com/v1", + "model": os.getenv("DEEPSEEK_MODEL", "deepseek-chat"), + "auth_header": "Bearer {api_key}", + }, + "openrouter": { + "api_key": get_ai_key("OPENROUTER_API_KEY"), + "base_url": "https://openrouter.ai/api/v1", + "model": os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini"), + "auth_header": "Bearer {api_key}", + }, + "gemini": { + "api_key": get_ai_key("GEMINI_API_KEY"), + "base_url": "https://generativelanguage.googleapis.com/v1beta", + "model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash"), + "auth_header": None, + }, + } + +PROVIDERS = _load_provider_keys() DEFAULT_PROVIDER: ProviderName = os.getenv("AI_DEFAULT_PROVIDER", "deepseek") # type: ignore diff --git a/backend/ai_routes.py b/backend/ai_routes.py index fb82b1a..83390b9 100644 --- a/backend/ai_routes.py +++ b/backend/ai_routes.py @@ -24,7 +24,7 @@ async def api_status(): Reads environment variables at runtime so that changes to .env or Docker environment are reflected immediately. """ - import os as _os + from backend.ai import get_ai_key provider_keys = { "deepseek": "DEEPSEEK_API_KEY", "openrouter": "OPENROUTER_API_KEY", @@ -32,7 +32,7 @@ async def api_status(): } providers = {} for name, env_var in provider_keys.items(): - has_key = bool(_os.getenv(env_var, "").strip()) + has_key = bool(get_ai_key(env_var)) providers[name] = { "available": has_key, "model": PROVIDERS[name]["model"] if has_key else None, diff --git a/backend/history.py b/backend/history.py index a11d25f..c6f3404 100644 --- a/backend/history.py +++ b/backend/history.py @@ -123,3 +123,46 @@ def is_bookmarked(username: str, vault: str, path: str) -> bool: return False bookmarks = _read_data(_get_bookmarks_file(username)) return any(b["vault"] == vault and b["path"] == path for b in bookmarks) + + +def update_bookmarks_after_rename(vault: str, old_path: str, new_path: str): + """Update all users' bookmarks when a file is renamed.""" + if not HISTORY_DIR.exists(): + return + for bfile in HISTORY_DIR.glob("*_bookmarks.json"): + try: + bookmarks = _read_data(bfile) + updated = False + for b in bookmarks: + if b.get("vault") == vault and b.get("path") == old_path: + b["path"] = new_path + # Update title from new path + new_title = new_path.split("/")[-1].replace(".md", "").replace("-", " ").replace("_", " ") + b["title"] = new_title + updated = True + if updated: + _write_data(bfile, bookmarks) + logger.info(f"Updated bookmarks in {bfile.name}: {vault}/{old_path} -> {new_path}") + except Exception as e: + logger.error(f"Failed to update bookmarks in {bfile.name}: {e}") + + +def update_history_after_rename(vault: str, old_path: str, new_path: str): + """Update all users' history entries when a file is renamed.""" + if not HISTORY_DIR.exists(): + return + for hfile in HISTORY_DIR.glob("*.json"): + if "_bookmarks" in hfile.name: + continue + try: + history = _read_data(hfile) + updated = False + for h in history: + if h.get("vault") == vault and h.get("path") == old_path: + h["path"] = new_path + updated = True + if updated: + _write_data(hfile, history) + logger.info(f"Updated history in {hfile.name}: {vault}/{old_path} -> {new_path}") + except Exception as e: + logger.error(f"Failed to update history in {hfile.name}: {e}") diff --git a/backend/main.py b/backend/main.py index 589a412..5aa65b5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,6 +9,7 @@ import secrets import shutil import string import time +import urllib.request from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager from datetime import datetime, timezone @@ -50,7 +51,7 @@ from backend.vault_settings import ( get_vault_setting, update_vault_setting, ) -from backend.history import record_open, get_recent_opened, remove_recent, toggle_bookmark, get_bookmarks, is_bookmarked +from backend.history import record_open, get_recent_opened, remove_recent, toggle_bookmark, get_bookmarks, is_bookmarked, update_bookmarks_after_rename, update_history_after_rename logging.basicConfig( level=logging.INFO, @@ -545,9 +546,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response.headers["Content-Security-Policy"] = ( "default-src 'self'; " "script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net; " - "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com; " + "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net; " "img-src 'self' data: blob:; " - "connect-src 'self' https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com; " + "connect-src 'self' https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; " "font-src 'self' https://fonts.gstatic.com;" ) # Cache static assets aggressively (fingerprinted by PWA manifest hash) @@ -654,7 +655,7 @@ except OSError: build_pdf_html = None # type: ignore[assignment] import logging logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)") -from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares # noqa: E402 +from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares, update_shares_after_rename # noqa: E402 from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks # noqa: E402 from backend.saved_searches import get_saved, save_search, delete_saved # noqa: E402 from backend.ai_routes import router as ai_router # noqa: E402 @@ -1814,6 +1815,11 @@ async def api_file_rename( # Update index await handle_file_move(vault_name, old_path_str, new_path_str) + # Update bookmarks, history, and shares + update_bookmarks_after_rename(vault_name, old_path_str, new_path_str) + update_history_after_rename(vault_name, old_path_str, new_path_str) + update_shares_after_rename(vault_name, old_path_str, new_path_str) + # Broadcast SSE event await sse_manager.broadcast("file_renamed", { "vault": vault_name, @@ -3602,6 +3608,114 @@ async def api_set_config(body: dict = Body(...), current_user=Depends(require_ad return current +# --------------------------------------------------------------------------- +# AI API Keys — stored in data/api_keys.json, fallback to .env +# --------------------------------------------------------------------------- + +from backend.ai import get_ai_key, _read_ai_keys # noqa: E402 + +AI_KEYS_FILE = Path("data/api_keys.json") + +def _write_ai_keys(data: dict): + AI_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = AI_KEYS_FILE.with_suffix(".tmp") + tmp.write_text(_json.dumps(data, indent=2), encoding="utf-8") + tmp.replace(AI_KEYS_FILE) + +@app.get("/api/config/ai-keys") +async def api_get_ai_keys(current_user=Depends(require_admin)): + """Return stored AI keys (values masked).""" + keys = _read_ai_keys() + masked = {} + for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]: + val = keys.get(k, "") or os.environ.get(k, "") + if val: + masked[k] = val[:4] + "..." + val[-4:] if len(val) > 8 else "***" + else: + masked[k] = "" + return masked + +@app.post("/api/config/ai-keys") +async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_admin)): + """Save AI keys. Pass {"DEEPSEEK_API_KEY":"sk-...","OPENROUTER_API_KEY":"...","GEMINI_API_KEY":"..."}""" + keys = _read_ai_keys() + for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]: + if k in body and body[k]: + keys[k] = body[k] + _write_ai_keys(keys) + logger.info("AI keys updated") + return {"status": "ok"} + +@app.post("/api/config/ai-keys/test") +async def api_test_ai_keys(current_user=Depends(require_admin)): + """Test which AI providers are configured.""" + results = {} + for key_name, label, test_url, test_header in [ + ("DEEPSEEK_API_KEY", "deepseek", "https://api.deepseek.com/v1/models", "Authorization"), + ("OPENROUTER_API_KEY", "openrouter", "https://openrouter.ai/api/v1/models", "Authorization"), + ("GEMINI_API_KEY", "gemini", "https://generativelanguage.googleapis.com/v1beta/models?key={key}", None), + ]: + key = get_ai_key(key_name) + if not key: + results[label] = "non configuré" + continue + try: + import urllib.request + if test_header: + req = urllib.request.Request(test_url, headers={test_header: "Bearer " + key}) + else: + req = urllib.request.Request(test_url.replace("{key}", key)) + urllib.request.urlopen(req, timeout=5) + results[label] = "ok" + except Exception as e: + results[label] = "erreur: " + str(e)[:80] + return results + + +# --------------------------------------------------------------------------- +# AI Models — list available models per provider +# --------------------------------------------------------------------------- + +@app.get("/api/config/ai-models") +async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)): + """List available models for a given AI provider.""" + provider = provider.lower() + + if provider not in ("deepseek", "openrouter", "gemini"): + return {"models": [], "error": f"Unknown provider: {provider}"} + + key_name = f"{provider.upper()}_API_KEY" + key = get_ai_key(key_name) + if not key: + return {"models": [], "error": "API key not configured"} + + # Build URL and request + if provider == "gemini": + url = f"https://generativelanguage.googleapis.com/v1beta/models?key={key}" + elif provider == "deepseek": + url = "https://api.deepseek.com/v1/models" + else: # openrouter + url = "https://openrouter.ai/api/v1/models" + + try: + if provider == "gemini": + req = urllib.request.Request(url) + else: + req = urllib.request.Request(url, headers={"Authorization": "Bearer " + key}) + + with urllib.request.urlopen(req, timeout=10) as resp: + data = _json.loads(resp.read().decode()) + + if provider == "gemini": + models = [m.get("name", "") for m in data.get("models", [])] + else: + models = [m.get("id", "") for m in data.get("data", [])] + + return {"models": models} + except Exception as e: + return {"models": [], "error": str(e)} + + # --------------------------------------------------------------------------- # Diagnostics API # --------------------------------------------------------------------------- diff --git a/backend/share.py b/backend/share.py index 748e57f..311a85e 100644 --- a/backend/share.py +++ b/backend/share.py @@ -109,3 +109,16 @@ def list_shares(vault_filter: Optional[str] = None) -> list: # Most recent first shares.sort(key=lambda s: s.get("created_at", ""), reverse=True) return shares + + +def update_shares_after_rename(vault: str, old_path: str, new_path: str): + """Update all shares when a file is renamed.""" + data = _read() + updated = False + for sid, s in data["shares"].items(): + if s.get("vault") == vault and s.get("path") == old_path: + s["path"] = new_path + updated = True + logger.info(f"Updated share {sid}: {vault}/{old_path} -> {new_path}") + if updated: + _write(data) diff --git a/docker-compose.podman.yml b/docker-compose.podman.yml new file mode 100644 index 0000000..0794044 --- /dev/null +++ b/docker-compose.podman.yml @@ -0,0 +1,23 @@ +services: + obsigate-podman: + build: + context: . + image: obsigate:latest + container_name: obsigate-podman + user: "1000:1000" + restart: unless-stopped + ports: + - "2022:8080" + volumes: + - /mnt/c/dev/git/python/obsigate/test_vault:/vaults/TestVault + - /mnt/c/dev/git/python/obsigate/test_dir:/vaults/TestDir + - /mnt/c/dev/git/python/obsigate/data:/app/data + environment: + - VAULT_1_NAME=TestVault + - VAULT_1_PATH=/vaults/TestVault + - DIR_1_NAME=TestDir + - DIR_1_PATH=/vaults/TestDir + - OBSIGATE_AUTH_ENABLED=false + - OBSIGATE_ADMIN_USER=admin + - OBSIGATE_ADMIN_PASSWORD=test123 + - OBSIGATE_WATCHER_ENABLED=false diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 0ace009..4aad2f6 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -9,11 +9,14 @@ services: ports: - "2020:8080" volumes: - - C:\dev\git\python\obsigate\test-vault:/vaults/TestVault - - C:\dev\git\python\obsigate\data:/app/data + - /c/dev/git/python/obsigate/test_vault:/vaults/TestVault + - /c/dev/git/python/obsigate/test_dir:/vaults/TestDir + - /c/dev/git/python/obsigate/data:/app/data environment: - VAULT_1_NAME=TestVault - VAULT_1_PATH=/vaults/TestVault + - DIR_1_NAME=TestDir + - DIR_1_PATH=/vaults/TestDir - OBSIGATE_AUTH_ENABLED=false - OBSIGATE_ADMIN_USER=admin - OBSIGATE_ADMIN_PASSWORD=test123 diff --git a/frontend/editor-poc.html b/frontend/editor-poc.html index 84e07a6..ea740e4 100644 --- a/frontend/editor-poc.html +++ b/frontend/editor-poc.html @@ -29,6 +29,10 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig .bar-title { flex: 1; min-width: 80px; background: transparent; border: 1px solid transparent; color: var(--text); font-size: 13px; padding: 3px 6px; border-radius: var(--r); outline: none; font-family: var(--sans); } .bar-title:hover { border-color: var(--border); } .bar-title:focus { border-color: var(--accent); background: var(--bg3); } +.bar-sep { color: var(--text3); font-size: 13px; padding: 0 2px; user-select: none; } +.bar-filename { flex: 0 1 auto; min-width: 100px; max-width: 200px; background: transparent; border: 1px solid transparent; color: var(--text); font-size: 12px; padding: 3px 6px; border-radius: var(--r); outline: none; font-family: var(--mono); } +.bar-filename:hover { border-color: var(--border); } +.bar-filename:focus { border-color: var(--ok); background: var(--bg3); } .bar-right { display: flex; align-items: center; gap: 4px; } .btn { height: 30px; padding: 0 10px; border-radius: var(--r); border: 1px solid transparent; background: transparent; color: var(--text2); cursor: pointer; font-size: 12px; display: flex; align-items: center; gap: 5px; white-space: nowrap; font-family: var(--sans); transition: all 120ms; } .btn:hover { background: var(--bg-hover); color: var(--text); } @@ -36,7 +40,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig .btn-ai:hover { background: rgba(139,92,246,0.2); box-shadow: 0 0 18px var(--ai-glow); } .btn-ai.active { background: var(--ai); color: #fff; } .btn-ai.off { color: var(--text3); border-color: var(--border); box-shadow: none; background: transparent; } -.save-dot { display: flex; align-items: center; gap: 4px; font-size: 11px; padding: 0 4px; } +.save-dot { display: flex; align-items: center; gap: 4px; font-size: 11px; padding: 0 4px; margin-right: 8px; } .save-dot .dot { width: 6px; height: 6px; border-radius: 50%; } .save-dot.ok { color: var(--ok); } .save-dot.ok .dot { background: var(--ok); } .save-dot.dirty { color: var(--warn); } .save-dot.dirty .dot { background: var(--warn); } @@ -44,6 +48,13 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig .save-dot.err { color: var(--err); } .save-dot.err .dot { background: var(--err); } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } +/* Square action buttons (delete/cancel/save) — match basic editor */ +.btn-square { width: 36px; height: 36px; padding: 0; justify-content: center; font-size: 16px; border-radius: 6px; border: 1px solid var(--border); background: transparent; color: var(--text2); cursor: pointer; transition: all 150ms; display: inline-flex; align-items: center; } +.btn-square:hover { color: var(--accent); border-color: var(--accent); } +.btn-square.primary { background: var(--accent); color: #fff; border-color: var(--accent); } +.btn-square.primary:hover { opacity: 0.9; } +.btn-square.danger:hover { color: var(--err); border-color: var(--err); } + /* Workspace */ .ws { display: flex; flex: 1; overflow: hidden; } .editor-area { flex: 1; display: flex; overflow: hidden; position: relative; } @@ -55,9 +66,18 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig .textarea { width: 100%; height: auto; min-height: 100%; resize: none; overflow: hidden; background: transparent; border: none; outline: none; color: var(--text); font-family: var(--mono); font-size: 13.5px; line-height: 1.75; tab-size: 2; } .textarea::placeholder { color: var(--text3); } -/* Quick insert + */ -.qi-btn { position: fixed; top: 56px; left: 48px; width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--border); background: var(--bg2); color: var(--text3); cursor: pointer; font-size: 16px; font-weight: 700; display: flex; align-items: center; justify-content: center; z-index: 50; transition: all 100ms; } +/* Line numbers */ +.ln-wrap { position: relative; display: flex; flex: 1; overflow: hidden; } +.ln-gutter { width: 44px; min-width: 44px; background: var(--bg2); border-right: 1px solid var(--border); overflow: hidden; user-select: none; padding-top: 24px; position: relative; z-index: 2; } +.ln-gutter-inner { padding: 0 8px 0 12px; text-align: right; font-family: var(--mono); font-size: 11.5px; line-height: 1.75; color: var(--text3); } +.ln-editor { flex: 1; overflow-y: auto; position: relative; } +.editor-wrap { flex: 1; overflow-y: auto; position: relative; padding: 24px 16px 24px 12px; } + +/* Quick insert + — centered in gutter top */ +.qi-btn { position: absolute; top: 2px; left: 50%; transform: translateX(-50%); width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--border); background: var(--bg2); color: var(--text3); cursor: pointer; font-size: 13px; display: flex; align-items: center; justify-content: center; transition: all 100ms; opacity: 0; z-index: 3; } +.ln-gutter:hover .qi-btn, .qi-btn:hover { opacity: 1; } .qi-btn:hover { color: var(--text); border-color: var(--accent); background: var(--accent-bg); } +.ln-gutter { width: 44px; min-width: 44px; background: var(--bg2); border-right: 1px solid var(--border); overflow: hidden; user-select: none; padding-top: 24px; position: relative; z-index: 2; } /* Preview pane */ .preview-pane { flex: 0.67; overflow-y: auto; padding: 24px 24px 80px; background: var(--bg2); border-left: 1px solid var(--border); display: none; } @@ -103,7 +123,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig .ai-chat-placeholder { color: var(--text3); font-size: 12px; padding: 10px; } /* Slash menu */ -.slash { position: absolute; z-index: 200; width: 300px; max-height: 360px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; } +.slash { position: fixed; z-index: 200; width: 300px; max-height: 360px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; } .slash.on { display: block; animation: fadeIn 120ms; } @keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } .slash-search { padding: 8px 10px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg4); border-radius: var(--R) var(--R) 0 0; } @@ -175,6 +195,19 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig .toast.on { display: block; } .toast.ok { border-color: var(--ok); } .toast.err { border-color: var(--err); } +/* Help panel */ +.help-overlay { position: fixed; inset: 0; z-index: 500; display: none; align-items: center; justify-content: center; background: rgba(15,17,23,0.8); } +.help-overlay.on { display: flex; } +.help-panel { background: var(--bg2); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); max-width: 560px; width: 90%; max-height: 75vh; overflow-y: auto; padding: 24px; } +.help-panel h2 { font-size: 18px; margin: 0 0 16px; color: var(--accent); } +.help-panel h3 { font-size: 13px; margin: 16px 0 6px; color: var(--text); text-transform: uppercase; letter-spacing: 0.5px; } +.help-panel table { width: 100%; border-collapse: collapse; font-size: 12px; } +.help-panel td { padding: 4px 8px; border-bottom: 1px solid var(--border); } +.help-panel td:first-child { font-family: var(--mono); color: var(--accent); white-space: nowrap; width: 140px; } +.help-panel td:last-child { color: var(--text2); } +.help-panel .help-close { display: block; margin: 16px auto 0; padding: 6px 20px; border-radius: var(--r); border: 1px solid var(--border); background: var(--bg3); color: var(--text); cursor: pointer; font-size: 12px; font-family: var(--sans); } +.help-panel .help-close:hover { background: var(--bg-hover); } + /* Loading spinner */ .spin { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--text3); border-top-color: var(--ai); border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 6px; vertical-align: middle; } @keyframes spin { to { transform: rotate(360deg); } } @@ -191,12 +224,18 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
ObsiGate - + + / +
- - + + +
Saved
+ + +
@@ -209,11 +248,16 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
🖼
Drop files here
Images, PDFs, documents
- +
-
- - +
+
+ +
+
+
+
+
@@ -273,6 +317,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
✨
AI: Translate
To another language
+
+
@@ -344,11 +390,57 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
+ +
+
+

Forge Editor — Aide

+

Navigation

+ + + + + + +
/Menu de commandes (en debut de ligne)
Alt+IInsertion rapide (Quick Insert)
F1Ce panneau d'aide
TabIndenter la ligne / element de liste
Shift+TabDesindenter
+

Formatage

+ + + + + + +
Ctrl+B**Gras**
Ctrl+I*Italique*
Ctrl+KInserer un lien [texte](url)
Selection + [Envelopper en [lien](https://)
Selection + `Envelopper en `code`
+

Brackets & Auto-completion

+ + + + + + +
[Auto-ferme [] avec curseur au milieu
(Auto-ferme () — si apres [texte], insere https://
"Auto-ferme ""
`Auto-ferme ``
BackspaceSupprime la paire de brackets d'un coup
+

Fichier

+ + + + +
Ctrl+SSauvegarder
Clic sur le titreRenommer le fichier
Ctrl+JPanneau AI
+

Slash — Commandes

+ + + + + + +
/h1 /h2 /h3Titres
/bullet /numberedListes
/checklistListe de taches
/quote /code /dividerBlocs
/link /image /tableInsertions
+ +
+
+ - - + +