refactor: split app.js (8875 lines) into 12 ES modules
frontend/js/ structure: state.js (55 lines) — Shared mutable state, constants utils.js (510 lines) — EXT_ICONS, getFileIcon, escapeHtml, safeCreateIcons auth.js (547 lines) — api(), AuthManager, initLoginForm, AdminPanel search.js (1106 lines)— SearchHistory, QueryParser, Autocomplete, performSearch sidebar.js (1091 lines)— Vault tree, sidebar filter, TagFilterService, loadTags viewer.js (1554 lines)— openFile, Outline, ScrollSpy, Frontmatter, Editor ui.js (2250 lines)— Theme, Toast, Sidebar, Dropdowns, Tabs, ContextMenu dashboard.js (461 lines) — Dashboard widgets (Recent, Stats, Bookmarks) config.js (999 lines) — Config panel, Hidden files, About, Sidebar tabs sync.js (436 lines) — SSE/IndexUpdateManager, PWA registration graph.js (401 lines) — GraphViewManager (force-directed canvas graph) legacy.js (550 lines) — Remaining bridge functions (goHome, showWelcome, initSearch) app.js (80 lines) — Thin orchestrator: imports all modules, calls init() index.html: switched from <script src="app.js"> to <script type="module" src="js/app.js"> Original app.js preserved for backward compatibility. All 14 modules pass node --check syntax validation.
This commit is contained in:
+1
-1
@@ -1133,6 +1133,6 @@
|
||||
<div class="find-error" id="find-error" hidden></div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* ObsiGate — Module orchestrator (ES modules).
|
||||
Replaces the monolithic frontend/app.js IIFE.
|
||||
All code lives in frontend/js/*.js modules. */
|
||||
|
||||
import * as UI from './ui.js';
|
||||
import * as Sidebar from './sidebar.js';
|
||||
import * as Auth from './auth.js';
|
||||
import * as Viewer from './viewer.js';
|
||||
import * as Utils from './utils.js';
|
||||
import * as Sync from './sync.js';
|
||||
import { initGraphView } from './graph.js';
|
||||
import * as Legacy from './legacy.js';
|
||||
|
||||
// =========================================================================
|
||||
// Initialization — mirrors the original app.js init() ordering
|
||||
// =========================================================================
|
||||
async function init() {
|
||||
UI.initTheme();
|
||||
UI.initHeaderMenu();
|
||||
UI.initCustomDropdowns();
|
||||
|
||||
document.getElementById("theme-toggle").addEventListener("click", UI.toggleTheme);
|
||||
document.getElementById("header-logo").addEventListener("click", Legacy.goHome);
|
||||
const refreshBtn = document.getElementById("header-refresh-btn");
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", Legacy.goHome);
|
||||
|
||||
Legacy.initSearch();
|
||||
UI.initSidebarToggle();
|
||||
UI.initMobile();
|
||||
Sidebar.initVaultContext();
|
||||
Legacy.initSidebarTabs();
|
||||
Legacy.initHelpModal();
|
||||
Legacy.initConfigModal();
|
||||
Sidebar.initSidebarFilter();
|
||||
UI.initSidebarResize();
|
||||
Utils.initEditor();
|
||||
Auth.initLoginForm();
|
||||
Legacy.initRecentTab();
|
||||
|
||||
UI.RightSidebarManager.init();
|
||||
UI.FindInPageManager.init();
|
||||
UI.ContextMenuManager.init();
|
||||
|
||||
const authOk = await Auth.AuthManager.initAuth();
|
||||
|
||||
if (authOk) {
|
||||
Legacy.initSyncStatus();
|
||||
|
||||
try {
|
||||
await Promise.all([Legacy.loadVaultSettings(), Sidebar.loadVaults(), Sidebar.loadTags()]);
|
||||
|
||||
Legacy.DashboardRecentWidget.init();
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get("popup") === "true") {
|
||||
document.body.classList.add("popup-mode");
|
||||
}
|
||||
|
||||
if (window.location.hash && window.location.hash.startsWith("#file=")) {
|
||||
const hashVal = window.location.hash.substring(6);
|
||||
const sepIndex = hashVal.indexOf(":");
|
||||
if (sepIndex > -1) {
|
||||
const vault = decodeURIComponent(hashVal.substring(0, sepIndex));
|
||||
const path = decodeURIComponent(hashVal.substring(sepIndex + 1));
|
||||
Viewer.openFile(vault, path);
|
||||
}
|
||||
} else if (urlParams.get("popup") !== "true") {
|
||||
Legacy.showWelcome();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize ObsiGate:", err);
|
||||
UI.showToast("Erreur lors de l'initialisation", "error");
|
||||
}
|
||||
}
|
||||
|
||||
initGraphView();
|
||||
Utils.safeCreateIcons();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
init();
|
||||
Sync.init();
|
||||
});
|
||||
@@ -0,0 +1,547 @@
|
||||
/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */
|
||||
import * as State from './state.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// Inject auth header if authenticated
|
||||
const authHeaders = AuthManager.getAuthHeaders();
|
||||
const mergedOpts = opts || {};
|
||||
// Auto-set Content-Type for JSON bodies
|
||||
if (mergedOpts.body && typeof mergedOpts.body === "string" && !mergedOpts.headers?.["Content-Type"]) {
|
||||
mergedOpts.headers = { ...mergedOpts.headers, "Content-Type": "application/json" };
|
||||
}
|
||||
if (authHeaders) {
|
||||
mergedOpts.headers = { ...mergedOpts.headers, ...authHeaders };
|
||||
}
|
||||
mergedOpts.credentials = "include";
|
||||
res = await fetch(path, mergedOpts);
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") throw err; // let callers handle abort
|
||||
showToast("Erreur réseau — vérifiez votre connexion", "error");
|
||||
throw err;
|
||||
}
|
||||
if (res.status === 401 && AuthManager._authEnabled) {
|
||||
// Token expired — try refresh
|
||||
try {
|
||||
await AuthManager.refreshAccessToken();
|
||||
// Retry the request with new token
|
||||
const retryHeaders = AuthManager.getAuthHeaders();
|
||||
const retryOpts = opts || {};
|
||||
retryOpts.headers = { ...retryOpts.headers, ...retryHeaders };
|
||||
retryOpts.credentials = "include";
|
||||
res = await fetch(path, retryOpts);
|
||||
} catch (refreshErr) {
|
||||
AuthManager.clearSession();
|
||||
AuthManager.showLoginScreen();
|
||||
throw new Error("Session expirée");
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
var detail = "";
|
||||
try {
|
||||
var body = await res.json();
|
||||
detail = body.detail || "";
|
||||
} catch (_) {
|
||||
/* no json body */
|
||||
}
|
||||
showToast(detail || "Erreur API : " + res.status, "error");
|
||||
throw new Error(detail || "API error: " + res.status);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AuthManager — Authentication state & token management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AuthManager = {
|
||||
ACCESS_TOKEN_KEY: "obsigate_access_token",
|
||||
TOKEN_EXPIRY_KEY: "obsigate_token_expiry",
|
||||
USER_KEY: "obsigate_user",
|
||||
_authEnabled: false,
|
||||
|
||||
// ── Token storage (sessionStorage) ─────────────────────────────
|
||||
|
||||
saveToken(tokenData) {
|
||||
const expiresAt = Date.now() + tokenData.expires_in * 1000;
|
||||
sessionStorage.setItem(this.ACCESS_TOKEN_KEY, tokenData.access_token);
|
||||
sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiresAt.toString());
|
||||
if (tokenData.user) {
|
||||
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
|
||||
}
|
||||
},
|
||||
|
||||
getToken() {
|
||||
return sessionStorage.getItem(this.ACCESS_TOKEN_KEY);
|
||||
},
|
||||
|
||||
getUser() {
|
||||
const raw = sessionStorage.getItem(this.USER_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
},
|
||||
|
||||
isTokenExpired() {
|
||||
const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY);
|
||||
if (!expiry) return true;
|
||||
// Renew 60s before expiration
|
||||
return Date.now() > parseInt(expiry) - 60000;
|
||||
},
|
||||
|
||||
clearSession() {
|
||||
sessionStorage.removeItem(this.ACCESS_TOKEN_KEY);
|
||||
sessionStorage.removeItem(this.TOKEN_EXPIRY_KEY);
|
||||
sessionStorage.removeItem(this.USER_KEY);
|
||||
},
|
||||
|
||||
getAuthHeaders() {
|
||||
const token = this.getToken();
|
||||
if (!token || !this._authEnabled) return null;
|
||||
return { Authorization: "Bearer " + token };
|
||||
},
|
||||
|
||||
// ── API calls ──────────────────────────────────────────────────
|
||||
|
||||
async login(username, password, rememberMe) {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, password, remember_me: rememberMe || false }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || "Erreur de connexion");
|
||||
}
|
||||
const data = await response.json();
|
||||
this.saveToken(data);
|
||||
return data.user;
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
const token = this.getToken();
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: "Bearer " + token } : {},
|
||||
credentials: "include",
|
||||
});
|
||||
} catch (e) {
|
||||
/* continue even if API fails */
|
||||
}
|
||||
this.clearSession();
|
||||
this.showLoginScreen();
|
||||
},
|
||||
|
||||
async refreshAccessToken() {
|
||||
const response = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
this.clearSession();
|
||||
throw new Error("Session expirée");
|
||||
}
|
||||
const data = await response.json();
|
||||
const expiry = Date.now() + data.expires_in * 1000;
|
||||
sessionStorage.setItem(this.ACCESS_TOKEN_KEY, data.access_token);
|
||||
sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiry.toString());
|
||||
return data.access_token;
|
||||
},
|
||||
|
||||
// ── UI controls ────────────────────────────────────────────────
|
||||
|
||||
showLoginScreen() {
|
||||
const app = document.getElementById("app");
|
||||
const login = document.getElementById("login-screen");
|
||||
if (app) app.classList.add("hidden");
|
||||
if (login) {
|
||||
login.classList.remove("hidden");
|
||||
const usernameInput = document.getElementById("login-username");
|
||||
if (usernameInput) usernameInput.focus();
|
||||
}
|
||||
},
|
||||
|
||||
showApp() {
|
||||
const login = document.getElementById("login-screen");
|
||||
const app = document.getElementById("app");
|
||||
if (login) login.classList.add("hidden");
|
||||
if (app) app.classList.remove("hidden");
|
||||
this.renderUserMenu();
|
||||
},
|
||||
|
||||
renderUserMenu() {
|
||||
const user = this.getUser();
|
||||
const userMenu = document.getElementById("user-menu");
|
||||
if (!userMenu) return;
|
||||
if (!user || !this._authEnabled) {
|
||||
userMenu.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
userMenu.innerHTML = '<span class="user-display-name">' + (user.display_name || user.username) + "</span>" + '<button class="btn-logout" id="logout-btn" title="Déconnexion"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
|
||||
safeCreateIcons();
|
||||
|
||||
const logoutBtn = document.getElementById("logout-btn");
|
||||
if (logoutBtn) logoutBtn.addEventListener("click", () => AuthManager.logout());
|
||||
|
||||
const adminRow = document.getElementById("admin-menu-row");
|
||||
if (adminRow) {
|
||||
if (user.role === "admin") {
|
||||
adminRow.classList.remove("hidden");
|
||||
// Important: use an inline function to ensure we don't bind multiple identical listeners on rerenders, or clean up before
|
||||
adminRow.onclick = () => {
|
||||
closeHeaderMenu();
|
||||
AdminPanel.show();
|
||||
};
|
||||
} else {
|
||||
adminRow.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Initialization ──────────────────────────────────────────────
|
||||
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/status");
|
||||
const data = await res.json();
|
||||
this._authEnabled = data.auth_enabled;
|
||||
return data;
|
||||
} catch (e) {
|
||||
this._authEnabled = false;
|
||||
return { auth_enabled: false };
|
||||
}
|
||||
},
|
||||
|
||||
async initAuth() {
|
||||
const status = await this.checkAuthStatus();
|
||||
if (!status.auth_enabled) {
|
||||
// Auth disabled — show app immediately
|
||||
this.showApp();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Auth enabled — check for existing session
|
||||
if (this.getToken() && !this.isTokenExpired()) {
|
||||
this.showApp();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try silent refresh
|
||||
try {
|
||||
await this.refreshAccessToken();
|
||||
// Fetch user info
|
||||
const token = this.getToken();
|
||||
const res = await fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.ok) {
|
||||
const user = await res.json();
|
||||
sessionStorage.setItem(this.USER_KEY, JSON.stringify(user));
|
||||
this.showApp();
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
/* silent refresh failed */
|
||||
}
|
||||
|
||||
// No valid session — show login
|
||||
this.showLoginScreen();
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login form handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initLoginForm() {
|
||||
const form = document.getElementById("login-form");
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById("login-username").value;
|
||||
const password = document.getElementById("login-password").value;
|
||||
const rememberMe = document.getElementById("remember-me").checked;
|
||||
const errorEl = document.getElementById("login-error");
|
||||
const btn = document.getElementById("login-btn");
|
||||
|
||||
btn.disabled = true;
|
||||
btn.querySelector(".btn-spinner").classList.remove("hidden");
|
||||
btn.querySelector(".btn-text").textContent = "Connexion...";
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
await AuthManager.login(username, password, rememberMe);
|
||||
AuthManager.showApp();
|
||||
// Load app data after successful login
|
||||
try {
|
||||
await Promise.all([loadVaults(), loadTags()]);
|
||||
// Start SSE sync now that auth cookie is set
|
||||
IndexUpdateManager.connect();
|
||||
// Show dashboard
|
||||
showWelcome();
|
||||
} catch (err) {
|
||||
console.error("Failed to load data after login:", err);
|
||||
}
|
||||
safeCreateIcons();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
document.getElementById("login-password").value = "";
|
||||
document.getElementById("login-password").focus();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.querySelector(".btn-spinner").classList.add("hidden");
|
||||
btn.querySelector(".btn-text").textContent = "Se connecter";
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle password visibility
|
||||
const toggleBtn = document.getElementById("toggle-password");
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
const input = document.getElementById("login-password");
|
||||
input.type = input.type === "password" ? "text" : "password";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin Panel — User management (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AdminPanel = {
|
||||
_modal: null,
|
||||
_allVaults: [],
|
||||
|
||||
show() {
|
||||
this._createModal();
|
||||
this._modal.classList.add("active");
|
||||
this._loadUsers();
|
||||
},
|
||||
|
||||
hide() {
|
||||
if (this._modal) this._modal.classList.remove("active");
|
||||
},
|
||||
|
||||
_createModal() {
|
||||
if (this._modal) return;
|
||||
this._modal = document.createElement("div");
|
||||
this._modal.className = "editor-modal";
|
||||
this._modal.id = "admin-modal";
|
||||
this._modal.innerHTML = `
|
||||
<div class="editor-container">
|
||||
<div class="editor-header">
|
||||
<div class="editor-title">⚙️ Administration — Utilisateurs</div>
|
||||
<div class="editor-actions">
|
||||
<button class="editor-btn" id="admin-close" title="Fermer">
|
||||
<i data-lucide="x" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-body" id="admin-body">
|
||||
<div class="admin-toolbar">
|
||||
<button class="btn-login" id="admin-add-user" style="font-size:0.85rem;padding:6px 16px;">+ Nouvel utilisateur</button>
|
||||
</div>
|
||||
<div id="admin-users-list" class="admin-users-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(this._modal);
|
||||
safeCreateIcons();
|
||||
|
||||
document.getElementById("admin-close").addEventListener("click", () => this.hide());
|
||||
document.getElementById("admin-add-user").addEventListener("click", () => this._showUserForm(null));
|
||||
},
|
||||
|
||||
async _loadUsers() {
|
||||
try {
|
||||
const users = await api("/api/auth/admin/users");
|
||||
// Also load available vaults
|
||||
try {
|
||||
const vaultsData = await api("/api/vaults");
|
||||
this._allVaults = vaultsData.map((v) => v.name);
|
||||
} catch (e) {
|
||||
this._allVaults = [];
|
||||
}
|
||||
this._renderUsers(users);
|
||||
} catch (err) {
|
||||
document.getElementById("admin-users-list").innerHTML = '<p style="color:var(--danger);padding:16px;">Erreur : ' + err.message + "</p>";
|
||||
}
|
||||
},
|
||||
|
||||
_renderUsers(users) {
|
||||
const container = document.getElementById("admin-users-list");
|
||||
if (!users.length) {
|
||||
container.innerHTML = '<p style="padding:16px;color:var(--text-muted);">Aucun utilisateur.</p>';
|
||||
return;
|
||||
}
|
||||
let html = '<table class="admin-table"><thead><tr>' + "<th>Utilisateur</th><th>Rôle</th><th>Vaults</th><th>Statut</th><th>Dernière connexion</th><th>Actions</th>" + "</tr></thead><tbody>";
|
||||
users.forEach((u) => {
|
||||
const vaults = u.vaults.includes("*") ? "Toutes" : u.vaults.join(", ") || "Aucune";
|
||||
const status = u.active ? "✅" : "🔴";
|
||||
const lastLogin = u.last_login ? new Date(u.last_login).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "Jamais";
|
||||
html +=
|
||||
"<tr>" +
|
||||
"<td><strong>" +
|
||||
u.username +
|
||||
"</strong>" +
|
||||
(u.display_name && u.display_name !== u.username ? "<br><small>" + u.display_name + "</small>" : "") +
|
||||
"</td>" +
|
||||
'<td><span class="admin-role-badge admin-role-' +
|
||||
u.role +
|
||||
'">' +
|
||||
u.role +
|
||||
"</span></td>" +
|
||||
'<td><span class="admin-vaults-text">' +
|
||||
vaults +
|
||||
"</span></td>" +
|
||||
"<td>" +
|
||||
status +
|
||||
"</td>" +
|
||||
"<td><small>" +
|
||||
lastLogin +
|
||||
"</small></td>" +
|
||||
'<td class="admin-actions">' +
|
||||
'<button class="admin-action-btn" data-action="edit" data-username="' +
|
||||
u.username +
|
||||
'" title="Modifier">✏️</button>' +
|
||||
'<button class="admin-action-btn danger" data-action="delete" data-username="' +
|
||||
u.username +
|
||||
'" title="Supprimer">🗑️</button>' +
|
||||
"</td></tr>";
|
||||
});
|
||||
html += "</tbody></table>";
|
||||
container.innerHTML = html;
|
||||
|
||||
// Bind action buttons
|
||||
container.querySelectorAll('[data-action="edit"]').forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const user = users.find((u) => u.username === btn.dataset.username);
|
||||
if (user) this._showUserForm(user);
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('[data-action="delete"]').forEach((btn) => {
|
||||
btn.addEventListener("click", () => this._deleteUser(btn.dataset.username));
|
||||
});
|
||||
},
|
||||
|
||||
_showUserForm(user) {
|
||||
const isEdit = !!user;
|
||||
const title = isEdit ? "Modifier : " + user.username : "Nouvel utilisateur";
|
||||
const vaultCheckboxes = this._allVaults
|
||||
.map((v) => {
|
||||
const checked = user && (user.vaults.includes(v) || user.vaults.includes("*")) ? "checked" : "";
|
||||
return '<label class="checkbox-label"><input type="checkbox" name="vault" value="' + v + '" ' + checked + "><span>" + v + "</span></label>";
|
||||
})
|
||||
.join("");
|
||||
const allVaultsChecked = user && user.vaults.includes("*") ? "checked" : "";
|
||||
|
||||
// Create form modal overlay
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "admin-form-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="admin-form-card">
|
||||
<h3>${title}</h3>
|
||||
<form id="admin-user-form">
|
||||
${!isEdit ? '<div class="form-group"><label>Nom d\'utilisateur</label><input type="text" name="username" required pattern="[a-zA-Z0-9_-]{2,32}" placeholder="username"></div>' : ""}
|
||||
<div class="form-group"><label>Nom affiché</label><input type="text" name="display_name" value="${isEdit ? user.display_name || "" : ""}"></div>
|
||||
<div class="form-group"><label>${isEdit ? "Nouveau mot de passe (vide = inchangé)" : "Mot de passe"}</label><input type="password" name="password" ${!isEdit ? 'required minlength="8"' : ""} placeholder="${isEdit ? "Laisser vide pour ne pas changer" : "Min. 8 caractères"}"></div>
|
||||
<div class="form-group"><label>Rôle</label><select name="role"><option value="user" ${isEdit && user.role === "user" ? "selected" : ""}>Utilisateur</option><option value="admin" ${isEdit && user.role === "admin" ? "selected" : ""}>Admin</option></select></div>
|
||||
<div class="form-group">
|
||||
<label>Vaults autorisées</label>
|
||||
<div class="admin-vault-list">${vaultCheckboxes}</div>
|
||||
<label class="checkbox-label" style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px;"><input type="checkbox" id="admin-all-vaults" ${allVaultsChecked}><span><strong>Accès total</strong> (toutes les vaults, y compris futures)</span></label>
|
||||
</div>
|
||||
${isEdit ? '<div class="form-group"><label>Compte actif</label><label class="checkbox-label"><input type="checkbox" name="active" ' + (user.active ? "checked" : "") + "><span>Actif</span></label></div>" : ""}
|
||||
<div class="admin-form-actions">
|
||||
<button type="button" class="config-btn-secondary" id="admin-form-cancel">Annuler</button>
|
||||
<button type="submit" class="btn-login" style="font-size:0.85rem;padding:6px 20px;">Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
this._modal.appendChild(overlay);
|
||||
|
||||
document.getElementById("admin-form-cancel").addEventListener("click", () => overlay.remove());
|
||||
|
||||
document.getElementById("admin-user-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const allVaults = document.getElementById("admin-all-vaults").checked;
|
||||
const selectedVaults = allVaults ? ["*"] : Array.from(form.querySelectorAll('input[name="vault"]:checked')).map((cb) => cb.value);
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
const updates = {
|
||||
display_name: form.display_name.value || null,
|
||||
role: form.role.value,
|
||||
vaults: selectedVaults,
|
||||
};
|
||||
if (form.password.value) updates.password = form.password.value;
|
||||
const activeCheckbox = form.querySelector('input[name="active"]');
|
||||
if (activeCheckbox) updates.active = activeCheckbox.checked;
|
||||
await api("/api/auth/admin/users/" + user.username, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
} else {
|
||||
await api("/api/auth/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: form.username.value,
|
||||
password: form.password.value,
|
||||
display_name: form.display_name.value || null,
|
||||
role: form.role.value,
|
||||
vaults: selectedVaults,
|
||||
}),
|
||||
});
|
||||
}
|
||||
overlay.remove();
|
||||
this._loadUsers();
|
||||
showToast(isEdit ? "Utilisateur modifié" : "Utilisateur créé", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async _deleteUser(username) {
|
||||
const currentUser = AuthManager.getUser();
|
||||
if (currentUser && currentUser.username === username) {
|
||||
showToast("Impossible de supprimer son propre compte", "error");
|
||||
return;
|
||||
}
|
||||
if (!confirm("Supprimer l'utilisateur \"" + username + '" ?')) return;
|
||||
try {
|
||||
await api("/api/auth/admin/users/" + username, { method: "DELETE" });
|
||||
this._loadUsers();
|
||||
showToast("Utilisateur supprimé", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export { api, AuthManager, initLoginForm, AdminPanel };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,461 @@
|
||||
// dashboard.js — extracted from app.js (3414-3806) + DashboardBookmarkWidget (3810-3870)
|
||||
import { selectedContextVault, allVaults } from './state.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent files
|
||||
// ---------------------------------------------------------------------------
|
||||
let _recentRefreshTimer = null;
|
||||
let _recentTimestampTimer = null;
|
||||
let _recentFilesCache = [];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard Recent Files Widget
|
||||
// ---------------------------------------------------------------------------
|
||||
// ── Dashboard Stats Widget ──
|
||||
const DashboardStatsWidget = {
|
||||
async load() {
|
||||
const grid = document.getElementById("dashboard-stats-grid");
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '<div class="dashboard-stats-loading">Chargement...</div>';
|
||||
try {
|
||||
const data = await api("/api/dashboard");
|
||||
this.render(data);
|
||||
} catch (err) {
|
||||
grid.innerHTML = `<div class="dashboard-recent-empty">Erreur: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
},
|
||||
render(data) {
|
||||
const grid = document.getElementById("dashboard-stats-grid");
|
||||
if (!grid) return;
|
||||
const fmtSize = (bytes) => bytes < 1024 ? `${bytes} o` : bytes < 1048576 ? `${(bytes/1024).toFixed(1)} Ko` : bytes < 1073741824 ? `${(bytes/1048576).toFixed(1)} Mo` : `${(bytes/1073741824).toFixed(1)} Go`;
|
||||
const items = [
|
||||
{ icon: "files", label: "Fichiers", value: data.total_files.toLocaleString() },
|
||||
{ icon: "tags", label: "Tags uniques", value: data.total_tags.toLocaleString() },
|
||||
{ icon: "hard-drive", label: "Taille totale", value: fmtSize(data.total_size_bytes) },
|
||||
{ icon: "folder-open", label: "Vaults", value: data.vaults.length.toString() },
|
||||
];
|
||||
grid.innerHTML = items.map(i => `
|
||||
<div class="stat-card">
|
||||
<i data-lucide="${i.icon}" class="stat-icon"></i>
|
||||
<span class="stat-value">${i.value}</span>
|
||||
<span class="stat-label">${i.label}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
safeCreateIcons();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Dashboard Shared Widget ──
|
||||
const DashboardSharedWidget = {
|
||||
async load() {
|
||||
const grid = document.getElementById("dashboard-shared-grid");
|
||||
const empty = document.getElementById("dashboard-shared-empty");
|
||||
if (!grid) return;
|
||||
try {
|
||||
const shares = await api("/api/shares");
|
||||
if (!shares.length) { if (empty) empty.style.display = ""; grid.innerHTML = ""; return; }
|
||||
if (empty) empty.style.display = "none";
|
||||
grid.innerHTML = shares.map(s => `
|
||||
<div class="shared-card" data-vault="${escapeHtml(s.vault)}" data-path="${escapeHtml(s.path)}">
|
||||
<div class="shared-card-header">
|
||||
<i data-lucide="file-text" style="width:14px;height:14px"></i>
|
||||
<span class="shared-card-title">${escapeHtml(s.path.split("/").pop().replace(/\.md$/i, ""))}</span>
|
||||
<span class="shared-card-vault">${escapeHtml(s.vault)}</span>
|
||||
</div>
|
||||
<div class="shared-card-meta">
|
||||
<span>${s.access_count || 0} vue(s)</span>
|
||||
${s.expires_at ? `<span>Expire le ${new Date(s.expires_at).toLocaleDateString("fr-FR")}</span>` : ""}
|
||||
</div>
|
||||
<div class="shared-card-actions">
|
||||
<button class="shared-copy-btn" data-url="${window.location.origin}/s/${s.token}">📋 Copier</button>
|
||||
<button class="shared-open-btn">📂 Ouvrir</button>
|
||||
<button class="shared-revoke-btn" data-id="${s.id}">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
lucide.createIcons();
|
||||
grid.querySelectorAll(".shared-copy-btn").forEach(b => b.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const url = b.dataset.url;
|
||||
try { await navigator.clipboard.writeText(url); } catch { const ta = document.createElement("textarea"); ta.value=url; ta.style.position="fixed"; ta.style.left="-9999px"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); }
|
||||
showToast("Lien copié !", "success");
|
||||
}));
|
||||
grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const card = b.closest(".shared-card");
|
||||
if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path);
|
||||
}));
|
||||
grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await api(`/api/share/${b.dataset.id}`, { method: "DELETE" });
|
||||
showToast("Partage révoqué", "success");
|
||||
this.load();
|
||||
}));
|
||||
grid.querySelectorAll(".shared-card").forEach(card => card.addEventListener("click", () => {
|
||||
TabManager.openPreview(card.dataset.vault, card.dataset.path);
|
||||
}));
|
||||
} catch (err) { if (empty) empty.style.display = ""; }
|
||||
}
|
||||
};
|
||||
|
||||
// ── Dashboard Conflicts Widget ──
|
||||
const DashboardConflictsWidget = {
|
||||
async load() {
|
||||
const container = document.getElementById("dashboard-conflicts-container");
|
||||
if (!container) return;
|
||||
try {
|
||||
const data = await api("/api/conflicts");
|
||||
if (data.total === 0) { container.innerHTML = ""; return; }
|
||||
this.render(data.conflicts, container);
|
||||
} catch (err) { container.innerHTML = ""; }
|
||||
},
|
||||
render(conflicts, container) {
|
||||
container.innerHTML = `
|
||||
<div class="dashboard-section">
|
||||
<div class="dashboard-header">
|
||||
<div class="dashboard-title-row">
|
||||
<i data-lucide="alert-triangle" class="dashboard-icon" style="color:var(--accent-orange)"></i>
|
||||
<h2>Conflits de synchronisation</h2>
|
||||
<span class="dashboard-badge" style="background:var(--accent-orange)">${conflicts.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-conflicts-grid">
|
||||
${conflicts.map(c => `
|
||||
<div class="conflict-card">
|
||||
<div class="conflict-info">
|
||||
<span class="conflict-vault">${escapeHtml(c.vault)}</span>
|
||||
<span class="conflict-name">${escapeHtml(c.conflict_path.split("/").pop())}</span>
|
||||
<span class="conflict-date">Conflit du ${c.conflict_date.replace(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/, "$3/$2/$1 $4:$5")}</span>
|
||||
</div>
|
||||
<div class="conflict-actions">
|
||||
<button class="conflict-btn keep-local" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">Garder l'original</button>
|
||||
<button class="conflict-btn keep-conflict" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">Garder le conflit</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>`;
|
||||
lucide.createIcons();
|
||||
container.querySelectorAll(".keep-local").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_local")));
|
||||
container.querySelectorAll(".keep-conflict").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_conflict")));
|
||||
},
|
||||
async _resolve(d, action) {
|
||||
try {
|
||||
await api("/api/conflicts/resolve", { method: "POST", body: JSON.stringify({ vault: d.vault, conflict_path: d.conflict, original_path: d.original, action }) });
|
||||
showToast("Conflit résolu", "success");
|
||||
this.load();
|
||||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||||
}
|
||||
};
|
||||
|
||||
const DashboardRecentWidget = {
|
||||
_cache: [],
|
||||
_currentFilter: "",
|
||||
|
||||
async load(vaultFilter = "") {
|
||||
const v = vaultFilter || selectedContextVault || "all";
|
||||
this._currentFilter = v;
|
||||
this.showLoading();
|
||||
|
||||
let url = "/api/recent?mode=opened";
|
||||
if (v !== "all") url += `&vault=${encodeURIComponent(v)}`;
|
||||
|
||||
try {
|
||||
const data = await api(url);
|
||||
this._cache = data.files || [];
|
||||
this.render();
|
||||
} catch (err) {
|
||||
console.error("Dashboard: Failed to load recent files:", err);
|
||||
this.showError();
|
||||
}
|
||||
},
|
||||
|
||||
async toggleBookmark(vault, path, title, card) {
|
||||
try {
|
||||
const data = await api("/api/bookmarks/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ vault, path, title }),
|
||||
});
|
||||
|
||||
// Refresh both widgets to keep sync
|
||||
DashboardBookmarkWidget.load();
|
||||
|
||||
// Update current card icon if it exists
|
||||
if (card) {
|
||||
const btn = card.querySelector(".dashboard-card-bookmark-btn");
|
||||
if (btn) {
|
||||
btn.classList.toggle("active", data.bookmarked);
|
||||
const icon = btn.querySelector("i");
|
||||
if (icon) icon.setAttribute("data-lucide", data.bookmarked ? "bookmark" : "bookmark-plus");
|
||||
safeCreateIcons();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need to refresh the current list to reflect bookmark status across all cards
|
||||
// To avoid flickering, just update the cache and re-render if needed or do a silent refresh
|
||||
this._cache.forEach(f => {
|
||||
if (f.vault === vault && f.path === path) f.bookmarked = data.bookmarked;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle bookmark:", err);
|
||||
showToast("Erreur lors de l'épinglage", "error");
|
||||
}
|
||||
},
|
||||
|
||||
showLoading() {
|
||||
const grid = document.getElementById("dashboard-recent-grid");
|
||||
const loading = document.getElementById("dashboard-loading");
|
||||
const empty = document.getElementById("dashboard-recent-empty");
|
||||
const count = document.getElementById("dashboard-count");
|
||||
|
||||
if (grid) grid.innerHTML = "";
|
||||
if (loading) loading.classList.add("active");
|
||||
if (empty) empty.classList.add("hidden");
|
||||
if (count) count.textContent = "";
|
||||
},
|
||||
|
||||
render() {
|
||||
const grid = document.getElementById("dashboard-recent-grid");
|
||||
const loading = document.getElementById("dashboard-loading");
|
||||
const empty = document.getElementById("dashboard-recent-empty");
|
||||
const count = document.getElementById("dashboard-count");
|
||||
|
||||
if (loading) loading.classList.remove("active");
|
||||
|
||||
if (!this._cache || this._cache.length === 0) {
|
||||
this.showEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty) empty.classList.add("hidden");
|
||||
if (count) count.textContent = `${this._cache.length} fichier${this._cache.length > 1 ? "s" : ""}`;
|
||||
|
||||
if (!grid) return;
|
||||
grid.innerHTML = "";
|
||||
|
||||
this._cache.forEach((f, index) => {
|
||||
const card = this._createCard(f, index);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
safeCreateIcons();
|
||||
},
|
||||
|
||||
_createCard(file, index) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "dashboard-card";
|
||||
card.setAttribute("data-vault", file.vault);
|
||||
card.setAttribute("data-path", file.path);
|
||||
card.style.animationDelay = `${Math.min(index * 50, 400)}ms`;
|
||||
|
||||
// Header with icon and vault badge
|
||||
const header = document.createElement("div");
|
||||
header.className = "dashboard-card-header";
|
||||
|
||||
const iconContainer = document.createElement("div");
|
||||
iconContainer.className = "dashboard-card-icon";
|
||||
const fileIconName = getFileIcon(file.path);
|
||||
try {
|
||||
iconContainer.appendChild(icon(fileIconName, 24));
|
||||
} catch (e) {
|
||||
console.error("Error creating icon:", fileIconName, e);
|
||||
// Fallback to default file icon
|
||||
iconContainer.appendChild(icon("file", 24));
|
||||
}
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "dashboard-vault-badge";
|
||||
badge.textContent = file.vault;
|
||||
|
||||
const bookmarkBtn = document.createElement("button");
|
||||
bookmarkBtn.className = `dashboard-card-bookmark-btn ${file.bookmarked ? "active" : ""}`;
|
||||
bookmarkBtn.title = file.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks";
|
||||
bookmarkBtn.innerHTML = `<i data-lucide="${file.bookmarked ? "bookmark" : "bookmark-plus"}" style="width:14px;height:14px"></i>`;
|
||||
|
||||
bookmarkBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleBookmark(file.vault, file.path, file.title, card);
|
||||
});
|
||||
|
||||
header.appendChild(iconContainer);
|
||||
header.appendChild(badge);
|
||||
header.appendChild(bookmarkBtn);
|
||||
card.appendChild(header);
|
||||
|
||||
// Title
|
||||
const title = document.createElement("h3");
|
||||
title.className = "dashboard-card-title";
|
||||
title.textContent = file.title || file.path.split("/").pop();
|
||||
title.title = file.title || file.path;
|
||||
card.appendChild(title);
|
||||
|
||||
// Path (compact)
|
||||
const pathParts = file.path.split("/");
|
||||
if (pathParts.length > 1) {
|
||||
const path = document.createElement("div");
|
||||
path.className = "dashboard-card-path";
|
||||
path.textContent = pathParts.slice(0, -1).join(" / ");
|
||||
path.title = file.path;
|
||||
card.appendChild(path);
|
||||
}
|
||||
|
||||
// Footer with time and tags
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "dashboard-card-footer";
|
||||
|
||||
const time = document.createElement("span");
|
||||
time.className = "dashboard-card-time";
|
||||
time.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> ${file.mtime_human || this._humanizeDelta(file.mtime)}`;
|
||||
|
||||
footer.appendChild(time);
|
||||
|
||||
// Tags
|
||||
if (file.tags && file.tags.length > 0) {
|
||||
const tags = document.createElement("div");
|
||||
tags.className = "dashboard-card-tags";
|
||||
file.tags.slice(0, 3).forEach((tag) => {
|
||||
const tagEl = document.createElement("span");
|
||||
tagEl.className = "tag-pill";
|
||||
tagEl.textContent = tag;
|
||||
tags.appendChild(tagEl);
|
||||
});
|
||||
footer.appendChild(tags);
|
||||
}
|
||||
|
||||
card.appendChild(footer);
|
||||
|
||||
// Click handler
|
||||
card.addEventListener("click", () => {
|
||||
openFile(file.vault, file.path);
|
||||
});
|
||||
|
||||
return card;
|
||||
},
|
||||
|
||||
showEmpty() {
|
||||
const grid = document.getElementById("dashboard-recent-grid");
|
||||
const loading = document.getElementById("dashboard-loading");
|
||||
const empty = document.getElementById("dashboard-recent-empty");
|
||||
const count = document.getElementById("dashboard-count");
|
||||
|
||||
if (grid) grid.innerHTML = "";
|
||||
if (loading) loading.classList.remove("active");
|
||||
if (empty) empty.classList.remove("hidden");
|
||||
if (count) count.textContent = "0 fichiers";
|
||||
safeCreateIcons();
|
||||
},
|
||||
|
||||
showError() {
|
||||
this.showEmpty();
|
||||
const empty = document.getElementById("dashboard-recent-empty");
|
||||
if (empty) {
|
||||
const msg = empty.querySelector("span");
|
||||
if (msg) msg.textContent = "Erreur de chargement";
|
||||
}
|
||||
},
|
||||
|
||||
_humanizeDelta(mtime) {
|
||||
const delta = Date.now() / 1000 - mtime;
|
||||
if (delta < 60) return "à l'instant";
|
||||
if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`;
|
||||
if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`;
|
||||
if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`;
|
||||
return new Date(mtime * 1000).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" });
|
||||
},
|
||||
|
||||
populateVaultFilter() {
|
||||
const select = document.getElementById("dashboard-vault-filter");
|
||||
if (!select) return;
|
||||
|
||||
// Keep first option "Tous les vaults"
|
||||
while (select.options.length > 1) select.remove(1);
|
||||
|
||||
if (typeof allVaults !== "undefined" && Array.isArray(allVaults)) {
|
||||
allVaults.forEach((v) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = v.name;
|
||||
opt.textContent = v.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
syncVaultSelectors();
|
||||
},
|
||||
|
||||
init() {
|
||||
const select = document.getElementById("dashboard-vault-filter");
|
||||
if (select) {
|
||||
select.addEventListener("change", async () => {
|
||||
await setSelectedVaultContext(select.value, { focusVault: select.value !== "all" });
|
||||
});
|
||||
}
|
||||
|
||||
this.populateVaultFilter();
|
||||
},
|
||||
};
|
||||
|
||||
// ── Dashboard Bookmarks Widget ──
|
||||
// (moved from app.js 3810-3870; logically belongs with dashboard widgets)
|
||||
const DashboardBookmarkWidget = {
|
||||
_cache: [],
|
||||
_currentFilter: "",
|
||||
|
||||
async load(vaultFilter = "") {
|
||||
const v = vaultFilter || selectedContextVault || "all";
|
||||
this._currentFilter = v;
|
||||
this.showLoading();
|
||||
|
||||
let url = "/api/bookmarks";
|
||||
if (v !== "all") url += `?vault=${encodeURIComponent(v)}`;
|
||||
|
||||
try {
|
||||
const data = await api(url);
|
||||
this._cache = data.files || [];
|
||||
this.render();
|
||||
} catch (err) {
|
||||
console.error("Dashboard: Failed to load bookmarks:", err);
|
||||
this.showEmpty();
|
||||
}
|
||||
},
|
||||
|
||||
showLoading() {
|
||||
const grid = document.getElementById("dashboard-bookmarks-grid");
|
||||
const empty = document.getElementById("dashboard-bookmarks-empty");
|
||||
const section = document.getElementById("dashboard-bookmarks-section");
|
||||
|
||||
if (grid) grid.innerHTML = "";
|
||||
if (empty) empty.classList.add("hidden");
|
||||
},
|
||||
|
||||
render() {
|
||||
const grid = document.getElementById("dashboard-bookmarks-grid");
|
||||
const empty = document.getElementById("dashboard-bookmarks-empty");
|
||||
const section = document.getElementById("dashboard-bookmarks-section");
|
||||
|
||||
if (!this._cache || this._cache.length === 0) {
|
||||
if (grid) grid.innerHTML = "";
|
||||
if (empty) empty.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty) empty.classList.add("hidden");
|
||||
if (!grid) return;
|
||||
grid.innerHTML = "";
|
||||
|
||||
this._cache.forEach((f, idx) => {
|
||||
const card = DashboardRecentWidget._createCard(f, idx);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
safeCreateIcons();
|
||||
},
|
||||
|
||||
showEmpty() {
|
||||
const grid = document.getElementById("dashboard-bookmarks-grid");
|
||||
const empty = document.getElementById("dashboard-bookmarks-empty");
|
||||
if (grid) grid.innerHTML = "";
|
||||
if (empty) empty.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
export { DashboardRecentWidget, DashboardStatsWidget, DashboardBookmarkWidget };
|
||||
@@ -0,0 +1,401 @@
|
||||
/* ObsiGate — Graph View: interactive file/folder relationship visualization */
|
||||
import { api } from './auth.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { openFile } from './viewer.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph View Manager — Interactive file/folder relationship visualization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GraphViewManager = {
|
||||
_canvas: null,
|
||||
_ctx: null,
|
||||
_nodes: [],
|
||||
_edges: [],
|
||||
_offsetX: 0,
|
||||
_offsetY: 0,
|
||||
_zoom: 1,
|
||||
_dragging: false,
|
||||
_dragNode: null,
|
||||
_panning: false,
|
||||
_panStartX: 0,
|
||||
_panStartY: 0,
|
||||
_animFrame: null,
|
||||
_vault: null,
|
||||
_path: null,
|
||||
_nodePositions: {},
|
||||
_width: 0,
|
||||
_height: 0,
|
||||
|
||||
async open(vault, path, type) {
|
||||
this._vault = vault;
|
||||
this._path = path;
|
||||
|
||||
const modal = document.getElementById("graph-modal");
|
||||
const title = document.getElementById("graph-title");
|
||||
const info = document.getElementById("graph-info");
|
||||
const canvas = document.getElementById("graph-canvas");
|
||||
|
||||
if (!modal || !canvas) return;
|
||||
|
||||
title.textContent = `Vue Graphique — ${vault}${path ? "/" + path : ""}`;
|
||||
info.textContent = "Chargement...";
|
||||
modal.classList.add("active");
|
||||
|
||||
this._canvas = canvas;
|
||||
this._ctx = canvas.getContext("2d");
|
||||
this._resetView();
|
||||
|
||||
// Fetch graph data
|
||||
try {
|
||||
const data = await api(`/api/graph/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}&depth=1`);
|
||||
this._nodes = data.nodes || [];
|
||||
this._edges = data.edges || [];
|
||||
info.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens`;
|
||||
this._initLayout();
|
||||
this._startRender();
|
||||
} catch (err) {
|
||||
info.textContent = "Erreur de chargement";
|
||||
console.error("Graph error:", err);
|
||||
}
|
||||
|
||||
safeCreateIcons();
|
||||
},
|
||||
|
||||
close() {
|
||||
const modal = document.getElementById("graph-modal");
|
||||
if (modal) modal.classList.remove("active");
|
||||
if (this._animFrame) {
|
||||
cancelAnimationFrame(this._animFrame);
|
||||
this._animFrame = null;
|
||||
}
|
||||
},
|
||||
|
||||
_resetView() {
|
||||
this._offsetX = 0;
|
||||
this._offsetY = 0;
|
||||
this._zoom = 1;
|
||||
this._nodePositions = {};
|
||||
},
|
||||
|
||||
_initLayout() {
|
||||
const w = this._canvas.parentElement.clientWidth;
|
||||
const h = this._canvas.parentElement.clientHeight;
|
||||
this._canvas.width = w * devicePixelRatio;
|
||||
this._canvas.height = h * devicePixelRatio;
|
||||
this._canvas.style.width = w + "px";
|
||||
this._canvas.style.height = h + "px";
|
||||
this._width = w;
|
||||
this._height = h;
|
||||
this._ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
|
||||
// Position nodes in a circle initially
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const radius = Math.min(w, h) * 0.35;
|
||||
|
||||
this._nodes.forEach((node, i) => {
|
||||
const angle = (2 * Math.PI * i) / this._nodes.length;
|
||||
this._nodePositions[node.id] = {
|
||||
x: cx + radius * Math.cos(angle),
|
||||
y: cy + radius * Math.sin(angle),
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
_startRender() {
|
||||
const self = this;
|
||||
let lastTime = 0;
|
||||
|
||||
const loop = (time) => {
|
||||
const dt = Math.min((time - lastTime) / 1000, 0.1);
|
||||
lastTime = time;
|
||||
self._simulate(dt);
|
||||
self._draw();
|
||||
self._animFrame = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
this._animFrame = requestAnimationFrame(loop);
|
||||
},
|
||||
|
||||
_simulate(dt) {
|
||||
if (this._dragging || this._dragNode) return;
|
||||
|
||||
const positions = this._nodePositions;
|
||||
const cx = this._width / 2;
|
||||
const cy = this._height / 2;
|
||||
|
||||
// Spring forces (edges)
|
||||
for (const edge of this._edges) {
|
||||
const a = positions[edge.source];
|
||||
const b = positions[edge.target];
|
||||
if (!a || !b) continue;
|
||||
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const targetLen = 80;
|
||||
const force = (dist - targetLen) * 0.01;
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
|
||||
// Repulsion between all nodes
|
||||
for (const n1 of this._nodes) {
|
||||
for (const n2 of this._nodes) {
|
||||
if (n1.id === n2.id) continue;
|
||||
const a = positions[n1.id];
|
||||
const b = positions[n2.id];
|
||||
if (!a || !b) continue;
|
||||
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const force = 2000 / (dist * dist);
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
|
||||
a.vx -= fx;
|
||||
a.vy -= fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Center gravity
|
||||
for (const node of this._nodes) {
|
||||
const p = positions[node.id];
|
||||
if (!p) continue;
|
||||
p.vx += (cx - p.x) * 0.001;
|
||||
p.vy += (cy - p.y) * 0.001;
|
||||
}
|
||||
|
||||
// Apply velocities with damping
|
||||
for (const node of this._nodes) {
|
||||
const p = positions[node.id];
|
||||
if (!p) continue;
|
||||
p.vx *= 0.9;
|
||||
p.vy *= 0.9;
|
||||
p.x += p.vx * dt * 60;
|
||||
p.y += p.vy * dt * 60;
|
||||
}
|
||||
},
|
||||
|
||||
_draw() {
|
||||
const ctx = this._ctx;
|
||||
const w = this._width;
|
||||
const h = this._height;
|
||||
|
||||
ctx.save();
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Apply transform (pan + zoom)
|
||||
ctx.translate(this._offsetX, this._offsetY);
|
||||
ctx.scale(this._zoom, this._zoom);
|
||||
|
||||
// Draw edges
|
||||
for (const edge of this._edges) {
|
||||
const a = this._nodePositions[edge.source];
|
||||
const b = this._nodePositions[edge.target];
|
||||
if (!a || !b) continue;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.strokeStyle = edge.relation === "wikilink" ? "var(--accent-color, #2563eb)" : "var(--text-muted, #888)";
|
||||
ctx.lineWidth = edge.relation === "wikilink" ? 2 : 1;
|
||||
ctx.setLineDash(edge.relation === "wikilink" ? [4, 4] : []);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Draw nodes
|
||||
for (const node of this._nodes) {
|
||||
const p = this._nodePositions[node.id];
|
||||
if (!p) continue;
|
||||
|
||||
const r = Math.max(5, Math.min(20, 6 + Math.sqrt(node.size || 100) / 100));
|
||||
|
||||
// Node circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
|
||||
|
||||
switch (node.type) {
|
||||
case "directory":
|
||||
ctx.fillStyle = "#5b9bd5";
|
||||
break;
|
||||
case "file":
|
||||
ctx.fillStyle = (node.path || "").endsWith(".md") ? "#70ad47" : "#c0c0c0";
|
||||
break;
|
||||
default:
|
||||
ctx.fillStyle = "#ffc000";
|
||||
break;
|
||||
}
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "var(--bg-primary, #1e1e1e)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Node label
|
||||
const label = node.name.length > 20 ? node.name.slice(0, 18) + "..." : node.name;
|
||||
ctx.font = `${11 / this._zoom}px -apple-system, sans-serif`;
|
||||
ctx.fillStyle = "var(--text-primary, #ddd)";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(label, p.x, p.y + r + 12 / this._zoom);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
_getNodeAt(screenX, screenY) {
|
||||
const x = (screenX - this._offsetX) / this._zoom;
|
||||
const y = (screenY - this._offsetY) / this._zoom;
|
||||
|
||||
for (const node of this._nodes) {
|
||||
const p = this._nodePositions[node.id];
|
||||
if (!p) continue;
|
||||
const r = Math.max(5, Math.min(20, 6 + Math.sqrt(node.size || 100) / 100));
|
||||
const dx = x - p.x;
|
||||
const dy = y - p.y;
|
||||
if (dx * dx + dy * dy <= r * r + 100) {
|
||||
return { node, pos: p };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
_onMouseDown(e) {
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
const hit = this._getNodeAt(mx, my);
|
||||
if (hit) {
|
||||
this._dragging = true;
|
||||
this._dragNode = hit;
|
||||
this._canvas.style.cursor = "grabbing";
|
||||
} else {
|
||||
this._panning = true;
|
||||
this._panStartX = e.clientX - this._offsetX;
|
||||
this._panStartY = e.clientY - this._offsetY;
|
||||
this._canvas.style.cursor = "grabbing";
|
||||
}
|
||||
},
|
||||
|
||||
_onMouseMove(e) {
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
if (this._dragging && this._dragNode) {
|
||||
this._dragNode.pos.x = (mx - this._offsetX) / this._zoom;
|
||||
this._dragNode.pos.y = (my - this._offsetY) / this._zoom;
|
||||
this._dragNode.pos.vx = 0;
|
||||
this._dragNode.pos.vy = 0;
|
||||
} else if (this._panning) {
|
||||
this._offsetX = e.clientX - this._panStartX;
|
||||
this._offsetY = e.clientY - this._panStartY;
|
||||
} else {
|
||||
const hit = this._getNodeAt(mx, my);
|
||||
this._canvas.style.cursor = hit ? "pointer" : "grab";
|
||||
this._canvas.title = hit ? `Ouvrir: ${hit.node.path || hit.node.name}` : "";
|
||||
}
|
||||
},
|
||||
|
||||
_onMouseUp(e) {
|
||||
if (this._dragging && this._dragNode) {
|
||||
// Check if it was a click (not a drag)
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const node = this._dragNode.node;
|
||||
this._dragging = false;
|
||||
this._dragNode = null;
|
||||
this._canvas.style.cursor = "grab";
|
||||
|
||||
// If it's a file, open it on click
|
||||
if (node.type === "file") {
|
||||
this.close();
|
||||
openFile(this._vault, node.path);
|
||||
} else if (node.type === "directory" || node.type === "vault") {
|
||||
// Expand into this directory
|
||||
this.close();
|
||||
this.open(this._vault, node.path, node.type);
|
||||
}
|
||||
}
|
||||
this._panning = false;
|
||||
},
|
||||
|
||||
_onWheel(e) {
|
||||
e.preventDefault();
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
const newZoom = Math.max(0.2, Math.min(3, this._zoom * zoomFactor));
|
||||
|
||||
this._offsetX = mx - (mx - this._offsetX) * (newZoom / this._zoom);
|
||||
this._offsetY = my - (my - this._offsetY) * (newZoom / this._zoom);
|
||||
this._zoom = newZoom;
|
||||
},
|
||||
|
||||
_onResize() {
|
||||
if (!this._canvas || !this._nodes.length) return;
|
||||
const w = this._canvas.parentElement.clientWidth;
|
||||
const h = this._canvas.parentElement.clientHeight;
|
||||
this._canvas.width = w * devicePixelRatio;
|
||||
this._canvas.height = h * devicePixelRatio;
|
||||
this._canvas.style.width = w + "px";
|
||||
this._canvas.style.height = h + "px";
|
||||
this._width = w;
|
||||
this._height = h;
|
||||
this._ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
this._ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init graph view event listeners after DOM ready
|
||||
// ---------------------------------------------------------------------------
|
||||
export function initGraphView() {
|
||||
const closeBtn = document.getElementById("graph-close");
|
||||
const zoomIn = document.getElementById("graph-zoom-in");
|
||||
const zoomOut = document.getElementById("graph-zoom-out");
|
||||
const reset = document.getElementById("graph-reset");
|
||||
const modal = document.getElementById("graph-modal");
|
||||
const canvas = document.getElementById("graph-canvas");
|
||||
|
||||
if (closeBtn) closeBtn.addEventListener("click", () => GraphViewManager.close());
|
||||
if (modal) modal.addEventListener("click", (e) => { if (e.target === modal) GraphViewManager.close(); });
|
||||
if (zoomIn) zoomIn.addEventListener("click", () => { GraphViewManager._zoom = Math.min(3, GraphViewManager._zoom * 1.2); });
|
||||
if (zoomOut) zoomOut.addEventListener("click", () => { GraphViewManager._zoom = Math.max(0.2, GraphViewManager._zoom * 0.8); });
|
||||
if (reset) reset.addEventListener("click", () => {
|
||||
GraphViewManager._offsetX = 0;
|
||||
GraphViewManager._offsetY = 0;
|
||||
GraphViewManager._zoom = 1;
|
||||
});
|
||||
|
||||
if (canvas) {
|
||||
canvas.addEventListener("mousedown", (e) => GraphViewManager._onMouseDown(e));
|
||||
canvas.addEventListener("mousemove", (e) => GraphViewManager._onMouseMove(e));
|
||||
canvas.addEventListener("mouseup", (e) => GraphViewManager._onMouseUp(e));
|
||||
canvas.addEventListener("mouseleave", () => {
|
||||
GraphViewManager._dragging = false;
|
||||
GraphViewManager._dragNode = null;
|
||||
GraphViewManager._panning = false;
|
||||
canvas.style.cursor = "grab";
|
||||
});
|
||||
canvas.addEventListener("wheel", (e) => GraphViewManager._onWheel(e), { passive: false });
|
||||
window.addEventListener("resize", () => GraphViewManager._onResize());
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (modal && modal.classList.contains("active") && e.key === "Escape") {
|
||||
GraphViewManager.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
/* ObsiGate — Legacy module: remaining functions for the orchestrator
|
||||
Extracted from the monolithic frontend/app.js IIFE.
|
||||
Functions already in other modules are re-exported. */
|
||||
|
||||
// --- State imports ---
|
||||
import {
|
||||
currentVault,
|
||||
currentPath,
|
||||
showingSource,
|
||||
cachedRawSource,
|
||||
searchTimeout,
|
||||
searchCaseSensitive,
|
||||
searchWholeWord,
|
||||
searchRegex,
|
||||
searchFilterVisible,
|
||||
advancedSearchOffset,
|
||||
selectedTags,
|
||||
selectedContextVault,
|
||||
vaultSettings,
|
||||
allVaults,
|
||||
MIN_SEARCH_LENGTH,
|
||||
} from './state.js';
|
||||
|
||||
// --- Search imports ---
|
||||
import {
|
||||
AutocompleteDropdown,
|
||||
SearchChips,
|
||||
SearchHistory,
|
||||
performAdvancedSearch,
|
||||
} from './search.js';
|
||||
|
||||
// --- Dashboard imports ---
|
||||
import {
|
||||
DashboardRecentWidget,
|
||||
DashboardStatsWidget,
|
||||
DashboardBookmarkWidget,
|
||||
DashboardSharedWidget,
|
||||
} from './dashboard.js';
|
||||
|
||||
// --- Re-exports from modules that already have these functions ---
|
||||
export { initSidebarTabs, initConfigModal, initHelpModal, initRecentTab } from './config.js';
|
||||
export { initSyncStatus } from './sync.js';
|
||||
export { DashboardRecentWidget } from './dashboard.js';
|
||||
|
||||
// =========================================================================
|
||||
// Progress bar helpers (used by showWelcome)
|
||||
// =========================================================================
|
||||
|
||||
function showProgressBar() {
|
||||
const bar = document.getElementById("search-progress-bar");
|
||||
if (bar) bar.classList.add("active");
|
||||
}
|
||||
|
||||
function hideProgressBar() {
|
||||
const bar = document.getElementById("search-progress-bar");
|
||||
if (bar) bar.classList.remove("active");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// loadVaultSettings
|
||||
// =========================================================================
|
||||
|
||||
async function loadVaultSettings() {
|
||||
try {
|
||||
const settings = await api("/api/vaults/settings/all");
|
||||
vaultSettings = settings;
|
||||
} catch (err) {
|
||||
console.error("Failed to load vault settings:", err);
|
||||
vaultSettings = {};
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Config helpers (needed by initSearch)
|
||||
// =========================================================================
|
||||
|
||||
const _FRONTEND_CONFIG_KEY = "obsigate-perf-config";
|
||||
|
||||
function _getFrontendConfig() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(_FRONTEND_CONFIG_KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function _getEffective(key, fallback) {
|
||||
const cfg = _getFrontendConfig();
|
||||
return cfg[key] !== undefined ? cfg[key] : fallback;
|
||||
}
|
||||
|
||||
/** Check if user is focused on an input/textarea/contenteditable */
|
||||
function _isInputFocused() {
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
return document.activeElement?.isContentEditable === true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// initSearch
|
||||
// =========================================================================
|
||||
|
||||
function initSearch() {
|
||||
const input = document.getElementById("search-input");
|
||||
if (!input) return;
|
||||
const caseBtn = document.getElementById("search-case-btn");
|
||||
const wordBtn = document.getElementById("search-word-btn");
|
||||
const regexBtn = document.getElementById("search-regex-btn");
|
||||
const filterBtn = document.getElementById("search-filter-btn");
|
||||
const clearBtn = document.getElementById("search-clear-btn");
|
||||
const filterRow = document.getElementById("search-filter-row");
|
||||
const prevBtn = document.getElementById("search-prev-btn");
|
||||
const nextBtn = document.getElementById("search-next-btn");
|
||||
const countEl = document.getElementById("search-match-count");
|
||||
|
||||
function _updateToggleUI() {
|
||||
caseBtn.classList.toggle("active", searchCaseSensitive);
|
||||
wordBtn.classList.toggle("active", searchWholeWord);
|
||||
regexBtn.classList.toggle("active", searchRegex);
|
||||
filterBtn.classList.toggle("active", searchFilterVisible);
|
||||
}
|
||||
|
||||
// Toggle buttons
|
||||
caseBtn.addEventListener("click", () => { searchCaseSensitive = !searchCaseSensitive; _updateToggleUI(); _research(); });
|
||||
if (wordBtn) wordBtn.addEventListener("click", () => { searchWholeWord = !searchWholeWord; _updateToggleUI(); _research(); });
|
||||
if (regexBtn) regexBtn.addEventListener("click", () => { searchRegex = !searchRegex; _updateToggleUI(); _research(); });
|
||||
if (filterBtn) filterBtn.addEventListener("click", () => { searchFilterVisible = !searchFilterVisible; if (filterRow) filterRow.style.display = searchFilterVisible ? "flex" : "none"; _updateToggleUI(); });
|
||||
|
||||
// Result navigation (up/down arrows + Enter)
|
||||
let _searchResultIdx = -1;
|
||||
let _searchResultItems = [];
|
||||
|
||||
function _updateResultHighlight() {
|
||||
_searchResultItems.forEach((el, i) => {
|
||||
el.classList.toggle("search-result-active", i === _searchResultIdx);
|
||||
});
|
||||
if (_searchResultIdx >= 0 && _searchResultIdx < _searchResultItems.length) {
|
||||
_searchResultItems[_searchResultIdx].scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
const countEl = document.getElementById("search-match-count");
|
||||
if (countEl) countEl.textContent = _searchResultIdx >= 0 ? `${_searchResultIdx + 1}/${_searchResultItems.length}` : `0/${_searchResultItems.length}`;
|
||||
}
|
||||
|
||||
function _refreshResultItems() {
|
||||
_searchResultItems = Array.from(document.querySelectorAll(".search-result-item"));
|
||||
_searchResultIdx = _searchResultItems.length > 0 ? 0 : -1;
|
||||
_updateResultHighlight();
|
||||
}
|
||||
|
||||
window.navigateSearchResults = function(delta) {
|
||||
_searchResultItems = Array.from(document.querySelectorAll(".search-result-item"));
|
||||
if (_searchResultItems.length === 0) return;
|
||||
_searchResultIdx = Math.max(0, Math.min(_searchResultItems.length - 1, _searchResultIdx + delta));
|
||||
_updateResultHighlight();
|
||||
};
|
||||
|
||||
if (prevBtn) prevBtn.addEventListener("click", () => navigateSearchResults(-1));
|
||||
if (nextBtn) nextBtn.addEventListener("click", () => navigateSearchResults(1));
|
||||
|
||||
function _research() {
|
||||
const q = input.value.trim();
|
||||
if (q.length >= _getEffective("min_query_length", 2)) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null;
|
||||
advancedSearchOffset = 0;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}, _getEffective("debounce_ms", 300));
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.altKey && !e.ctrlKey && !e.metaKey) {
|
||||
if (e.key === "c" || e.key === "C") { e.preventDefault(); caseBtn.click(); }
|
||||
else if (e.key === "w" || e.key === "W") { e.preventDefault(); if (wordBtn) wordBtn.click(); }
|
||||
else if (e.key === "r" || e.key === "R") { e.preventDefault(); if (regexBtn) regexBtn.click(); }
|
||||
else if (e.key === "f" || e.key === "F") { e.preventDefault(); if (filterBtn) filterBtn.click(); input.focus(); }
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize sub-controllers
|
||||
AutocompleteDropdown.init();
|
||||
SearchChips.init();
|
||||
|
||||
// Initially hide clear button
|
||||
if (clearBtn) clearBtn.style.display = "none";
|
||||
|
||||
// Input handler: debounced search + autocomplete dropdown
|
||||
input.addEventListener("input", () => {
|
||||
const hasText = input.value.length > 0;
|
||||
clearBtn.style.display = hasText ? "flex" : "none";
|
||||
|
||||
// Show autocomplete dropdown while typing
|
||||
AutocompleteDropdown.populate(input.value, input.selectionStart);
|
||||
|
||||
// Debounced search execution
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(
|
||||
() => {
|
||||
const q = input.value.trim();
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null;
|
||||
advancedSearchOffset = 0;
|
||||
if (q.length >= _getEffective("min_query_length", MIN_SEARCH_LENGTH) || tagFilter) {
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
} else if (q.length === 0) {
|
||||
SearchChips.clear();
|
||||
showWelcome();
|
||||
}
|
||||
},
|
||||
_getEffective("debounce_ms", 300),
|
||||
);
|
||||
});
|
||||
|
||||
// Focus handler: show history dropdown
|
||||
input.addEventListener("focus", () => {
|
||||
if (input.value.length === 0) {
|
||||
const historyItems = SearchHistory.filter("").slice(0, 5);
|
||||
if (historyItems.length > 0) {
|
||||
AutocompleteDropdown.populate("", 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard navigation in dropdown
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (AutocompleteDropdown.isVisible()) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
AutocompleteDropdown.navigateDown();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
AutocompleteDropdown.navigateUp();
|
||||
} else if (e.key === "Enter") {
|
||||
// First: check dropdown suggestions (higher priority than search results)
|
||||
if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Second: navigate search results if visible
|
||||
const results = document.querySelectorAll(".search-result-item");
|
||||
if (results.length > 0 && _searchResultIdx >= 0) {
|
||||
const el = results[_searchResultIdx];
|
||||
if (el) {
|
||||
const vault = el.dataset.vault;
|
||||
const path = el.dataset.path;
|
||||
if (vault && path) { TabManager.openPreview(vault, path); e.preventDefault(); return; }
|
||||
}
|
||||
}
|
||||
// Third: execute search
|
||||
AutocompleteDropdown.hide();
|
||||
const q = input.value.trim();
|
||||
if (q) {
|
||||
SearchHistory.add(q);
|
||||
clearTimeout(searchTimeout);
|
||||
advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.key === "ArrowDown" && !AutocompleteDropdown.isVisible()) {
|
||||
// Navigate search results when dropdown is closed
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); }
|
||||
} else if (e.key === "ArrowUp" && !AutocompleteDropdown.isVisible()) {
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); }
|
||||
} else if (e.key === "Escape") {
|
||||
AutocompleteDropdown.hide();
|
||||
e.stopPropagation();
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const q = input.value.trim();
|
||||
if (q) {
|
||||
SearchHistory.add(q);
|
||||
clearTimeout(searchTimeout);
|
||||
advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
input.value = "";
|
||||
if (clearBtn) clearBtn.style.display = "none";
|
||||
searchCaseSensitive = false;
|
||||
searchWholeWord = false;
|
||||
searchRegex = false;
|
||||
_updateToggleUI();
|
||||
SearchChips.clear();
|
||||
AutocompleteDropdown.hide();
|
||||
showWelcome();
|
||||
});
|
||||
|
||||
// Global keyboard shortcuts
|
||||
document.addEventListener("keydown", (e) => {
|
||||
// Ctrl+K or Cmd+K: focus search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
// "/" key: focus search (when not in an input/textarea)
|
||||
if (e.key === "/" && !_isInputFocused()) {
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
}
|
||||
// Escape: blur search input and close dropdown
|
||||
if (e.key === "Escape" && document.activeElement === input) {
|
||||
AutocompleteDropdown.hide();
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// showWelcome
|
||||
// =========================================================================
|
||||
|
||||
function showWelcome() {
|
||||
hideProgressBar();
|
||||
|
||||
// Restore or rebuild the dashboard with tabbed sections
|
||||
const area = document.getElementById("content-area");
|
||||
const home = document.getElementById("dashboard-home");
|
||||
|
||||
if (area && !home) {
|
||||
area.innerHTML = `
|
||||
<div id="dashboard-home" class="dashboard-home" role="region" aria-label="Tableau de bord">
|
||||
<!-- Dashboard Tabs -->
|
||||
<div class="dashboard-tabs">
|
||||
<button class="dashboard-tab active" data-tab="stats">
|
||||
<i data-lucide="bar-chart-3" style="width:14px;height:14px"></i> Statistiques
|
||||
</button>
|
||||
<button class="dashboard-tab" data-tab="bookmarks">
|
||||
<i data-lucide="bookmark" style="width:14px;height:14px"></i> Bookmarks
|
||||
</button>
|
||||
<button class="dashboard-tab" data-tab="recent">
|
||||
<i data-lucide="clock" style="width:14px;height:14px"></i> Récents
|
||||
</button>
|
||||
<button class="dashboard-tab" data-tab="shared">
|
||||
<i data-lucide="share-2" style="width:14px;height:14px"></i> Partagés
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Panel -->
|
||||
<div id="dashboard-panel-stats" class="dashboard-panel active">
|
||||
<div id="dashboard-stats-grid" class="dashboard-stats-grid">
|
||||
<div class="dashboard-stats-loading">Chargement...</div>
|
||||
</div>
|
||||
<div id="dashboard-conflicts-container" style="margin-top:16px"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bookmarks Panel -->
|
||||
<div id="dashboard-panel-bookmarks" class="dashboard-panel">
|
||||
<div id="dashboard-bookmarks-grid" class="dashboard-recent-grid"></div>
|
||||
<div id="dashboard-bookmarks-empty" class="dashboard-recent-empty">
|
||||
<i data-lucide="pin"></i>
|
||||
<span>Aucun bookmark</span>
|
||||
<p>Épinglez des fichiers pour les retrouver ici.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Panel -->
|
||||
<div id="dashboard-panel-recent" class="dashboard-panel">
|
||||
<div class="dashboard-header">
|
||||
<div class="dashboard-title-row">
|
||||
<span id="dashboard-count" class="dashboard-badge"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dashboard-recent-grid" class="dashboard-recent-grid"></div>
|
||||
<div id="dashboard-loading" class="dashboard-loading">
|
||||
<div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
|
||||
</div>
|
||||
<div id="dashboard-recent-empty" class="dashboard-recent-empty hidden">
|
||||
<i data-lucide="inbox"></i>
|
||||
<span>Aucun fichier récent</span>
|
||||
<p>Ouvrez un fichier pour le voir apparaître ici</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared Panel -->
|
||||
<div id="dashboard-panel-shared" class="dashboard-panel">
|
||||
<div id="dashboard-shared-grid" class="dashboard-recent-grid"></div>
|
||||
<div id="dashboard-shared-empty" class="dashboard-recent-empty">
|
||||
<i data-lucide="share-2"></i>
|
||||
<span>Aucun document partagé</span>
|
||||
<p>Partagez un document pour le voir apparaître ici</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Re-initialize widgets and dashboard tabs
|
||||
DashboardRecentWidget.init();
|
||||
initDashboardTabs();
|
||||
safeCreateIcons();
|
||||
} else if (home) {
|
||||
// Dashboard already exists, show it with default tab
|
||||
home.style.display = "";
|
||||
// Reset tabs to default
|
||||
document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
|
||||
document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
|
||||
const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]');
|
||||
const defaultPanel = document.getElementById("dashboard-panel-stats");
|
||||
if (defaultTab) defaultTab.classList.add("active");
|
||||
if (defaultPanel) defaultPanel.classList.add("active");
|
||||
}
|
||||
|
||||
// Load all widgets (they handle missing elements gracefully)
|
||||
if (typeof DashboardStatsWidget !== "undefined") {
|
||||
DashboardStatsWidget.load();
|
||||
}
|
||||
if (typeof DashboardConflictsWidget !== "undefined") {
|
||||
DashboardConflictsWidget.load();
|
||||
}
|
||||
DashboardRecentWidget.load(selectedContextVault);
|
||||
if (typeof DashboardBookmarkWidget !== "undefined") {
|
||||
DashboardBookmarkWidget.load(selectedContextVault);
|
||||
}
|
||||
if (typeof DashboardSharedWidget !== "undefined") {
|
||||
DashboardSharedWidget.load();
|
||||
}
|
||||
|
||||
// Load saved searches sidebar
|
||||
loadSavedSearches();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// goHome
|
||||
// =========================================================================
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// loadSavedSearches (needed by showWelcome)
|
||||
// =========================================================================
|
||||
|
||||
async function loadSavedSearches() {
|
||||
const list = document.getElementById("saved-searches-list");
|
||||
const empty = document.getElementById("saved-searches-empty");
|
||||
if (!list) return;
|
||||
try {
|
||||
const searches = await api("/api/saved-searches");
|
||||
if (!searches.length) {
|
||||
list.innerHTML = "";
|
||||
if (empty) empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = "none";
|
||||
list.innerHTML = searches.map(s => {
|
||||
const badges = [];
|
||||
if (s.case_sensitive) badges.push('<span class="search-filter-badge">Aa</span>');
|
||||
if (s.whole_word) badges.push('<span class="search-filter-badge">wd</span>');
|
||||
if (s.regex) badges.push('<span class="search-filter-badge">.*</span>');
|
||||
const pathFilters = [];
|
||||
if (s.include_paths) pathFilters.push(`<span class="saved-search-path" title="Inclure: ${escapeHtml(s.include_paths)}">\u{1F4E5} ${escapeHtml(s.include_paths)}</span>`);
|
||||
if (s.exclude_paths) pathFilters.push(`<span class="saved-search-path" title="Exclure: ${escapeHtml(s.exclude_paths)}">\u{1F4E4} ${escapeHtml(s.exclude_paths)}</span>`);
|
||||
const vaultStr = s.vault && s.vault !== "all" ? `<span class="saved-search-vault">\u{1F4C1} ${escapeHtml(s.vault)}</span>` : "";
|
||||
return `
|
||||
<div class="saved-search-item">
|
||||
<div class="saved-search-query">${escapeHtml(s.query)}</div>
|
||||
<div class="saved-search-meta">
|
||||
${badges.join("")}
|
||||
${vaultStr}
|
||||
</div>
|
||||
${pathFilters.length ? '<div class="saved-search-filters">' + pathFilters.join(" ") + '</div>' : ""}
|
||||
<button class="saved-search-delete" data-id="${s.id}" title="Supprimer"><E2><9C><95></button>
|
||||
</div>
|
||||
`}).join("");
|
||||
list.querySelectorAll(".saved-search-item").forEach(item => {
|
||||
item.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("saved-search-delete")) return;
|
||||
const idx = Array.from(list.children).indexOf(item);
|
||||
const s = searches[idx];
|
||||
if (!s) return;
|
||||
// Apply the saved search
|
||||
const input = document.getElementById("search-input");
|
||||
if (input) input.value = s.query;
|
||||
searchCaseSensitive = s.case_sensitive || false;
|
||||
searchWholeWord = s.whole_word || false;
|
||||
searchRegex = s.regex || false;
|
||||
if (typeof _updateToggleUI === "function") _updateToggleUI();
|
||||
if (s.include_paths) {
|
||||
const incl = document.getElementById("search-include-input");
|
||||
if (incl) incl.value = s.include_paths;
|
||||
}
|
||||
if (s.exclude_paths) {
|
||||
const excl = document.getElementById("search-exclude-input");
|
||||
if (excl) excl.value = s.exclude_paths;
|
||||
}
|
||||
// Execute the search
|
||||
AutocompleteDropdown.hide();
|
||||
AutocompleteDropdown._suppressNext = true;
|
||||
const vault = s.vault || "all";
|
||||
if (input) { input.dispatchEvent(new Event("input")); }
|
||||
clearTimeout(searchTimeout);
|
||||
advancedSearchOffset = 0;
|
||||
performAdvancedSearch(s.query, vault, null);
|
||||
});
|
||||
});
|
||||
list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" });
|
||||
loadSavedSearches();
|
||||
}));
|
||||
safeCreateIcons();
|
||||
} catch (err) { /* silently ignore */ }
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// initDashboardTabs (needed by showWelcome)
|
||||
// =========================================================================
|
||||
|
||||
function initDashboardTabs() {
|
||||
document.querySelectorAll(".dashboard-tab").forEach(tab => {
|
||||
// Remove existing listeners by cloning
|
||||
const newTab = tab.cloneNode(true);
|
||||
tab.parentNode.replaceChild(newTab, tab);
|
||||
newTab.addEventListener("click", function() {
|
||||
document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
|
||||
document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
|
||||
this.classList.add("active");
|
||||
const panel = document.getElementById("dashboard-panel-" + this.dataset.tab);
|
||||
if (panel) panel.classList.add("active");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Exports
|
||||
// =========================================================================
|
||||
|
||||
export {
|
||||
loadVaultSettings,
|
||||
initSearch,
|
||||
showWelcome,
|
||||
goHome,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
/* ObsiGate — Shared application state */
|
||||
export const APP_VERSION = "1.5.0";
|
||||
|
||||
// Core navigation state
|
||||
export let currentVault = null;
|
||||
export let currentPath = null;
|
||||
export let allVaults = [];
|
||||
export let selectedContextVault = "all";
|
||||
|
||||
// Search state
|
||||
export let searchTimeout = null;
|
||||
export let searchAbortController = null;
|
||||
export let advancedSearchOffset = 0;
|
||||
export let advancedSearchTotal = 0;
|
||||
export let advancedSearchSort = "relevance";
|
||||
export let advancedSearchLastQuery = "";
|
||||
export let suggestAbortController = null;
|
||||
export let dropdownActiveIndex = -1;
|
||||
export let dropdownItems = [];
|
||||
export let currentSearchId = 0;
|
||||
export let selectedTags = [];
|
||||
export let searchCaseSensitive = false;
|
||||
export let searchWholeWord = false;
|
||||
export let searchRegex = false;
|
||||
export let searchFilterVisible = false;
|
||||
|
||||
// Search constants
|
||||
export const SEARCH_HISTORY_KEY = "obsigate_search_history";
|
||||
export const MAX_HISTORY_ENTRIES = 50;
|
||||
export const SUGGEST_DEBOUNCE_MS = 150;
|
||||
export const ADVANCED_SEARCH_LIMIT = 50;
|
||||
export const MIN_SEARCH_LENGTH = 2;
|
||||
export const SEARCH_TIMEOUT_MS = 30000;
|
||||
|
||||
// Viewer state
|
||||
export let showingSource = false;
|
||||
export let cachedRawSource = null;
|
||||
export let editorView = null;
|
||||
export let editorVault = null;
|
||||
export let editorPath = null;
|
||||
export let fallbackEditorEl = null;
|
||||
export let _iconDebounceTimer = null;
|
||||
|
||||
// Outline/TOC state
|
||||
export let outlineObserver = null;
|
||||
export let activeHeadingId = null;
|
||||
export let headingsCache = [];
|
||||
export let rightSidebarVisible = true;
|
||||
export let rightSidebarWidth = 280;
|
||||
|
||||
// Sidebar state
|
||||
export let sidebarFilterCaseSensitive = false;
|
||||
export let activeSidebarTab = "vaults";
|
||||
export let filterDebounce = null;
|
||||
export let vaultSettings = {};
|
||||
@@ -0,0 +1,437 @@
|
||||
/* ObsiGate — Sync: SSE client + PWA registration */
|
||||
import {
|
||||
currentVault,
|
||||
currentPath,
|
||||
activeSidebarTab,
|
||||
selectedContextVault
|
||||
} from './state.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { loadVaults, loadTags, refreshTagsForContext } from './sidebar.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE Client — IndexUpdateManager
|
||||
// ---------------------------------------------------------------------------
|
||||
export const IndexUpdateManager = (() => {
|
||||
let eventSource = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectDelay = 1000;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
let recentEvents = [];
|
||||
const MAX_RECENT_EVENTS = 20;
|
||||
let connectionState = "disconnected"; // disconnected | connecting | connected
|
||||
|
||||
function connect() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
connectionState = "connecting";
|
||||
_updateBadge();
|
||||
|
||||
eventSource = new EventSource("/api/events");
|
||||
|
||||
eventSource.addEventListener("connected", (e) => {
|
||||
connectionState = "connected";
|
||||
reconnectDelay = 1000;
|
||||
_updateBadge();
|
||||
});
|
||||
|
||||
eventSource.addEventListener("index_updated", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("index_updated", data);
|
||||
_onIndexUpdated(data);
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("index_reloaded", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("index_reloaded", data);
|
||||
_onIndexReloaded(data);
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("vault_added", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("vault_added", data);
|
||||
showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info");
|
||||
loadVaults();
|
||||
loadTags();
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("vault_removed", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("vault_removed", data);
|
||||
showToast(`Vault "${data.vault}" supprimé`, "info");
|
||||
loadVaults();
|
||||
loadTags();
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("index_start", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("index_start", data);
|
||||
connectionState = "syncing";
|
||||
_updateBadge();
|
||||
showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info");
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("index_progress", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("index_progress", data);
|
||||
connectionState = "syncing";
|
||||
_updateBadge();
|
||||
loadVaults();
|
||||
loadTags();
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("index_complete", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("index_complete", data);
|
||||
connectionState = "connected";
|
||||
_updateBadge();
|
||||
showToast(`Indexation terminée (${data.total_files} fichiers)`, "success");
|
||||
loadVaults();
|
||||
loadTags();
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
connectionState = "disconnected";
|
||||
_updateBadge();
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
_scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
function _scheduleReconnect() {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
}
|
||||
|
||||
function _addEvent(type, data) {
|
||||
recentEvents.unshift({
|
||||
type,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
if (recentEvents.length > MAX_RECENT_EVENTS) {
|
||||
recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
|
||||
}
|
||||
}
|
||||
|
||||
async function _onIndexUpdated(data) {
|
||||
// Brief syncing state
|
||||
connectionState = "syncing";
|
||||
_updateBadge();
|
||||
|
||||
const n = data.total_changes || 0;
|
||||
const vaults = (data.vaults || []).join(", ");
|
||||
// Toast removed: silent auto-indexing — no notification needed
|
||||
|
||||
// Refresh sidebar and tags if affected vault matches current context
|
||||
const affectsCurrentVault = selectedContextVault === "all" || (data.vaults || []).includes(selectedContextVault);
|
||||
if (affectsCurrentVault) {
|
||||
try {
|
||||
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
|
||||
// Refresh current file if it was updated
|
||||
if (currentVault && currentPath) {
|
||||
const changed = (data.changes || []).some((c) => c.vault === currentVault && c.path === currentPath);
|
||||
if (changed) {
|
||||
openFile(currentVault, currentPath);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error refreshing after index update:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh recent tab if it is active
|
||||
if (activeSidebarTab === "recent") {
|
||||
const vaultFilter = document.getElementById("recent-vault-filter");
|
||||
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
connectionState = "connected";
|
||||
_updateBadge();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function _onIndexReloaded(data) {
|
||||
connectionState = "syncing";
|
||||
_updateBadge();
|
||||
showToast("Index complet rechargé", "info");
|
||||
try {
|
||||
await Promise.all([loadVaults(), loadTags()]);
|
||||
} catch (err) {
|
||||
console.error("Error refreshing after full reload:", err);
|
||||
}
|
||||
setTimeout(() => {
|
||||
connectionState = "connected";
|
||||
_updateBadge();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function _updateBadge() {
|
||||
const badge = document.getElementById("sync-badge");
|
||||
if (!badge) return;
|
||||
badge.className = "sync-badge sync-badge--" + connectionState;
|
||||
const labels = {
|
||||
disconnected: "Déconnecté",
|
||||
connecting: "Connexion...",
|
||||
connected: "Synchronisé",
|
||||
syncing: "Mise à jour...",
|
||||
};
|
||||
badge.title = labels[connectionState] || connectionState;
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
connectionState = "disconnected";
|
||||
_updateBadge();
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return connectionState;
|
||||
}
|
||||
|
||||
function getRecentEvents() {
|
||||
return recentEvents;
|
||||
}
|
||||
|
||||
return { connect, disconnect, getState, getRecentEvents };
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync status badge and panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initSyncStatus() {
|
||||
const badge = document.getElementById("sync-badge");
|
||||
if (!badge) return;
|
||||
|
||||
badge.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleSyncPanel();
|
||||
});
|
||||
|
||||
IndexUpdateManager.connect();
|
||||
}
|
||||
|
||||
function toggleSyncPanel() {
|
||||
let panel = document.getElementById("sync-panel");
|
||||
if (panel) {
|
||||
panel.remove();
|
||||
return;
|
||||
}
|
||||
// Auto reconnect if disconnected when user opens the panel
|
||||
if (IndexUpdateManager.getState() === "disconnected") {
|
||||
IndexUpdateManager.connect();
|
||||
}
|
||||
panel = document.createElement("div");
|
||||
panel.id = "sync-panel";
|
||||
panel.className = "sync-panel";
|
||||
_renderSyncPanel(panel);
|
||||
document.body.appendChild(panel);
|
||||
|
||||
// Close on outside click
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", _closeSyncPanelOutside, { once: true });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _closeSyncPanelOutside(e) {
|
||||
const panel = document.getElementById("sync-panel");
|
||||
if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
|
||||
panel.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function _renderSyncPanel(panel) {
|
||||
const state = IndexUpdateManager.getState();
|
||||
const events = IndexUpdateManager.getRecentEvents();
|
||||
|
||||
const stateLabels = {
|
||||
disconnected: "Déconnecté",
|
||||
connecting: "Connexion...",
|
||||
connected: "Connecté",
|
||||
syncing: "Synchronisation...",
|
||||
};
|
||||
|
||||
let html = `<div class="sync-panel__header">
|
||||
<span class="sync-panel__title">Synchronisation</span>
|
||||
<span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
|
||||
</div>`;
|
||||
|
||||
if (events.length === 0) {
|
||||
html += `<div class="sync-panel__empty">Aucun événement récent</div>`;
|
||||
} else {
|
||||
html += `<div class="sync-panel__events">`;
|
||||
events.slice(0, 10).forEach((ev) => {
|
||||
const time = new Date(ev.timestamp).toLocaleTimeString();
|
||||
const typeLabels = {
|
||||
index_updated: "Mise à jour",
|
||||
index_reloaded: "Rechargement",
|
||||
vault_added: "Vault ajouté",
|
||||
vault_removed: "Vault supprimé",
|
||||
index_start: "Démarrage index.",
|
||||
index_progress: "Vault indexé",
|
||||
index_complete: "Indexation tech.",
|
||||
};
|
||||
const label = typeLabels[ev.type] || ev.type;
|
||||
let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || "";
|
||||
if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`;
|
||||
if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`;
|
||||
if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`;
|
||||
html += `<div class="sync-panel__event">
|
||||
<span class="sync-panel__event-type">${label}</span>
|
||||
<span class="sync-panel__event-detail">${detail}</span>
|
||||
<span class="sync-panel__event-time">${time}</span>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
panel.innerHTML = html;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PWA Service Worker Registration
|
||||
// ---------------------------------------------------------------------------
|
||||
function registerServiceWorker() {
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
console.log("[PWA] Service Worker registered successfully:", registration.scope);
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update();
|
||||
}, 60000); // Check every minute
|
||||
|
||||
// Handle service worker updates
|
||||
registration.addEventListener("updatefound", () => {
|
||||
const newWorker = registration.installing;
|
||||
newWorker.addEventListener("statechange", () => {
|
||||
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
|
||||
// New service worker available
|
||||
showUpdateNotification();
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("[PWA] Service Worker registration failed:", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showUpdateNotification() {
|
||||
const message = document.createElement("div");
|
||||
message.className = "pwa-update-notification";
|
||||
message.innerHTML = `
|
||||
<div class="pwa-update-content">
|
||||
<span>Une nouvelle version d'ObsiGate est disponible !</span>
|
||||
<button class="pwa-update-btn" onclick="window.location.reload()">Mettre à jour</button>
|
||||
<button class="pwa-update-dismiss" onclick="this.parentElement.parentElement.remove()">×</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(message);
|
||||
|
||||
// Auto-dismiss after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (message.parentElement) {
|
||||
message.remove();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// Handle install prompt
|
||||
let deferredPrompt;
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
|
||||
// Show install button if desired
|
||||
const installBtn = document.getElementById("pwa-install-btn");
|
||||
if (installBtn) {
|
||||
installBtn.style.display = "block";
|
||||
installBtn.addEventListener("click", async () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
console.log(`[PWA] User response to install prompt: ${outcome}`);
|
||||
deferredPrompt = null;
|
||||
installBtn.style.display = "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Log when app is installed
|
||||
window.addEventListener("appinstalled", () => {
|
||||
console.log("[PWA] ObsiGate has been installed");
|
||||
showToast("ObsiGate installé avec succès !", "success");
|
||||
});
|
||||
|
||||
// ── Dashboard tab switching (runs on page load and after rebuild) ──
|
||||
function initDashboardTabs() {
|
||||
document.querySelectorAll(".dashboard-tab").forEach(tab => {
|
||||
// Remove existing listeners by cloning
|
||||
const newTab = tab.cloneNode(true);
|
||||
tab.parentNode.replaceChild(newTab, tab);
|
||||
newTab.addEventListener("click", function() {
|
||||
document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
|
||||
document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
|
||||
this.classList.add("active");
|
||||
const panel = document.getElementById("dashboard-panel-" + this.dataset.tab);
|
||||
if (panel) panel.classList.add("active");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init — called by app.js orchestrator
|
||||
// ---------------------------------------------------------------------------
|
||||
export { initSyncStatus };
|
||||
export function init() {
|
||||
registerServiceWorker();
|
||||
initDashboardTabs();
|
||||
}
|
||||
+2250
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,510 @@
|
||||
import * as State from "./state.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File extension → Lucide icon mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
const EXT_ICONS = {
|
||||
// Text files
|
||||
".md": "file-text",
|
||||
".txt": "file-text",
|
||||
".log": "file-text",
|
||||
".readme": "file-text",
|
||||
".rst": "file-text",
|
||||
".adoc": "file-text",
|
||||
|
||||
// Web development
|
||||
".html": "file-code",
|
||||
".htm": "file-code",
|
||||
".css": "file-code",
|
||||
".scss": "file-code",
|
||||
".sass": "file-code",
|
||||
".less": "file-code",
|
||||
".js": "file-code",
|
||||
".jsx": "file-code",
|
||||
".ts": "file-code",
|
||||
".tsx": "file-code",
|
||||
".vue": "file-code",
|
||||
".svelte": "file-code",
|
||||
|
||||
// Programming languages
|
||||
".py": "file-code",
|
||||
".java": "file-code",
|
||||
".c": "file-code",
|
||||
".cpp": "file-code",
|
||||
".cc": "file-code",
|
||||
".cxx": "file-code",
|
||||
".h": "file-code",
|
||||
".hpp": "file-code",
|
||||
".cs": "file-code",
|
||||
".go": "file-code",
|
||||
".rs": "file-code",
|
||||
".rb": "file-code",
|
||||
".php": "file-code",
|
||||
".swift": "file-code",
|
||||
".kt": "file-code",
|
||||
".scala": "file-code",
|
||||
".r": "file-code",
|
||||
".m": "file-code",
|
||||
".pl": "file-code",
|
||||
".lua": "file-code",
|
||||
".dart": "file-code",
|
||||
".nim": "file-code",
|
||||
".zig": "file-code",
|
||||
".odin": "file-code",
|
||||
".v": "file-code",
|
||||
".cr": "file-code",
|
||||
".ex": "file-code",
|
||||
".exs": "file-code",
|
||||
".elm": "file-code",
|
||||
".purs": "file-code",
|
||||
".hs": "file-code",
|
||||
".ml": "file-code",
|
||||
".ocaml": "file-code",
|
||||
".fs": "file-code",
|
||||
".fsx": "file-code",
|
||||
".vb": "file-code",
|
||||
".pas": "file-code",
|
||||
".pp": "file-code",
|
||||
".inc": "file-code",
|
||||
|
||||
// Data formats
|
||||
".json": "file-json",
|
||||
".yaml": "file-cog",
|
||||
".yml": "file-cog",
|
||||
".toml": "file-cog",
|
||||
".xml": "file-code",
|
||||
".csv": "table",
|
||||
".tsv": "table",
|
||||
".sql": "database",
|
||||
".db": "database",
|
||||
".sqlite": "database",
|
||||
".sqlite3": "database",
|
||||
".parquet": "database",
|
||||
".avro": "database",
|
||||
|
||||
// Configuration files
|
||||
".ini": "file-cog",
|
||||
".cfg": "file-cog",
|
||||
".conf": "file-cog",
|
||||
".env": "file-cog",
|
||||
".dockerfile": "file-cog",
|
||||
".gitignore": "file-cog",
|
||||
".gitattributes": "file-cog",
|
||||
".editorconfig": "file-cog",
|
||||
".eslintrc": "file-cog",
|
||||
".prettierrc": "file-cog",
|
||||
".babelrc": "file-cog",
|
||||
".tsconfig": "file-cog",
|
||||
"package.json": "file-cog",
|
||||
"package-lock.json": "file-cog",
|
||||
"yarn.lock": "file-cog",
|
||||
"composer.json": "file-cog",
|
||||
"requirements.txt": "file-cog",
|
||||
"pipfile": "file-cog",
|
||||
"gemfile": "file-cog",
|
||||
"cargo.toml": "file-cog",
|
||||
"go.mod": "file-cog",
|
||||
"go.sum": "file-cog",
|
||||
"pom.xml": "file-cog",
|
||||
"build.gradle": "file-cog",
|
||||
"cmakelists.txt": "file-cog",
|
||||
"makefile": "file-cog",
|
||||
|
||||
// Shell scripts
|
||||
".sh": "terminal",
|
||||
".bash": "terminal",
|
||||
".zsh": "terminal",
|
||||
".fish": "terminal",
|
||||
".bat": "terminal",
|
||||
".cmd": "terminal",
|
||||
".ps1": "terminal",
|
||||
".psm1": "terminal",
|
||||
".psd1": "terminal",
|
||||
|
||||
// Document formats
|
||||
".pdf": "file-text",
|
||||
".doc": "file-text",
|
||||
".docx": "file-text",
|
||||
".rtf": "file-text",
|
||||
".odt": "file-text",
|
||||
".tex": "file-text",
|
||||
".latex": "file-text",
|
||||
|
||||
// Image files
|
||||
".png": "file-image",
|
||||
".jpg": "file-image",
|
||||
".jpeg": "file-image",
|
||||
".gif": "file-image",
|
||||
".svg": "file-image",
|
||||
".webp": "file-image",
|
||||
".bmp": "file-image",
|
||||
".ico": "file-image",
|
||||
".tiff": "file-image",
|
||||
".tif": "file-image",
|
||||
|
||||
// Audio files
|
||||
".mp3": "file-music",
|
||||
".wav": "file-music",
|
||||
".flac": "file-music",
|
||||
".aac": "file-music",
|
||||
".ogg": "file-music",
|
||||
".m4a": "file-music",
|
||||
".wma": "file-music",
|
||||
|
||||
// Video files
|
||||
".mp4": "play",
|
||||
".avi": "play",
|
||||
".mov": "play",
|
||||
".wmv": "play",
|
||||
".flv": "play",
|
||||
".webm": "play",
|
||||
".mkv": "play",
|
||||
".m4v": "play",
|
||||
".3gp": "play",
|
||||
|
||||
// Archive files
|
||||
".zip": "file-archive",
|
||||
".rar": "file-archive",
|
||||
".7z": "file-archive",
|
||||
".tar": "file-archive",
|
||||
".gz": "file-archive",
|
||||
".tgz": "file-archive",
|
||||
".bz2": "file-archive",
|
||||
".xz": "file-archive",
|
||||
".deb": "file-archive",
|
||||
".rpm": "file-archive",
|
||||
".dmg": "file-archive",
|
||||
".pkg": "file-archive",
|
||||
".msi": "file-archive",
|
||||
".exe": "file-archive",
|
||||
|
||||
// Font files
|
||||
".ttf": "file-type",
|
||||
".otf": "file-type",
|
||||
".woff": "file-type",
|
||||
".woff2": "file-type",
|
||||
".eot": "file-type",
|
||||
|
||||
// Other common files
|
||||
".key": "file-cog",
|
||||
".pem": "file-cog",
|
||||
".crt": "file-cog",
|
||||
".cert": "file-cog",
|
||||
".p12": "file-cog",
|
||||
".pfx": "file-cog",
|
||||
".lock": "file-cog",
|
||||
".tmp": "file",
|
||||
".bak": "file",
|
||||
".old": "file",
|
||||
".orig": "file",
|
||||
".save": "file",
|
||||
};
|
||||
|
||||
function getFileIcon(name) {
|
||||
const ext = "." + name.split(".").pop().toLowerCase();
|
||||
return EXT_ICONS[ext] || "file";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe CDN helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _iconDebounceTimer = null;
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeHighlight(block) {
|
||||
if (typeof hljs !== "undefined" && hljs.highlightElement) {
|
||||
try {
|
||||
hljs.highlightElement(block);
|
||||
} catch (e) {
|
||||
/* CDN not loaded */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function escapeHtml(str) {
|
||||
if (!str) return "";
|
||||
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
fallbackEditorEl = null;
|
||||
|
||||
try {
|
||||
await waitForCodeMirror();
|
||||
|
||||
const { EditorView, EditorState, basicSetup, markdown, python, javascript, html, css, json, xml, sql, php, cpp, java, rust, oneDark, keymap } = window.CodeMirror;
|
||||
|
||||
const currentTheme = document.documentElement.getAttribute("data-theme");
|
||||
const fileExt = filePath.split(".").pop().toLowerCase();
|
||||
|
||||
const extensions = [
|
||||
basicSetup,
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-s",
|
||||
run: () => {
|
||||
saveFile();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
EditorView.lineWrapping,
|
||||
];
|
||||
|
||||
// Add language support based on file extension
|
||||
const langMap = {
|
||||
md: markdown,
|
||||
markdown: markdown,
|
||||
py: python,
|
||||
js: javascript,
|
||||
jsx: javascript,
|
||||
ts: javascript,
|
||||
tsx: javascript,
|
||||
mjs: javascript,
|
||||
cjs: javascript,
|
||||
html: html,
|
||||
htm: html,
|
||||
css: css,
|
||||
scss: css,
|
||||
less: css,
|
||||
json: json,
|
||||
xml: xml,
|
||||
svg: xml,
|
||||
sql: sql,
|
||||
php: php,
|
||||
cpp: cpp,
|
||||
cc: cpp,
|
||||
cxx: cpp,
|
||||
c: cpp,
|
||||
h: cpp,
|
||||
hpp: cpp,
|
||||
java: java,
|
||||
rs: rust,
|
||||
sh: javascript, // Using javascript for shell scripts as fallback
|
||||
bash: javascript,
|
||||
zsh: javascript,
|
||||
};
|
||||
|
||||
const langMode = langMap[fileExt];
|
||||
if (langMode) {
|
||||
extensions.push(langMode());
|
||||
}
|
||||
|
||||
if (currentTheme === "dark") {
|
||||
extensions.push(oneDark);
|
||||
}
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: rawData.raw,
|
||||
extensions: extensions,
|
||||
});
|
||||
|
||||
editorView = new EditorView({
|
||||
state: state,
|
||||
parent: bodyEl,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("CodeMirror init failed, falling back to textarea:", err);
|
||||
fallbackEditorEl = document.createElement("textarea");
|
||||
fallbackEditorEl.className = "fallback-editor";
|
||||
fallbackEditorEl.value = rawData.raw;
|
||||
bodyEl.appendChild(fallbackEditorEl);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
fallbackEditorEl = null;
|
||||
editorVault = null;
|
||||
editorPath = null;
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
if ((!editorView && !fallbackEditorEl) || !editorVault || !editorPath) return;
|
||||
|
||||
const content = editorView ? editorView.state.doc.toString() : fallbackEditorEl.value;
|
||||
const saveBtn = document.getElementById("editor-save");
|
||||
const originalHTML = saveBtn.innerHTML;
|
||||
|
||||
try {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px"></i>';
|
||||
safeCreateIcons();
|
||||
|
||||
const response = await fetch(`/api/file/${encodeURIComponent(editorVault)}/save?path=${encodeURIComponent(editorPath)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ 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:16px;height:16px"></i>';
|
||||
safeCreateIcons();
|
||||
|
||||
setTimeout(() => {
|
||||
closeEditor();
|
||||
if (currentVault === editorVault && currentPath === editorPath) {
|
||||
openFile(currentVault, currentPath);
|
||||
}
|
||||
}, 800);
|
||||
} catch (err) {
|
||||
console.error("Save error:", err);
|
||||
alert(`Erreur: ${err.message}`);
|
||||
saveBtn.innerHTML = originalHTML;
|
||||
saveBtn.disabled = false;
|
||||
safeCreateIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile() {
|
||||
if (!editorVault || !editorPath) return;
|
||||
|
||||
const deleteBtn = document.getElementById("editor-delete");
|
||||
const originalHTML = deleteBtn.innerHTML;
|
||||
|
||||
try {
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px"></i>';
|
||||
safeCreateIcons();
|
||||
|
||||
const response = await fetch(`/api/file/${encodeURIComponent(editorVault)}?path=${encodeURIComponent(editorPath)}`, { method: "DELETE" });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Erreur de suppression");
|
||||
}
|
||||
|
||||
closeEditor();
|
||||
showWelcome();
|
||||
await refreshSidebarForContext();
|
||||
await refreshTagsForContext();
|
||||
} catch (err) {
|
||||
console.error("Delete error:", err);
|
||||
alert(`Erreur: ${err.message}`);
|
||||
deleteBtn.innerHTML = originalHTML;
|
||||
deleteBtn.disabled = false;
|
||||
safeCreateIcons();
|
||||
}
|
||||
}
|
||||
|
||||
function initEditor() {
|
||||
const cancelBtn = document.getElementById("editor-cancel");
|
||||
const deleteBtn = document.getElementById("editor-delete");
|
||||
const saveBtn = document.getElementById("editor-save");
|
||||
const modal = document.getElementById("editor-modal");
|
||||
|
||||
cancelBtn.addEventListener("click", closeEditor);
|
||||
deleteBtn.addEventListener("click", deleteFile);
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// Fix mouse wheel scrolling in editor
|
||||
modal.addEventListener(
|
||||
"wheel",
|
||||
(e) => {
|
||||
const editorBody = document.getElementById("editor-body");
|
||||
if (editorBody && editorBody.contains(e.target)) {
|
||||
// Let the editor handle the scroll
|
||||
return;
|
||||
}
|
||||
// Prevent modal from scrolling if not in editor area
|
||||
e.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
}
|
||||
|
||||
export { getFileIcon, safeCreateIcons, flushIcons, safeHighlight, escapeHtml, EXT_ICONS, openEditor, closeEditor, saveFile, deleteFile, waitForCodeMirror, initEditor };
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user