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
This commit is contained in:
parent
d26a40a99d
commit
d3b9298dfa
418
backend/main.py
418
backend/main.py
@ -6,6 +6,7 @@ import html as html_mod
|
|||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import secrets
|
import secrets
|
||||||
|
import shutil
|
||||||
import string
|
import string
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
@ -234,6 +235,61 @@ class HealthResponse(BaseModel):
|
|||||||
total_files: int
|
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
|
# 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
|
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)
|
# 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():
|
if not file_path.exists() or not file_path.is_file():
|
||||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
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:
|
try:
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
logger.info(f"File deleted: {vault_name}/{path}")
|
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}
|
return {"status": "ok", "vault": vault_name, "path": path}
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
|
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)}")
|
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)
|
@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)):
|
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.
|
"""Return rendered HTML and metadata for a file.
|
||||||
|
|||||||
493
frontend/app.js
493
frontend/app.js
@ -2272,6 +2272,12 @@
|
|||||||
safeCreateIcons();
|
safeCreateIcons();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dirItem.addEventListener("contextmenu", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const isReadonly = false;
|
||||||
|
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const fileIconName = getFileIcon(item.name);
|
const fileIconName = getFileIcon(item.name);
|
||||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||||||
@ -2281,6 +2287,13 @@
|
|||||||
openFile(vaultName, item.path);
|
openFile(vaultName, item.path);
|
||||||
closeMobileSidebar();
|
closeMobileSidebar();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fileItem.addEventListener("contextmenu", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const isReadonly = false;
|
||||||
|
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly);
|
||||||
|
});
|
||||||
|
|
||||||
fragment.appendChild(fileItem);
|
fragment.appendChild(fileItem);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -5486,6 +5499,485 @@
|
|||||||
panel.innerHTML = html;
|
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 = `
|
||||||
|
<i data-lucide="${icon}" class="icon"></i>
|
||||||
|
<span>${label}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="obsigate-modal-header">
|
||||||
|
<h3 class="obsigate-modal-title">Créer un dossier</h3>
|
||||||
|
</div>
|
||||||
|
<div class="obsigate-modal-body">
|
||||||
|
<div class="modal-form-group">
|
||||||
|
<label class="modal-label">Nom du dossier</label>
|
||||||
|
<input type="text" class="modal-input" id="dir-name-input" placeholder="nouveau-dossier" />
|
||||||
|
<div class="modal-hint">Dans: ${parentPath || '/'}</div>
|
||||||
|
<div class="modal-error" id="dir-error" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="obsigate-modal-footer">
|
||||||
|
<button class="modal-btn" id="dir-cancel-btn">Annuler</button>
|
||||||
|
<button class="modal-btn primary" id="dir-create-btn">Créer</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="obsigate-modal-header">
|
||||||
|
<h3 class="obsigate-modal-title">Créer un fichier</h3>
|
||||||
|
</div>
|
||||||
|
<div class="obsigate-modal-body">
|
||||||
|
<div class="modal-form-group">
|
||||||
|
<label class="modal-label">Nom du fichier</label>
|
||||||
|
<input type="text" class="modal-input" id="file-name-input" placeholder="note.md" />
|
||||||
|
<div class="modal-hint">Dans: ${parentPath || '/'}</div>
|
||||||
|
<div class="modal-error" id="file-error" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-form-group">
|
||||||
|
<label class="modal-label">Type de fichier</label>
|
||||||
|
<select class="modal-select" id="file-ext-select">
|
||||||
|
<option value=".md">Markdown (.md)</option>
|
||||||
|
<option value=".txt">Texte (.txt)</option>
|
||||||
|
<option value=".py">Python (.py)</option>
|
||||||
|
<option value=".js">JavaScript (.js)</option>
|
||||||
|
<option value=".json">JSON (.json)</option>
|
||||||
|
<option value=".yaml">YAML (.yaml)</option>
|
||||||
|
<option value=".sh">Shell (.sh)</option>
|
||||||
|
<option value=".ps1">PowerShell (.ps1)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="obsigate-modal-footer">
|
||||||
|
<button class="modal-btn" id="file-cancel-btn">Annuler</button>
|
||||||
|
<button class="modal-btn primary" id="file-create-btn">Créer</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="obsigate-modal-header">
|
||||||
|
<h3 class="obsigate-modal-title">Supprimer le dossier</h3>
|
||||||
|
</div>
|
||||||
|
<div class="obsigate-modal-body">
|
||||||
|
<div class="modal-warning">
|
||||||
|
<i data-lucide="alert-triangle" class="icon"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Attention !</strong> Cette action est irréversible.
|
||||||
|
<br>Tous les fichiers et sous-dossiers seront supprimés définitivement.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-form-group">
|
||||||
|
<label class="modal-label">Dossier à supprimer:</label>
|
||||||
|
<div style="font-family: 'JetBrains Mono', monospace; color: var(--text-muted);">${path}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="obsigate-modal-footer">
|
||||||
|
<button class="modal-btn" id="del-cancel-btn">Annuler</button>
|
||||||
|
<button class="modal-btn danger" id="del-confirm-btn">Supprimer définitivement</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="obsigate-modal-header">
|
||||||
|
<h3 class="obsigate-modal-title">Supprimer le fichier</h3>
|
||||||
|
</div>
|
||||||
|
<div class="obsigate-modal-body">
|
||||||
|
<div class="modal-warning">
|
||||||
|
<i data-lucide="alert-triangle" class="icon"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Attention !</strong> Cette action est irréversible.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-form-group">
|
||||||
|
<label class="modal-label">Fichier à supprimer:</label>
|
||||||
|
<div style="font-family: 'JetBrains Mono', monospace; color: var(--text-muted);">${path}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="obsigate-modal-footer">
|
||||||
|
<button class="modal-btn" id="del-cancel-btn">Annuler</button>
|
||||||
|
<button class="modal-btn danger" id="del-confirm-btn">Supprimer définitivement</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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
|
// Find in Page Manager
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -5933,6 +6425,7 @@
|
|||||||
initRecentTab();
|
initRecentTab();
|
||||||
RightSidebarManager.init();
|
RightSidebarManager.init();
|
||||||
FindInPageManager.init();
|
FindInPageManager.init();
|
||||||
|
ContextMenuManager.init();
|
||||||
|
|
||||||
// Check auth status first
|
// Check auth status first
|
||||||
const authOk = await AuthManager.initAuth();
|
const authOk = await AuthManager.initAuth();
|
||||||
|
|||||||
@ -5019,3 +5019,274 @@ body.popup-mode .content-area {
|
|||||||
transform: translateX(100%);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user