Add file editing capability with CodeMirror 6 integration, loading indicator for searches, and clickable home logo
This commit is contained in:
parent
1213eb4781
commit
ae46e62902
@ -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."""
|
||||
|
||||
193
frontend/app.js
193
frontend/app.js
@ -13,6 +13,9 @@
|
||||
let cachedRawSource = null;
|
||||
let allVaults = [];
|
||||
let selectedContextVault = "all";
|
||||
let editorView = null;
|
||||
let editorVault = null;
|
||||
let editorPath = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File extension → Lucide icon mapping
|
||||
@ -476,6 +479,14 @@
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
|
||||
const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [
|
||||
icon("edit", 14),
|
||||
document.createTextNode("Éditer"),
|
||||
]);
|
||||
editBtn.addEventListener("click", () => {
|
||||
openEditor(data.vault, data.path);
|
||||
});
|
||||
|
||||
// Frontmatter
|
||||
let fmSection = null;
|
||||
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
|
||||
@ -529,7 +540,7 @@
|
||||
area.appendChild(el("div", { class: "file-header" }, [
|
||||
el("div", { class: "file-title" }, [document.createTextNode(data.title)]),
|
||||
tagsDiv,
|
||||
el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn]),
|
||||
el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn, editBtn]),
|
||||
]));
|
||||
if (fmSection) area.appendChild(fmSection);
|
||||
area.appendChild(mdDiv);
|
||||
@ -574,6 +585,8 @@
|
||||
}
|
||||
|
||||
async function performSearch(query, vaultFilter, tagFilter) {
|
||||
showLoading();
|
||||
|
||||
let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`;
|
||||
if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
|
||||
|
||||
@ -754,18 +767,196 @@
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = `
|
||||
<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()]);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user