Add file editing capability with CodeMirror 6 integration, loading indicator for searches, and clickable home logo

This commit is contained in:
Bruno Charest 2026-03-21 12:04:10 -04:00
parent 1213eb4781
commit ae46e62902
4 changed files with 428 additions and 7 deletions

View File

@ -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."""

View File

@ -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 = `
<div class="loading-indicator">
<div class="loading-spinner"></div>
<div>Recherche en cours...</div>
</div>`;
}
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 = '<i data-lucide="loader" style="width:14px;height:14px"></i> 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 = '<i data-lucide="check" style="width:14px;height:14px"></i> 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()]);

View File

@ -9,6 +9,16 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-theme-light" disabled>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
<!-- CodeMirror 6 -->
<script type="module">
import { EditorView, basicSetup } from "https://esm.sh/@codemirror/basic-setup@0.20.0";
import { EditorState } from "https://esm.sh/@codemirror/state@6.2.0";
import { markdown } from "https://esm.sh/@codemirror/lang-markdown@6.1.0";
import { oneDark } from "https://esm.sh/@codemirror/theme-one-dark@6.1.0";
import { keymap } from "https://esm.sh/@codemirror/view@6.9.0";
window.CodeMirror = { EditorView, EditorState, basicSetup, markdown, oneDark, keymap };
</script>
</head>
<body>
<div class="app-container">
@ -19,7 +29,7 @@
<i data-lucide="menu" style="width:20px;height:20px"></i>
</button>
<div class="header-logo">
<div class="header-logo" id="header-logo">
<i data-lucide="book-open" style="width:20px;height:20px"></i>
ObsiGate
</div>
@ -81,6 +91,26 @@
</div>
</div>
<!-- Editor Modal -->
<div class="editor-modal" id="editor-modal">
<div class="editor-container">
<div class="editor-header">
<div class="editor-title" id="editor-title">Édition</div>
<div class="editor-actions">
<button class="editor-btn" id="editor-cancel">
<i data-lucide="x" style="width:14px;height:14px"></i>
Annuler
</button>
<button class="editor-btn primary" id="editor-save">
<i data-lucide="save" style="width:14px;height:14px"></i>
Sauvegarder
</button>
</div>
</div>
<div class="editor-body" id="editor-body"></div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@ -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;
}