From d3b9298dfab3e16587b711126150185107faf5fd Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 30 Mar 2026 15:26:44 -0400 Subject: [PATCH] feat: add file and directory management endpoints with context menu support - Add POST/PATCH/DELETE endpoints for directory operations (create, rename, delete) - Add POST/PATCH endpoints for file operations (create, rename) - Implement writable vault check to prevent modifications on read-only vaults - Update file delete endpoint to broadcast SSE events and update index - Add Pydantic models for all new request/response schemas - Integrate context menu support in frontend for files and directories - Broadcast real --- backend/main.py | 418 ++++++++++++++++++++++++++++++++++++++ frontend/app.js | 493 +++++++++++++++++++++++++++++++++++++++++++++ frontend/style.css | 271 +++++++++++++++++++++++++ 3 files changed, 1182 insertions(+) diff --git a/backend/main.py b/backend/main.py index 8347a51..c4a37fa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,6 +6,7 @@ import html as html_mod import logging import mimetypes import secrets +import shutil import string import time from concurrent.futures import ThreadPoolExecutor @@ -234,6 +235,61 @@ class HealthResponse(BaseModel): total_files: int +class DirectoryCreateRequest(BaseModel): + """Request to create a new directory.""" + path: str = Field(description="Relative path of the new directory") + + +class DirectoryCreateResponse(BaseModel): + """Response after creating a directory.""" + success: bool + path: str + + +class DirectoryRenameRequest(BaseModel): + """Request to rename a directory.""" + path: str = Field(description="Current path of the directory") + new_name: str = Field(description="New name for the directory") + + +class DirectoryRenameResponse(BaseModel): + """Response after renaming a directory.""" + success: bool + old_path: str + new_path: str + + +class DirectoryDeleteResponse(BaseModel): + """Response after deleting a directory.""" + success: bool + deleted_count: int + + +class FileCreateRequest(BaseModel): + """Request to create a new file.""" + path: str = Field(description="Relative path of the new file") + content: str = Field(default="", description="Initial content") + + +class FileCreateResponse(BaseModel): + """Response after creating a file.""" + success: bool + path: str + + +class FileRenameRequest(BaseModel): + """Request to rename a file.""" + path: str = Field(description="Current path of the file") + new_name: str = Field(description="New name for the file") + + +class FileRenameResponse(BaseModel): + """Response after renaming a file.""" + success: bool + old_path: str + new_path: str + + # --------------------------------------------------------------------------- # SSE Manager — Server-Sent Events for real-time notifications # --------------------------------------------------------------------------- @@ -523,6 +579,18 @@ def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path: return resolved +def _check_vault_writable(vault_root: Path) -> bool: + """Check if a vault is writable (not mounted read-only). + + Args: + vault_root: The vault's root directory (absolute). + + Returns: + True if the vault is writable, False otherwise. + """ + return os.access(vault_root, os.W_OK) + + # --------------------------------------------------------------------------- # Markdown rendering helpers (singleton renderer) # --------------------------------------------------------------------------- @@ -1028,9 +1096,23 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail=f"File not found: {path}") + # Check if vault is writable + if not _check_vault_writable(vault_root): + raise HTTPException(status_code=403, detail="Vault is read-only") + try: file_path.unlink() logger.info(f"File deleted: {vault_name}/{path}") + + # Update index + await remove_single_file(vault_name, path) + + # Broadcast SSE event + await sse_manager.broadcast("file_deleted", { + "vault": vault_name, + "path": path, + }) + return {"status": "ok", "vault": vault_name, "path": path} except PermissionError: raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only") @@ -1039,6 +1121,342 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}") +# --------------------------------------------------------------------------- +# Directory management endpoints +# --------------------------------------------------------------------------- + +@app.post("/api/directory/{vault_name}", response_model=DirectoryCreateResponse) +async def api_directory_create( + vault_name: str, + body: DirectoryCreateRequest, + current_user=Depends(require_auth), +): + """Create a new directory in a vault. + + Args: + vault_name: Name of the vault. + body: Request body with directory path. + + Returns: + DirectoryCreateResponse confirming creation. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + 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"]) + + # Check if vault is writable + if not _check_vault_writable(vault_root): + raise HTTPException(status_code=403, detail="Vault is read-only") + + # Resolve and validate path + dir_path = _resolve_safe_path(vault_root, body.path) + + # Check if directory already exists + if dir_path.exists(): + raise HTTPException(status_code=409, detail=f"Directory already exists: {body.path}") + + try: + # Create directory with parents + dir_path.mkdir(parents=True, exist_ok=False) + logger.info(f"Directory created: {vault_name}/{body.path}") + + # Broadcast SSE event + await sse_manager.broadcast("directory_created", { + "vault": vault_name, + "path": body.path, + }) + + return {"success": True, "path": body.path} + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied: cannot create directory") + except Exception as e: + logger.error(f"Error creating directory {vault_name}/{body.path}: {e}") + raise HTTPException(status_code=500, detail=f"Error creating directory: {str(e)}") + + +@app.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse) +async def api_directory_rename( + vault_name: str, + body: DirectoryRenameRequest, + current_user=Depends(require_auth), +): + """Rename a directory in a vault. + + Args: + vault_name: Name of the vault. + body: Request body with current path and new name. + + Returns: + DirectoryRenameResponse with old and new paths. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + 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"]) + + # Check if vault is writable + if not _check_vault_writable(vault_root): + raise HTTPException(status_code=403, detail="Vault is read-only") + + # Resolve old path + old_path = _resolve_safe_path(vault_root, body.path) + + if not old_path.exists() or not old_path.is_dir(): + raise HTTPException(status_code=404, detail=f"Directory not found: {body.path}") + + # Construct new path (same parent, new name) + new_path = old_path.parent / body.new_name + + # Validate new path is still within vault + new_path = _resolve_safe_path(vault_root, str(new_path.relative_to(vault_root))) + + # Check if destination already exists + if new_path.exists(): + raise HTTPException(status_code=409, detail=f"Destination already exists: {body.new_name}") + + try: + # Rename directory + old_path.rename(new_path) + + old_path_str = str(old_path.relative_to(vault_root)).replace("\\", "/") + new_path_str = str(new_path.relative_to(vault_root)).replace("\\", "/") + + logger.info(f"Directory renamed: {vault_name}/{old_path_str} -> {new_path_str}") + + # Update index for all files in the directory + from backend.indexer import reload_single_vault + await reload_single_vault(vault_name) + + # Broadcast SSE event + await sse_manager.broadcast("directory_renamed", { + "vault": vault_name, + "old_path": old_path_str, + "new_path": new_path_str, + }) + + return {"success": True, "old_path": old_path_str, "new_path": new_path_str} + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied: cannot rename directory") + except Exception as e: + logger.error(f"Error renaming directory {vault_name}/{body.path}: {e}") + raise HTTPException(status_code=500, detail=f"Error renaming directory: {str(e)}") + + +@app.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse) +async def api_directory_delete( + vault_name: str, + path: str = Query(..., description="Relative path to directory"), + current_user=Depends(require_auth), +): + """Delete a directory and all its contents from a vault. + + Args: + vault_name: Name of the vault. + path: Relative directory path within the vault. + + Returns: + DirectoryDeleteResponse with count of deleted files. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + 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"]) + + # Check if vault is writable + if not _check_vault_writable(vault_root): + raise HTTPException(status_code=403, detail="Vault is read-only") + + # Resolve and validate path + dir_path = _resolve_safe_path(vault_root, path) + + if not dir_path.exists() or not dir_path.is_dir(): + raise HTTPException(status_code=404, detail=f"Directory not found: {path}") + + try: + # Count files before deletion + file_count = sum(1 for _ in dir_path.rglob('*') if _.is_file()) + + # Delete directory recursively + shutil.rmtree(dir_path) + logger.info(f"Directory deleted: {vault_name}/{path} ({file_count} files)") + + # Update index + from backend.indexer import reload_single_vault + await reload_single_vault(vault_name) + + # Broadcast SSE event + await sse_manager.broadcast("directory_deleted", { + "vault": vault_name, + "path": path, + "deleted_count": file_count, + }) + + return {"success": True, "deleted_count": file_count} + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied: cannot delete directory") + except Exception as e: + logger.error(f"Error deleting directory {vault_name}/{path}: {e}") + raise HTTPException(status_code=500, detail=f"Error deleting directory: {str(e)}") + + +# --------------------------------------------------------------------------- +# File creation and rename endpoints +# --------------------------------------------------------------------------- + +@app.post("/api/file/{vault_name}", response_model=FileCreateResponse) +async def api_file_create( + vault_name: str, + body: FileCreateRequest, + current_user=Depends(require_auth), +): + """Create a new file in a vault. + + Args: + vault_name: Name of the vault. + body: Request body with file path and initial content. + + Returns: + FileCreateResponse confirming creation. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + 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"]) + + # Check if vault is writable + if not _check_vault_writable(vault_root): + raise HTTPException(status_code=403, detail="Vault is read-only") + + # Resolve and validate path + file_path = _resolve_safe_path(vault_root, body.path) + + # Validate file extension + ext = file_path.suffix.lower() + if ext not in SUPPORTED_EXTENSIONS and file_path.name.lower() not in ("dockerfile", "makefile"): + raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}") + + # Check if file already exists + if file_path.exists(): + raise HTTPException(status_code=409, detail=f"File already exists: {body.path}") + + try: + # Create parent directories if needed + file_path.parent.mkdir(parents=True, exist_ok=True) + + # Create file with initial content + file_path.write_text(body.content, encoding="utf-8") + logger.info(f"File created: {vault_name}/{body.path}") + + # Update index + await update_single_file(vault_name, body.path) + + # Broadcast SSE event + await sse_manager.broadcast("file_created", { + "vault": vault_name, + "path": body.path, + }) + + return {"success": True, "path": body.path} + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied: cannot create file") + except Exception as e: + logger.error(f"Error creating file {vault_name}/{body.path}: {e}") + raise HTTPException(status_code=500, detail=f"Error creating file: {str(e)}") + + +@app.patch("/api/file/{vault_name}", response_model=FileRenameResponse) +async def api_file_rename( + vault_name: str, + body: FileRenameRequest, + current_user=Depends(require_auth), +): + """Rename a file in a vault. + + Args: + vault_name: Name of the vault. + body: Request body with current path and new name. + + Returns: + FileRenameResponse with old and new paths. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + 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"]) + + # Check if vault is writable + if not _check_vault_writable(vault_root): + raise HTTPException(status_code=403, detail="Vault is read-only") + + # Resolve old path + old_path = _resolve_safe_path(vault_root, body.path) + + if not old_path.exists() or not old_path.is_file(): + raise HTTPException(status_code=404, detail=f"File not found: {body.path}") + + # Construct new path (same parent, new name) + new_path = old_path.parent / body.new_name + + # Validate new path is still within vault + new_path = _resolve_safe_path(vault_root, str(new_path.relative_to(vault_root))) + + # Validate file extension + ext = new_path.suffix.lower() + if ext not in SUPPORTED_EXTENSIONS and new_path.name.lower() not in ("dockerfile", "makefile"): + raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}") + + # Check if destination already exists + if new_path.exists(): + raise HTTPException(status_code=409, detail=f"Destination already exists: {body.new_name}") + + try: + # Rename file + old_path.rename(new_path) + + old_path_str = str(old_path.relative_to(vault_root)).replace("\\", "/") + new_path_str = str(new_path.relative_to(vault_root)).replace("\\", "/") + + logger.info(f"File renamed: {vault_name}/{old_path_str} -> {new_path_str}") + + # Update index + await handle_file_move(vault_name, old_path_str, new_path_str) + + # Broadcast SSE event + await sse_manager.broadcast("file_renamed", { + "vault": vault_name, + "old_path": old_path_str, + "new_path": new_path_str, + }) + + return {"success": True, "old_path": old_path_str, "new_path": new_path_str} + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied: cannot rename file") + except Exception as e: + logger.error(f"Error renaming file {vault_name}/{body.path}: {e}") + raise HTTPException(status_code=500, detail=f"Error renaming file: {str(e)}") + + @app.get("/api/file/{vault_name}", response_model=FileContentResponse) async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)): """Return rendered HTML and metadata for a file. diff --git a/frontend/app.js b/frontend/app.js index 4dc61b6..ecacf7a 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2272,6 +2272,12 @@ safeCreateIcons(); } }); + + dirItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const isReadonly = false; + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly); + }); } else { const fileIconName = getFileIcon(item.name); const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; @@ -2281,6 +2287,13 @@ openFile(vaultName, item.path); closeMobileSidebar(); }); + + fileItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const isReadonly = false; + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly); + }); + fragment.appendChild(fileItem); } }); @@ -5486,6 +5499,485 @@ panel.innerHTML = html; } + // --------------------------------------------------------------------------- + // Context Menu Manager + // --------------------------------------------------------------------------- + + const ContextMenuManager = { + _menu: null, + _targetElement: null, + _targetVault: null, + _targetPath: null, + _targetType: null, + + init() { + this._menu = document.createElement('div'); + this._menu.className = 'context-menu'; + this._menu.id = 'context-menu'; + document.body.appendChild(this._menu); + + document.addEventListener('click', () => this.hide()); + document.addEventListener('contextmenu', (e) => { + if (!e.target.closest('.tree-item')) { + this.hide(); + } + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') this.hide(); + }); + + document.addEventListener('scroll', () => this.hide(), true); + }, + + show(x, y, vault, path, type, isReadonly) { + this._targetVault = vault; + this._targetPath = path; + this._targetType = type; + + this._menu.innerHTML = ''; + + if (type === 'directory') { + this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly); + this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly); + this._addSeparator(); + this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); + this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly); + } else if (type === 'file') { + this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); + this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly); + } + + this._menu.classList.add('active'); + + const rect = this._menu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let finalX = x; + let finalY = y; + + if (x + rect.width > viewportWidth) { + finalX = viewportWidth - rect.width - 10; + } + + if (y + rect.height > viewportHeight) { + finalY = viewportHeight - rect.height - 10; + } + + this._menu.style.left = `${finalX}px`; + this._menu.style.top = `${finalY}px`; + + safeCreateIcons(); + }, + + hide() { + if (this._menu) { + this._menu.classList.remove('active'); + } + }, + + _addItem(icon, label, callback, disabled) { + const item = document.createElement('div'); + item.className = 'context-menu-item' + (disabled ? ' disabled' : ''); + item.innerHTML = ` + + ${label} + `; + + if (!disabled) { + item.addEventListener('click', (e) => { + e.stopPropagation(); + this.hide(); + callback(); + }); + } else { + item.title = 'Vault en lecture seule'; + } + + this._menu.appendChild(item); + }, + + _addSeparator() { + const sep = document.createElement('div'); + sep.className = 'context-menu-separator'; + this._menu.appendChild(sep); + }, + + _createDirectory() { + FileOperations.showCreateDirectoryModal(this._targetVault, this._targetPath); + }, + + _createFile() { + FileOperations.showCreateFileModal(this._targetVault, this._targetPath); + }, + + _renameItem() { + FileOperations.startInlineRename(this._targetVault, this._targetPath, this._targetType); + }, + + _deleteDirectory() { + FileOperations.confirmDeleteDirectory(this._targetVault, this._targetPath); + }, + + _deleteFile() { + FileOperations.confirmDeleteFile(this._targetVault, this._targetPath); + } + }; + + // --------------------------------------------------------------------------- + // File Operations Manager + // --------------------------------------------------------------------------- + + const FileOperations = { + showCreateDirectoryModal(vault, parentPath) { + const overlay = this._createModalOverlay(); + const modal = document.createElement('div'); + modal.className = 'obsigate-modal'; + modal.innerHTML = ` +
+

