Implement multi-stage Docker build with security hardening, add health check endpoint, optimize in-memory search with O(1) wikilink lookup, extract inline tags from markdown content, and enhance documentation with architecture diagrams and performance metrics
This commit is contained in:
+111
-11
@@ -9,6 +9,7 @@
|
||||
let currentVault = null;
|
||||
let currentPath = null;
|
||||
let searchTimeout = null;
|
||||
let searchAbortController = null;
|
||||
let showingSource = false;
|
||||
let cachedRawSource = null;
|
||||
let allVaults = [];
|
||||
@@ -20,6 +21,7 @@
|
||||
let fallbackEditorEl = null;
|
||||
let sidebarFilterCaseSensitive = false;
|
||||
let searchCaseSensitive = false;
|
||||
let _iconDebounceTimer = null;
|
||||
const panelState = {
|
||||
vault: true,
|
||||
tag: true,
|
||||
@@ -78,7 +80,26 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe CDN helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Debounced icon creation — batches multiple rapid calls into one
|
||||
* DOM scan to avoid excessive reflows when building large trees.
|
||||
*/
|
||||
function safeCreateIcons() {
|
||||
if (typeof lucide === "undefined" || !lucide.createIcons) return;
|
||||
if (_iconDebounceTimer) return; // already scheduled
|
||||
_iconDebounceTimer = requestAnimationFrame(() => {
|
||||
_iconDebounceTimer = null;
|
||||
try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ }
|
||||
});
|
||||
}
|
||||
|
||||
/** Force-flush icon creation immediately (use sparingly). */
|
||||
function flushIcons() {
|
||||
if (_iconDebounceTimer) {
|
||||
cancelAnimationFrame(_iconDebounceTimer);
|
||||
_iconDebounceTimer = null;
|
||||
}
|
||||
if (typeof lucide !== "undefined" && lucide.createIcons) {
|
||||
try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ }
|
||||
}
|
||||
@@ -292,12 +313,60 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toast notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Display a brief toast message at the bottom of the viewport. */
|
||||
function showToast(message, type) {
|
||||
type = type || "error";
|
||||
let container = document.getElementById("toast-container");
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.id = "toast-container";
|
||||
container.className = "toast-container";
|
||||
container.setAttribute("aria-live", "polite");
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
var toast = document.createElement("div");
|
||||
toast.className = "toast toast-" + type;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
// Trigger entrance animation
|
||||
requestAnimationFrame(function () { toast.classList.add("show"); });
|
||||
setTimeout(function () {
|
||||
toast.classList.remove("show");
|
||||
toast.addEventListener("transitionend", function () { toast.remove(); });
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
async function api(path) {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
|
||||
/**
|
||||
* Fetch JSON from an API endpoint with optional AbortSignal support.
|
||||
* Surfaces errors to the user via toast instead of silently failing.
|
||||
*
|
||||
* @param {string} path - API URL path.
|
||||
* @param {object} [opts] - Fetch options (may include signal).
|
||||
* @returns {Promise<any>} Parsed JSON response.
|
||||
*/
|
||||
async function api(path, opts) {
|
||||
var res;
|
||||
try {
|
||||
res = await fetch(path, opts || {});
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") throw err; // let callers handle abort
|
||||
showToast("Erreur réseau — vérifiez votre connexion");
|
||||
throw err;
|
||||
}
|
||||
if (!res.ok) {
|
||||
var detail = "";
|
||||
try { var body = await res.json(); detail = body.detail || ""; } catch (_) { /* no json body */ }
|
||||
showToast(detail || "Erreur API : " + res.status);
|
||||
throw new Error(detail || "API error: " + res.status);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -564,8 +633,17 @@
|
||||
}
|
||||
|
||||
async function loadDirectory(vaultName, dirPath, container) {
|
||||
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
|
||||
const data = await api(url);
|
||||
// Show inline loading indicator while fetching directory contents
|
||||
container.innerHTML = '<div class="tree-loading"><div class="loading-spinner" style="width:16px;height:16px;border-width:2px"></div></div>';
|
||||
|
||||
var data;
|
||||
try {
|
||||
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
|
||||
data = await api(url);
|
||||
} catch (err) {
|
||||
container.innerHTML = '<div class="tree-loading" style="color:var(--text-muted);font-size:0.75rem;padding:4px 16px">Erreur de chargement</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = "";
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
@@ -905,9 +983,17 @@
|
||||
if (active) active.classList.add("active");
|
||||
} catch (e) { /* selector might fail on special chars */ }
|
||||
|
||||
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
|
||||
const data = await api(url);
|
||||
renderFile(data);
|
||||
// Show loading state while fetching
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
|
||||
|
||||
try {
|
||||
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
|
||||
const data = await api(url);
|
||||
renderFile(data);
|
||||
} catch (err) {
|
||||
area.innerHTML = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier.</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderFile(data) {
|
||||
@@ -1324,13 +1410,26 @@
|
||||
}
|
||||
|
||||
async function performSearch(query, vaultFilter, tagFilter) {
|
||||
// Cancel any in-flight search request
|
||||
if (searchAbortController) {
|
||||
searchAbortController.abort();
|
||||
}
|
||||
searchAbortController = new AbortController();
|
||||
|
||||
showLoading();
|
||||
|
||||
|
||||
let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`;
|
||||
if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
|
||||
|
||||
const data = await api(url);
|
||||
renderSearchResults(data, query, tagFilter);
|
||||
try {
|
||||
const data = await api(url, { signal: searchAbortController.signal });
|
||||
renderSearchResults(data, query, tagFilter);
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") return; // superseded by newer request
|
||||
showWelcome();
|
||||
} finally {
|
||||
searchAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSearchResults(data, query, tagFilter) {
|
||||
@@ -1764,6 +1863,7 @@
|
||||
await Promise.all([loadVaults(), loadTags()]);
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize ObsiGate:", err);
|
||||
showToast("Erreur lors de l'initialisation");
|
||||
}
|
||||
|
||||
safeCreateIcons();
|
||||
|
||||
+4
-4
@@ -157,7 +157,7 @@
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Navigation des vaults">
|
||||
<div class="sidebar-tree" id="sidebar-tree">
|
||||
<!-- Sidebar filter -->
|
||||
<div class="sidebar-filter">
|
||||
@@ -190,7 +190,7 @@
|
||||
<i data-lucide="chevron-down" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
<div class="sidebar-panel-content" id="vault-panel-content">
|
||||
<div id="vault-tree"></div>
|
||||
<div id="vault-tree" role="tree" aria-label="Arborescence des fichiers"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -212,8 +212,8 @@
|
||||
<div class="sidebar-resize-handle" id="sidebar-resize-handle"></div>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="content-area" id="content-area">
|
||||
<div class="welcome" id="welcome">
|
||||
<main class="content-area" id="content-area" aria-label="Contenu principal">
|
||||
<div class="welcome" id="welcome" role="status">
|
||||
<i data-lucide="library" style="width:48px;height:48px;color:var(--text-muted)"></i>
|
||||
<h2>ObsiGate</h2>
|
||||
<p>Sélectionnez un fichier dans la sidebar ou utilisez la recherche pour commencer.</p>
|
||||
|
||||
+62
-2
@@ -26,6 +26,10 @@
|
||||
--scrollbar: #30363d;
|
||||
--resize-handle: #30363d;
|
||||
--overlay-bg: rgba(0,0,0,0.5);
|
||||
--danger: #ff7b72;
|
||||
--danger-bg: #3d1a18;
|
||||
--success: #3fb950;
|
||||
--success-bg: #1a3d1f;
|
||||
}
|
||||
|
||||
/* ===== THEME — LIGHT ===== */
|
||||
@@ -47,6 +51,10 @@
|
||||
--scrollbar: #d0d7de;
|
||||
--resize-handle: #d0d7de;
|
||||
--overlay-bg: rgba(0,0,0,0.3);
|
||||
--danger: #cf222e;
|
||||
--danger-bg: #ffebe9;
|
||||
--success: #1a7f37;
|
||||
--success-bg: #dafbe1;
|
||||
}
|
||||
|
||||
/* ===== BASE ===== */
|
||||
@@ -1382,8 +1390,8 @@ select {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.editor-btn.danger:hover {
|
||||
color: #ff7b72;
|
||||
border-color: #ff7b72;
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
@@ -2059,3 +2067,55 @@ body.resizing-v {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* --- Toast notifications --- */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
transition: opacity 250ms ease, transform 250ms ease;
|
||||
pointer-events: auto;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.toast-error {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
.toast-success {
|
||||
border-color: var(--success);
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* --- Tree loading indicator --- */
|
||||
.tree-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user