refactor: split app.js (8875 lines) into 12 ES modules
CI / lint (push) Successful in 10s
CI / security (push) Successful in 8s
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled

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:
2026-05-28 14:04:50 -04:00
parent 58ce3181c9
commit 4836d6f1d0
15 changed files with 11151 additions and 1 deletions
+1 -1
View File
@@ -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>
+83
View File
@@ -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();
});
+547
View File
@@ -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
+461
View File
@@ -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 };
+401
View File
@@ -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();
}
});
}
+560
View File
@@ -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
+55
View File
@@ -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 = {};
+437
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+510
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ---------------------------------------------------------------------------
// 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