Créer un dossier

+
+
+ +
+ + `; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + setTimeout(() => overlay.classList.add('active'), 10); + + const input = modal.querySelector('#dir-name-input'); + const errorDiv = modal.querySelector('#dir-error'); + const createBtn = modal.querySelector('#dir-create-btn'); + const cancelBtn = modal.querySelector('#dir-cancel-btn'); + + input.focus(); + + const validateName = (name) => { + if (!name.trim()) return 'Le nom ne peut pas être vide'; + if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; + return null; + }; + + input.addEventListener('input', () => { + const error = validateName(input.value); + if (error) { + errorDiv.textContent = error; + errorDiv.style.display = 'block'; + input.classList.add('error'); + } else { + errorDiv.style.display = 'none'; + input.classList.remove('error'); + } + }); + + const create = async () => { + const name = input.value.trim(); + const error = validateName(name); + if (error) { + errorDiv.textContent = error; + errorDiv.style.display = 'block'; + return; + } + + const path = parentPath ? `${parentPath}/${name}` : name; + createBtn.disabled = true; + createBtn.textContent = 'Création...'; + + try { + await api(`/api/directory/${encodeURIComponent(vault)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }); + + showToast(`Dossier "${name}" créé`, 'success'); + this._closeModal(overlay); + await refreshVaultTree(); + } catch (err) { + showToast(err.message || 'Erreur lors de la création', 'error'); + createBtn.disabled = false; + createBtn.textContent = 'Créer'; + } + }; + + createBtn.addEventListener('click', create); + cancelBtn.addEventListener('click', () => this._closeModal(overlay)); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') create(); + if (e.key === 'Escape') this._closeModal(overlay); + }); + }, + + showCreateFileModal(vault, parentPath) { + const overlay = this._createModalOverlay(); + const modal = document.createElement('div'); + modal.className = 'obsigate-modal'; + modal.innerHTML = ` +
+

