diff --git a/frontend/js/sidebar.js b/frontend/js/sidebar.js index 0eaec0a..d163453 100644 --- a/frontend/js/sidebar.js +++ b/frontend/js/sidebar.js @@ -4,6 +4,7 @@ import { api, AuthManager } from './auth.js'; import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js'; import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js'; import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js'; +import { openCoffresView, openCoffresNewSecret, openCoffresAcces, openCoffresJournal, getCoffresForSidebar, invalidateCoffresCache } from './coffres.js'; import { performAdvancedSearch } from './search.js'; import { t } from './i18n.js'; import { filterChatMessages as _filterChat } from './filechat.js'; @@ -46,6 +47,168 @@ function _sharedIcon(size = 16) { return i; } +// --------------------------------------------------------------------------- +// Coffre Secrets — nœud virtuel « Secrets » sous le Home (Phase 1c, #19). +// Patron exact du dossier virtuel « Partage » (#196) : inséré à la racine +// du Home de l'utilisateur courant seulement, sans dossier sur disque, sans +// passer par l'indexeur. Contrairement à « Partage », le nœud est permanent +// (même sans coffre) : cliquer dessus ouvre la vue Secrets, qui crée le +// coffre personnel à la première ouverture si besoin. +// AUCUNE action de fichier n'est attachée à ce nœud ni à ses enfants : +// ni attachTreeItemActionButton, ni ContextMenuManager (qui proposerait +// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous. +// --------------------------------------------------------------------------- + +/** Icône cadenas (U+1F510) du nœud Secrets, distincte de la maison et du diamant. */ +function _secretsIcon() { + const s = document.createElement("span"); + s.className = "coffres-icon-lock"; + s.textContent = "\u{1F510}"; + return s; +} + +function _isOwnHome(vaultName) { + const me = (AuthManager.getUser() || {}).username; + return !!me && vaultName === `home-${me}`; +} + +/** Construit le nœud Secrets + son conteneur d'enfants (coffres). */ +async function _buildSecretsNode(vaultName) { + const pair = document.createDocumentFragment(); + const item = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Secrets", "data-type": "virtual-secrets" }, [icon("chevron-right", 14), _secretsIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("coffres.secrets"))])]); + const sub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Secrets` }); + item.addEventListener("click", async () => { + scrollTreeItemIntoView(item, false); + if (sub.classList.contains("collapsed")) { + if (sub.children.length === 0) await _fillSecretsNode(item, sub, vaultName); + sub.classList.remove("collapsed"); + const chev = item.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-down"); + safeCreateIcons(); + } else { + sub.classList.add("collapsed"); + const chev = item.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-right"); + safeCreateIcons(); + } + await openCoffresView(); + closeMobileSidebar(); + }); + item.addEventListener("contextmenu", (e) => { + e.preventDefault(); + _openSecretsMenu(e.clientX, e.clientY); + }); + const actionBtn = el("button", { class: "tree-item-action-btn", type: "button", title: t("coffres.secrets") }, [document.createTextNode("\u22EE")]); + actionBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const r = actionBtn.getBoundingClientRect(); + _openSecretsMenu(Math.min(r.right, window.innerWidth - 16), r.bottom); + }); + item.appendChild(actionBtn); + pair.appendChild(item); + pair.appendChild(sub); + // Compteur et enfants, en arrière-plan : le nœud existe même si l'API échoue. + _fillSecretsNode(item, sub, vaultName).catch(() => {}); + return pair; +} + +/** Remplit le compteur et les enfants (coffres) d'un nœud Secrets existant. */ +async function _fillSecretsNode(item, sub, vaultName) { + const data = await getCoffresForSidebar(); + item.querySelectorAll(".badge-small, .coffres-alert-dot").forEach((n) => n.remove()); + if (!data.disabled && data.personnel) { + if (!data.personnel.kek_configuree) { + const dot = document.createElement("span"); + dot.className = "coffres-alert-dot"; + dot.textContent = "\u26A0"; + dot.title = t("coffres.kek_absente"); + item.appendChild(dot); + } else { + item.appendChild(smallBadge(data.personnel.nb_secrets)); + } + } + sub.innerHTML = ""; + const coffres = (data.coffres || []).slice().sort((a, b) => { + const aPers = a.proprietaire === (AuthManager.getUser() || {}).username && a.perimetre === "personnel" ? 0 : 1; + const bPers = b.proprietaire === (AuthManager.getUser() || {}).username && b.perimetre === "personnel" ? 0 : 1; + return aPers - bPers || String(a.nom).localeCompare(String(b.nom)); + }); + coffres.forEach((c) => { + const me = (AuthManager.getUser() || {}).username; + const label = c.proprietaire === me ? c.nom : `${c.nom} (${c.proprietaire})`; + const child = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": `Secrets/${c.slug}`, "data-type": "virtual-coffre", "data-coffre-slug": c.slug }, [icon("lock", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(label)]), smallBadge(c.nb_secrets)]); + child.addEventListener("click", async () => { + scrollTreeItemIntoView(child, false); + await openCoffresView(c.slug); + closeMobileSidebar(); + }); + child.addEventListener("contextmenu", (e) => { + e.preventDefault(); + _openSecretsMenu(e.clientX, e.clientY); + }); + sub.appendChild(child); + }); + safeCreateIcons(); +} + +/** Rafraîchit tous les nœuds Secrets affichés (après création, rotation, révocation…). */ +async function _refreshSecretsNodes() { + const items = Array.from(document.querySelectorAll('[data-type="virtual-secrets"]')); + for (const item of items) { + const vaultName = item.dataset.vault; + const sub = item.nextElementSibling; + if (sub && sub.classList.contains("tree-children")) { + await _fillSecretsNode(item, sub, vaultName); + } + } +} + +if (!window.__obsigateSecretsListener) { + window.__obsigateSecretsListener = true; + document.addEventListener("obsigate:coffres-changed", () => { + invalidateCoffresCache(); + _refreshSecretsNodes().catch(() => {}); + }); +} + +/** Menu ⋮ du nœud Secrets : Ouvrir, Nouveau secret, Gérer les accès, Journal. + * Menu propre, jamais ContextMenuManager (pas d'action de fichier). */ +function _openSecretsMenu(x, y) { + document.querySelectorAll(".coffres-context-menu").forEach((n) => n.remove()); + const menu = el("div", { class: "coffres-context-menu" }, []); + const entries = [ + [t("coffres.detail"), () => openCoffresView()], + [t("coffres.new_secret"), () => openCoffresNewSecret()], + [t("coffres.acces"), () => openCoffresAcces()], + [t("coffres.journal"), () => openCoffresJournal()], + ]; + entries.forEach(([label, run]) => { + const b = el("button", { class: "coffres-context-item", type: "button" }, [document.createTextNode(label)]); + b.addEventListener("click", async () => { + menu.remove(); + await run(); + closeMobileSidebar(); + }); + menu.appendChild(b); + }); + document.body.appendChild(menu); + const rect = menu.getBoundingClientRect(); + menu.style.left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)) + "px"; + menu.style.top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)) + "px"; + const closer = (e) => { + if (!menu.contains(e.target)) { + menu.remove(); + document.removeEventListener("click", closer, true); + document.removeEventListener("keydown", onKey); + } + }; + const onKey = (e) => { if (e.key === "Escape") { menu.remove(); document.removeEventListener("click", closer, true); document.removeEventListener("keydown", onKey); } }; + setTimeout(() => { + document.addEventListener("click", closer, true); + document.addEventListener("keydown", onKey); + }, 0); +} + /** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */ function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) { if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon(); @@ -494,6 +657,12 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) { } }); + // Coffre Secrets (Phase 1c) : même nœud virtuel que dans loadDirectory. + if (me && vaultName === `home-${me}` && !dirPath) { + const pair = await _buildSecretsNode(vaultName); + if (pair) fragment.insertBefore(pair, fragment.firstChild); + } + // Replace container content in a single batch operation to avoid flash container.textContent = ""; container.appendChild(fragment); @@ -838,6 +1007,14 @@ async function loadDirectory(vaultName, dirPath, container) { } }); + // Coffre Secrets (Phase 1c) : nœud virtuel permanent en tête, à la racine + // du Home de l'utilisateur courant seulement, jamais sous un autre Home + // ni sous un vault de notes, et jamais comme un dossier physique. + if (me && vaultName === `home-${me}` && !dirPath) { + const pair = await _buildSecretsNode(vaultName); + if (pair) fragment.insertBefore(pair, fragment.firstChild); + } + container.appendChild(fragment); safeCreateIcons(); }