diff --git a/backend/main.py b/backend/main.py index 9823b90..19206cf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -195,6 +195,39 @@ async def api_file_download(vault_name: str, path: str = Query(..., description= ) +@app.put("/api/file/{vault_name}/save") +async def api_file_save(vault_name: str, path: str = Query(..., description="Relative path to file"), body: dict = {}): + """Save file content.""" + vault_data = get_vault_data(vault_name) + if not vault_data: + raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") + + vault_root = Path(vault_data["path"]) + file_path = vault_root / path + + # Security: ensure path is within vault + try: + file_path.resolve().relative_to(vault_root.resolve()) + except ValueError: + raise HTTPException(status_code=403, detail="Access denied: path outside vault") + + if not file_path.exists(): + raise HTTPException(status_code=404, detail=f"File not found: {path}") + + # Get content from body + content = body.get('content', '') + + try: + file_path.write_text(content, encoding="utf-8") + logger.info(f"File saved: {vault_name}/{path}") + return {"status": "ok", "vault": vault_name, "path": path, "size": len(content)} + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only") + except Exception as e: + logger.error(f"Error saving file {vault_name}/{path}: {e}") + raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}") + + @app.get("/api/file/{vault_name}") async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file")): """Return rendered HTML + metadata for a file.""" diff --git a/frontend/app.js b/frontend/app.js index 14d3c69..89ec9ae 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -13,6 +13,9 @@ let cachedRawSource = null; let allVaults = []; let selectedContextVault = "all"; + let editorView = null; + let editorVault = null; + let editorPath = null; // --------------------------------------------------------------------------- // File extension → Lucide icon mapping @@ -476,6 +479,14 @@ document.body.removeChild(a); }); + const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [ + icon("edit", 14), + document.createTextNode("Éditer"), + ]); + editBtn.addEventListener("click", () => { + openEditor(data.vault, data.path); + }); + // Frontmatter let fmSection = null; if (data.frontmatter && Object.keys(data.frontmatter).length > 0) { @@ -529,7 +540,7 @@ area.appendChild(el("div", { class: "file-header" }, [ el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, - el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn]), + el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn, editBtn]), ])); if (fmSection) area.appendChild(fmSection); area.appendChild(mdDiv); @@ -574,6 +585,8 @@ } async function performSearch(query, vaultFilter, tagFilter) { + showLoading(); + let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`; if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`; @@ -754,18 +767,196 @@ safeCreateIcons(); } + function showLoading() { + const area = document.getElementById("content-area"); + area.innerHTML = ` +
+
+
Recherche en cours...
+
`; + } + + function goHome() { + const searchInput = document.getElementById("search-input"); + if (searchInput) searchInput.value = ""; + + document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); + + currentVault = null; + currentPath = null; + showingSource = false; + cachedRawSource = null; + + closeMobileSidebar(); + showWelcome(); + } + + // --------------------------------------------------------------------------- + // Editor (CodeMirror) + // --------------------------------------------------------------------------- + async function openEditor(vaultName, filePath) { + editorVault = vaultName; + editorPath = filePath; + + const modal = document.getElementById("editor-modal"); + const titleEl = document.getElementById("editor-title"); + const bodyEl = document.getElementById("editor-body"); + + titleEl.textContent = `Édition: ${filePath.split("/").pop()}`; + + // Fetch raw content + const rawUrl = `/api/file/${encodeURIComponent(vaultName)}/raw?path=${encodeURIComponent(filePath)}`; + const rawData = await api(rawUrl); + + // Clear previous editor + bodyEl.innerHTML = ""; + if (editorView) { + editorView.destroy(); + editorView = null; + } + + // Wait for CodeMirror to be available + await waitForCodeMirror(); + + const { EditorView, EditorState, basicSetup, markdown, oneDark, keymap } = window.CodeMirror; + + // Determine theme + const currentTheme = document.documentElement.getAttribute("data-theme"); + const extensions = [ + basicSetup, + markdown(), + keymap.of([{ + key: "Mod-s", + run: () => { + saveFile(); + return true; + } + }]), + EditorView.lineWrapping, + ]; + + if (currentTheme === "dark") { + extensions.push(oneDark); + } + + const state = EditorState.create({ + doc: rawData.raw, + extensions: extensions, + }); + + editorView = new EditorView({ + state: state, + parent: bodyEl, + }); + + modal.classList.add("active"); + safeCreateIcons(); + } + + async function waitForCodeMirror() { + let attempts = 0; + while (!window.CodeMirror && attempts < 50) { + await new Promise(resolve => setTimeout(resolve, 100)); + attempts++; + } + if (!window.CodeMirror) { + throw new Error("CodeMirror failed to load"); + } + } + + function closeEditor() { + const modal = document.getElementById("editor-modal"); + modal.classList.remove("active"); + if (editorView) { + editorView.destroy(); + editorView = null; + } + editorVault = null; + editorPath = null; + } + + async function saveFile() { + if (!editorView || !editorVault || !editorPath) return; + + const content = editorView.state.doc.toString(); + const saveBtn = document.getElementById("editor-save"); + const originalText = saveBtn.textContent; + + try { + saveBtn.disabled = true; + saveBtn.innerHTML = ' Sauvegarde...'; + safeCreateIcons(); + + const response = await fetch( + `/api/file/${encodeURIComponent(editorVault)}/save?path=${encodeURIComponent(editorPath)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: content }), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || "Erreur de sauvegarde"); + } + + saveBtn.innerHTML = ' Sauvegardé !'; + safeCreateIcons(); + + setTimeout(() => { + closeEditor(); + // Reload the file if it's currently open + if (currentVault === editorVault && currentPath === editorPath) { + openFile(currentVault, currentPath); + } + }, 800); + } catch (err) { + console.error("Save error:", err); + alert(`Erreur: ${err.message}`); + saveBtn.innerHTML = originalText; + saveBtn.disabled = false; + safeCreateIcons(); + } + } + + function initEditor() { + const cancelBtn = document.getElementById("editor-cancel"); + const saveBtn = document.getElementById("editor-save"); + const modal = document.getElementById("editor-modal"); + + cancelBtn.addEventListener("click", closeEditor); + saveBtn.addEventListener("click", saveFile); + + // Close on overlay click + modal.addEventListener("click", (e) => { + if (e.target === modal) { + closeEditor(); + } + }); + + // ESC to close + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && modal.classList.contains("active")) { + closeEditor(); + } + }); + } + // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- async function init() { initTheme(); document.getElementById("theme-toggle").addEventListener("click", toggleTheme); + document.getElementById("header-logo").addEventListener("click", goHome); initSearch(); initMobile(); initVaultContext(); initSidebarFilter(); initSidebarResize(); initTagResize(); + initEditor(); try { await Promise.all([loadVaults(), loadTags()]); diff --git a/frontend/index.html b/frontend/index.html index 9bc0249..208e8bb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,6 +9,16 @@ + + +
@@ -19,7 +29,7 @@ -
+ +
+
+
+
Édition
+
+ + +
+
+
+
+
+ diff --git a/frontend/style.css b/frontend/style.css index 2c4127c..71a9c55 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -118,6 +118,13 @@ a:hover { display: flex; align-items: center; gap: 8px; + cursor: pointer; + transition: opacity 200ms ease; + text-decoration: none; +} +.header-logo:hover { + opacity: 0.8; + text-decoration: none; } .search-wrapper { @@ -741,6 +748,150 @@ a:hover { padding: 1px 6px; } +/* --- Loading indicator --- */ +.loading-indicator { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--text-muted); + gap: 12px; +} +.loading-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* --- Editor Modal --- */ +.editor-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + background: var(--overlay-bg); + align-items: center; + justify-content: center; + padding: 20px; +} +.editor-modal.active { + display: flex; +} + +.editor-container { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + width: 100%; + max-width: 1200px; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); +} + +.editor-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg-primary); + flex-shrink: 0; +} + +.editor-title { + font-family: 'JetBrains Mono', monospace; + font-size: 0.9rem; + color: var(--text-primary); + font-weight: 600; +} + +.editor-actions { + display: flex; + gap: 8px; +} + +.editor-btn { + font-family: 'JetBrains Mono', monospace; + font-size: 0.8rem; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all 150ms ease; + display: inline-flex; + align-items: center; + gap: 4px; +} +.editor-btn:hover { + color: var(--accent); + border-color: var(--accent); +} +.editor-btn.primary { + background: var(--accent); + color: #ffffff; + border-color: var(--accent); +} +.editor-btn.primary:hover { + opacity: 0.9; +} + +.editor-body { + flex: 1; + overflow: hidden; + position: relative; +} + +.cm-editor { + height: 100%; + font-size: 0.9rem; +} + +.cm-scroller { + font-family: 'JetBrains Mono', monospace; +} + +/* Mobile editor */ +@media (max-width: 768px) { + .editor-modal { + padding: 0; + } + + .editor-container { + max-width: 100%; + max-height: 100vh; + border-radius: 0; + height: 100vh; + } + + .editor-header { + padding: 10px 12px; + } + + .editor-title { + font-size: 0.85rem; + } + + .editor-btn { + font-size: 0.75rem; + padding: 5px 10px; + } +} + /* --- No-select during resize --- */ body.resizing { user-select: none; @@ -768,12 +919,13 @@ body.resizing-v { } .header { - gap: 8px; - padding: 8px 12px; + gap: 6px; + padding: 8px 10px; } .header-logo { - font-size: 1rem; + font-size: 0.95rem; + gap: 6px; } .search-wrapper { @@ -781,9 +933,14 @@ body.resizing-v { flex: 1; } + .search-wrapper input { + padding: 6px 10px 6px 32px; + font-size: 0.8rem; + } + .search-vault-filter { - font-size: 0.72rem; - padding: 5px 6px; + font-size: 0.7rem; + padding: 4px 6px; } .sidebar { @@ -808,6 +965,16 @@ body.resizing-v { display: none; } + .tag-resize-handle { + display: none; + } + + .tag-cloud-section { + height: auto; + max-height: 200px; + min-height: 80px; + } + .content-area { padding: 16px 12px 60px; }