Créer un fichier

+
+
+ + +
+ + `; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + setTimeout(() => overlay.classList.add('active'), 10); + + const input = modal.querySelector('#file-name-input'); + const extSelect = modal.querySelector('#file-ext-select'); + const errorDiv = modal.querySelector('#file-error'); + const createBtn = modal.querySelector('#file-create-btn'); + const cancelBtn = modal.querySelector('#file-cancel-btn'); + + input.focus(); + + const validateName = (name) => { + if (!name.trim()) return 'Le nom ne peut pas être vide'; + if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; + return null; + }; + + input.addEventListener('input', () => { + const error = validateName(input.value); + if (error) { + errorDiv.textContent = error; + errorDiv.style.display = 'block'; + input.classList.add('error'); + } else { + errorDiv.style.display = 'none'; + input.classList.remove('error'); + } + }); + + const create = async () => { + let name = input.value.trim(); + const error = validateName(name); + if (error) { + errorDiv.textContent = error; + errorDiv.style.display = 'block'; + return; + } + + const ext = extSelect.value; + if (!name.endsWith(ext)) { + name += ext; + } + + const path = parentPath ? `${parentPath}/${name}` : name; + createBtn.disabled = true; + createBtn.textContent = 'Création...'; + + try { + await api(`/api/file/${encodeURIComponent(vault)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, content: '' }), + }); + + showToast(`Fichier "${name}" créé`, 'success'); + this._closeModal(overlay); + await refreshVaultTree(); + openFile(vault, path); + } catch (err) { + showToast(err.message || 'Erreur lors de la création', 'error'); + createBtn.disabled = false; + createBtn.textContent = 'Créer'; + } + }; + + createBtn.addEventListener('click', create); + cancelBtn.addEventListener('click', () => this._closeModal(overlay)); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') create(); + if (e.key === 'Escape') this._closeModal(overlay); + }); + }, + + async startInlineRename(vault, path, type) { + showToast('Fonctionnalité de renommage inline en cours de développement', 'info'); + }, + + confirmDeleteDirectory(vault, path) { + const overlay = this._createModalOverlay(); + const modal = document.createElement('div'); + modal.className = 'obsigate-modal'; + modal.innerHTML = ` +
+

Supprimer le dossier

+
+
+ + +
+ + `; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + setTimeout(() => overlay.classList.add('active'), 10); + safeCreateIcons(); + + const confirmBtn = modal.querySelector('#del-confirm-btn'); + const cancelBtn = modal.querySelector('#del-cancel-btn'); + + const deleteDir = async () => { + confirmBtn.disabled = true; + confirmBtn.textContent = 'Suppression...'; + + try { + const result = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { + method: 'DELETE', + }); + + showToast(`Dossier supprimé (${result.deleted_count} fichiers)`, 'success'); + this._closeModal(overlay); + await refreshVaultTree(); + + if (currentVault === vault && currentPath && currentPath.startsWith(path)) { + showWelcome(); + } + } catch (err) { + showToast(err.message || 'Erreur lors de la suppression', 'error'); + confirmBtn.disabled = false; + confirmBtn.textContent = 'Supprimer définitivement'; + } + }; + + confirmBtn.addEventListener('click', deleteDir); + cancelBtn.addEventListener('click', () => this._closeModal(overlay)); + }, + + confirmDeleteFile(vault, path) { + const overlay = this._createModalOverlay(); + const modal = document.createElement('div'); + modal.className = 'obsigate-modal'; + modal.innerHTML = ` +
+

Supprimer le fichier

+
+
+ + +
+ + `; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + setTimeout(() => overlay.classList.add('active'), 10); + safeCreateIcons(); + + const confirmBtn = modal.querySelector('#del-confirm-btn'); + const cancelBtn = modal.querySelector('#del-cancel-btn'); + + const deleteFile = async () => { + confirmBtn.disabled = true; + confirmBtn.textContent = 'Suppression...'; + + try { + await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { + method: 'DELETE', + }); + + showToast('Fichier supprimé', 'success'); + this._closeModal(overlay); + await refreshVaultTree(); + + if (currentVault === vault && currentPath === path) { + showWelcome(); + } + } catch (err) { + showToast(err.message || 'Erreur lors de la suppression', 'error'); + confirmBtn.disabled = false; + confirmBtn.textContent = 'Supprimer définitivement'; + } + }; + + confirmBtn.addEventListener('click', deleteFile); + cancelBtn.addEventListener('click', () => this._closeModal(overlay)); + }, + + _createModalOverlay() { + const overlay = document.createElement('div'); + overlay.className = 'obsigate-modal-overlay'; + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + this._closeModal(overlay); + } + }); + return overlay; + }, + + _closeModal(overlay) { + overlay.classList.remove('active'); + setTimeout(() => overlay.remove(), 200); + } + }; + // --------------------------------------------------------------------------- // Find in Page Manager // --------------------------------------------------------------------------- @@ -5933,6 +6425,7 @@ initRecentTab(); RightSidebarManager.init(); FindInPageManager.init(); + ContextMenuManager.init(); // Check auth status first const authOk = await AuthManager.initAuth(); diff --git a/frontend/style.css b/frontend/style.css index 13a9627..15548a5 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -5019,3 +5019,274 @@ body.popup-mode .content-area { transform: translateX(100%); } } + +/* ===== CONTEXT MENU ===== */ +.context-menu { + position: fixed; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + min-width: 200px; + z-index: 10000; + padding: 4px; + display: none; +} + +.context-menu.active { + display: block; +} + +.context-menu-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + font-family: "JetBrains Mono", monospace; + font-size: 0.82rem; + color: var(--text-primary); + transition: all 150ms ease; +} + +.context-menu-item:hover:not(.disabled) { + background: var(--bg-hover); + color: var(--accent); +} + +.context-menu-item.disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.context-menu-item .icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.context-menu-separator { + height: 1px; + background: var(--border); + margin: 4px 0; +} + +/* ===== INLINE RENAME ===== */ +.sidebar-item-input { + width: 100%; + padding: 4px 8px; + background: var(--bg-primary); + border: 1px solid var(--accent); + border-radius: 4px; + color: var(--text-primary); + font-family: "JetBrains Mono", monospace; + font-size: 0.82rem; + outline: none; +} + +/* ===== VAULT READONLY BADGE ===== */ +.vault-readonly-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + background: var(--danger-bg); + color: var(--danger); + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; + margin-left: 6px; +} + +/* ===== OBSIGATE MODALS ===== */ +.obsigate-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--overlay-bg); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease; +} + +.obsigate-modal-overlay.active { + opacity: 1; + pointer-events: all; +} + +.obsigate-modal { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); + min-width: 400px; + max-width: 600px; + transform: scale(0.9); + transition: transform 200ms ease; +} + +.obsigate-modal-overlay.active .obsigate-modal { + transform: scale(1); +} + +.obsigate-modal-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.obsigate-modal-title { + font-family: "JetBrains Mono", monospace; + font-size: 1.1rem; + font-weight: 700; + color: var(--text-primary); +} + +.obsigate-modal-body { + padding: 20px; +} + +.obsigate-modal-footer { + padding: 16px 20px; + border-top: 1px solid var(--border); + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.modal-input { + width: 100%; + padding: 10px 12px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-family: "JetBrains Mono", monospace; + font-size: 0.9rem; + outline: none; + transition: border-color 200ms ease; +} + +.modal-input:focus { + border-color: var(--accent); +} + +.modal-input.error { + border-color: var(--danger); +} + +.modal-label { + display: block; + font-family: "JetBrains Mono", monospace; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.modal-hint { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 6px; +} + +.modal-error { + font-size: 0.8rem; + color: var(--danger); + margin-top: 6px; +} + +.modal-select { + width: 100%; + padding: 10px 12px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-family: "JetBrains Mono", monospace; + font-size: 0.9rem; + outline: none; + cursor: pointer; + transition: border-color 200ms ease; +} + +.modal-select:focus { + border-color: var(--accent); +} + +.modal-btn { + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-primary); + color: var(--text-primary); + font-family: "JetBrains Mono", monospace; + font-size: 0.85rem; + cursor: pointer; + transition: all 150ms ease; +} + +.modal-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.modal-btn.primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.modal-btn.primary:hover { + opacity: 0.9; +} + +.modal-btn.danger { + background: var(--danger); + color: white; + border-color: var(--danger); +} + +.modal-btn.danger:hover { + opacity: 0.9; +} + +.modal-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.modal-form-group { + margin-bottom: 16px; +} + +.modal-form-group:last-child { + margin-bottom: 0; +} + +.modal-warning { + padding: 12px; + background: var(--danger-bg); + border: 1px solid var(--danger); + border-radius: 6px; + color: var(--danger); + font-size: 0.85rem; + margin-bottom: 16px; + display: flex; + align-items: flex-start; + gap: 10px; +} + +.modal-warning .icon { + flex-shrink: 0; + margin-top: 2px; +}