Files
ObsiGate/frontend/js/config.js
T
bruno fe9f7b49f7
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 2m1s
CI / test (push) Successful in 4m44s
CI / build (push) Successful in 1m54s
CI / e2e (push) Successful in 16m25s
feat: premier lancement ObsiGate + section Configuration harmonisée (#160)
- Premier lancement : %USERPROFILE%\ObsiGate créé et monté comme vault de
  démarrage « ObsiGate » (remplace voute_obsidian), vault_path dessus,
  racine home nommée d'après son dernier segment (fin du « bruno » codé en
  dur), champs de fenêtre préservés.
- Document « Prise en main.md » embarqué dans le binaire
  (include_str!) et écrit dans ce répertoire si absent — jamais écrasé.
- Section « 🖥️ Vaults & dossiers (Desktop) » refondue : markup à classes
  (zéro style inline), bloc CSS desktop-roots-* sur variables (motif
  webauthn-key-item), boutons .config-btn-sm primaire/secondaire,
  cibles tactiles 44px en mobile.
- Tests : test_default_first_run_config, test_welcome_doc_embedded
  (cargo test 28 passed), suites frontend vertes, E2E 123/123.
2026-10-03 10:02:16 -04:00

2758 lines
103 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// config.js — extracted from app.js (3872-4865)
import { api, AuthManager, initMfaSettings } from './auth.js';
import { state } from './state.js';
import { el, icon, openFile, filterSavedSearches } from './viewer.js';
import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js';
import { escapeHtml, safeCreateIcons } from './utils.js';
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
import { t, setLocale, getLocale } from './i18n.js';
import { getModelCapabilities, renderCapabilityBadges, refreshAIPickers } from './ai.js';
import { isTauriEnv, invoke, pickAndAddVault, pickAndAddDir, removeRoot } from './desktop.js';
let _recentTimestampTimer = null;
let _recentFilesCache = [];
let _recentRefreshTimer = null;
let _recentQuery = "";
let _aiSessionsCache = [];
let _aiQuery = "";
export async function loadRecentFiles(vaultFilter) {
const listEl = document.getElementById("recent-list");
const emptyEl = document.getElementById("recent-empty");
if (!listEl) return;
let url = "/api/recent?mode=modified";
if (vaultFilter) url += `&vault=${encodeURIComponent(vaultFilter)}`;
try {
const data = await api(url);
_recentFilesCache = data.files || [];
renderRecentList(_applyRecentFilter(_recentFilesCache));
} catch (err) {
console.error("Failed to load recent files:", err);
listEl.innerHTML = "";
if (emptyEl) {
emptyEl.classList.remove("hidden");
}
}
}
function renderRecentList(files) {
const listEl = document.getElementById("recent-list");
const emptyEl = document.getElementById("recent-empty");
if (!listEl) return;
listEl.innerHTML = "";
if (!files || files.length === 0) {
if (_recentFilesCache.length && _recentQuery) {
listEl.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode(t("sidebar.no_results"))]));
}
if (emptyEl) {
emptyEl.classList.remove("hidden");
safeCreateIcons();
}
return;
}
if (emptyEl) emptyEl.classList.add("hidden");
files.forEach((f) => {
const item = el("div", { class: "recent-item", "data-vault": f.vault, "data-path": f.path });
// Header row: time + vault badge
const header = el("div", { class: "recent-item-header" });
const timeSpan = el("span", { class: "recent-time" }, [icon("clock", 11), document.createTextNode(f.mtime_human)]);
const badge = el("span", { class: "recent-vault-badge" }, [document.createTextNode(f.vault)]);
header.appendChild(timeSpan);
header.appendChild(badge);
item.appendChild(header);
// Title
const titleEl = el("div", { class: "recent-item-title" }, [document.createTextNode(f.title || f.path.split("/").pop())]);
item.appendChild(titleEl);
// Path breadcrumb
const pathParts = f.path.split("/");
if (pathParts.length > 1) {
const pathEl = el("div", { class: "recent-item-path" }, [document.createTextNode(pathParts.slice(0, -1).join(" / "))]);
item.appendChild(pathEl);
}
// Preview
if (f.preview) {
const previewEl = el("div", { class: "recent-item-preview" }, [document.createTextNode(f.preview)]);
item.appendChild(previewEl);
}
// Tags
if (f.tags && f.tags.length > 0) {
const tagsEl = el("div", { class: "recent-item-tags" });
f.tags.forEach((t) => {
tagsEl.appendChild(el("span", { class: "tag-pill" }, [document.createTextNode(t)]));
});
item.appendChild(tagsEl);
}
// Click handler
item.addEventListener("click", () => {
openFile(f.vault, f.path);
closeMobileSidebar();
});
listEl.appendChild(item);
});
safeCreateIcons();
}
function _humanizeDelta(mtime) {
const delta = Date.now() / 1000 - mtime;
if (delta < 60) return t("config.just_now");
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" });
}
function _refreshRecentTimestamps() {
if (state.activeSidebarTab !== "recent" || !_recentFilesCache.length) return;
const items = document.querySelectorAll(".recent-item");
items.forEach((item, i) => {
if (i < _recentFilesCache.length) {
const timeSpan = item.querySelector(".recent-time");
if (timeSpan) {
// keep the icon, update text
const textNode = timeSpan.lastChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
textNode.textContent = _humanizeDelta(_recentFilesCache[i].mtime);
}
}
}
});
}
export function _populateRecentVaultFilter() {
const select = document.getElementById("recent-vault-filter");
if (!select) return;
// keep first option "Tous les vaults"
while (select.options.length > 1) select.remove(1);
state.allVaults.forEach((v) => {
const opt = document.createElement("option");
opt.value = v.name;
opt.textContent = v.name;
select.appendChild(opt);
});
syncVaultSelectors();
}
function initRecentTab() {
const select = document.getElementById("recent-vault-filter");
if (select) {
select.addEventListener("change", async () => {
const val = select.value || "all";
await setSelectedVaultContext(val, { focusVault: val !== "all" });
});
}
// Periodic timestamp refresh (every 60s)
_recentTimestampTimer = setInterval(_refreshRecentTimestamps, 60000);
}
// ---------------------------------------------------------------------------
// AI history sidebar tab (#96)
// ---------------------------------------------------------------------------
function _formatAIDate(ts) {
if (!ts) return "";
try {
return new Date(ts).toLocaleString();
} catch {
return "";
}
}
/** Accent- and case-insensitive normalization for the sidebar filters (#98/#99). */
function _sidebarNorm(value) {
return String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
/** Apply the current sidebar query to the recent-files list (#99). */
function _applyRecentFilter(files) {
const q = _sidebarNorm(_recentQuery);
if (!q) return files;
return (files || []).filter((f) => {
const tags = Array.isArray(f.tags) ? f.tags.join(" ") : "";
return _sidebarNorm(f.title).includes(q)
|| _sidebarNorm(f.path).includes(q)
|| _sidebarNorm(f.vault).includes(q)
|| _sidebarNorm(f.preview).includes(q)
|| _sidebarNorm(tags).includes(q);
});
}
/** Filter the sidebar recent-files list by the sidebar filter input (#99). */
export function filterRecentFiles(query) {
_recentQuery = (query || "").trim();
renderRecentList(_applyRecentFilter(_recentFilesCache));
}
/** Apply the current sidebar query to a session list, resolving the mode label. */
function _applyAIFilter(sessions) {
const q = _sidebarNorm(_aiQuery);
if (!q) return sessions;
return (sessions || []).filter((s) => {
const modeLabel = t("bookslm.mode_" + (s.mode || "general"));
return _sidebarNorm(s.title).includes(q)
|| _sidebarNorm(s.preview).includes(q)
|| _sidebarNorm(s.directory).includes(q)
|| _sidebarNorm(s.context).includes(q)
|| _sidebarNorm(modeLabel).includes(q);
});
}
/** Filter the sidebar AI history list by the sidebar filter input (#98). */
export function filterAIHistory(query) {
_aiQuery = (query || "").trim();
renderAIHistoryList(_applyAIFilter(_aiSessionsCache));
}
export async function loadAISessionList() {
const listEl = document.getElementById("ai-history-list");
const emptyEl = document.getElementById("ai-history-empty");
if (!listEl) return;
let sessions = [];
try {
const data = await api("/api/ai/bookslm/history");
sessions = data.sessions || [];
} catch (err) {
console.error("Failed to load AI history:", err);
}
_aiSessionsCache = sessions;
renderAIHistoryList(_applyAIFilter(_aiSessionsCache));
}
function renderAIHistoryList(sessions) {
const listEl = document.getElementById("ai-history-list");
const emptyEl = document.getElementById("ai-history-empty");
if (!listEl) return;
listEl.innerHTML = "";
if (!sessions || sessions.length === 0) {
if (_aiSessionsCache.length && _aiQuery) {
listEl.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode(t("bookslm.history_no_match"))]));
}
if (emptyEl) {
emptyEl.classList.remove("hidden");
safeCreateIcons();
}
return;
}
if (emptyEl) emptyEl.classList.add("hidden");
sessions.forEach((s) => {
const item = el("div", { class: "recent-item", "data-session-id": s.id });
const header = el("div", { class: "recent-item-header" });
const timeSpan = el("span", { class: "recent-time" }, [icon("clock", 11), document.createTextNode(_formatAIDate(s.updatedAt))]);
header.appendChild(timeSpan);
header.appendChild(el("span", { class: "recent-vault-badge" }, [document.createTextNode(t("bookslm.mode_" + (s.mode || "general"))) ]));
item.appendChild(header);
const titleEl = el("div", { class: "recent-item-title" }, [document.createTextNode(s.title || t("bookslm.untitled"))]);
item.appendChild(titleEl);
if (s.preview) {
item.appendChild(el("div", { class: "recent-item-preview" }, [document.createTextNode(s.preview)]));
}
item.addEventListener("click", () => {
closeMobileSidebar();
import("./bookslm.js").then((m) => m.default.openWithSession(s.id)).catch((e) => console.error(e));
});
listEl.appendChild(item);
});
safeCreateIcons();
}
// ---------------------------------------------------------------------------
// Sidebar tabs
// ---------------------------------------------------------------------------
function initSidebarTabs() {
document.querySelectorAll(".sidebar-tab").forEach((tab) => {
tab.addEventListener("click", () => switchSidebarTab(tab.dataset.tab));
});
// #96 — Refresh the sidebar AI history list whenever a conversation changes.
window.addEventListener("bookslm:history-updated", () => {
if (state.activeSidebarTab === "ai") loadAISessionList();
});
}
function switchSidebarTab(tab) {
state.activeSidebarTab = tab;
document.querySelectorAll(".sidebar-tab").forEach((btn) => {
const isActive = btn.dataset.tab === tab;
btn.classList.toggle("active", isActive);
btn.setAttribute("aria-selected", isActive ? "true" : "false");
});
document.querySelectorAll(".sidebar-tab-panel").forEach((panel) => {
const isActive = panel.id === `sidebar-panel-${tab}`;
panel.classList.toggle("active", isActive);
});
const filterInput = document.getElementById("sidebar-filter-input");
if (filterInput) {
const placeholders = {
vaults: "Filtrer fichiers...",
tags: "Filtrer tags...",
recent: t("sidebar.filter_recent"),
saved: t("sidebar.filter_saved"),
ai: t("sidebar.filter_ai"),
};
filterInput.placeholder = placeholders[tab] || "";
}
const query = filterInput ? (state.sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : "";
if (query) {
if (tab === "vaults") performTreeSearch(query);
else if (tab === "tags") filterTagCloud(query);
else if (tab === "recent") filterRecentFiles(query);
else if (tab === "saved") filterSavedSearches(query);
else if (tab === "ai") filterAIHistory(query);
}
// Auto-load recent files when switching to the recent tab
if (tab === "recent") {
_populateRecentVaultFilter();
if (filterInput) filterRecentFiles(filterInput.value.trim());
const vaultFilter = document.getElementById("recent-vault-filter");
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
}
// #99 — Re-apply the current sidebar query on the saved-searches tab.
if (tab === "saved" && filterInput) {
filterSavedSearches(filterInput.value.trim());
}
// #96/#98 — Auto-load the AI conversation history when entering its tab,
// re-applying the current sidebar filter query.
if (tab === "ai") {
if (filterInput) _aiQuery = filterInput.value.trim();
loadAISessionList();
}
}
function initHelpModal() {
const openBtn = document.getElementById("help-open-btn");
const closeBtn = document.getElementById("help-close");
const modal = document.getElementById("help-modal");
if (!openBtn || !closeBtn || !modal) return;
let helpNavInitialized = false;
openBtn.addEventListener("click", () => {
modal.classList.add("active");
closeHeaderMenu();
safeCreateIcons();
// Reset TOC to CSS default (mobile: hidden, desktop: visible)
const nav = document.getElementById("help-nav");
if (nav) nav.style.display = '';
if (!helpNavInitialized) {
initHelpNavigation();
helpNavInitialized = true;
}
renderGuideMermaid();
});
closeBtn.addEventListener("click", closeHelpModal);
modal.addEventListener("click", (e) => {
if (e.target === modal) {
closeHelpModal();
}
});
// Help TOC hamburger + search clear (#87, ex-onclick inline in index.html).
var helpHamburger = document.getElementById("help-hamburger");
if (helpHamburger) {
helpHamburger.addEventListener("click", function() {
var n = document.getElementById("help-nav");
if (n) {
var d = n.style.display;
n.style.display = d === "none" || d === "" ? "flex" : "none";
}
});
}
var helpSearch = document.getElementById("help-nav-search");
var helpSearchClear = document.getElementById("help-search-clear");
if (helpSearchClear && helpSearch) {
helpSearchClear.addEventListener("click", function() {
helpSearch.value = "";
helpSearch.dispatchEvent(new Event("input"));
helpSearch.focus();
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
closeHelpModal();
}
});
// Guide downloads (#105) — markdown / pdf, current language.
const dlMd = document.getElementById("help-download-md");
const dlPdf = document.getElementById("help-download-pdf");
[dlMd, dlPdf].forEach((btn) => {
if (!btn) return;
btn.addEventListener("click", () => {
downloadGuide(btn.id === "help-download-md" ? "md" : "pdf");
});
});
}
// Render any Mermaid blocks inside the guide modal (the architecture diagram,
// #105). The viewer's own pipeline only touches document views, so the help
// modal is enriched here — once per modal open, cheap on repeats.
function renderGuideMermaid() {
const modal = document.getElementById("help-modal");
if (!modal || modal.dataset.mermaidRendered === "1") return;
import("./mermaid-viewer.js")
.then((m) => m.renderMermaidBlocks(modal))
.then(() => {
// Only mark done when the source block actually became a rendered
// diagram — if the Mermaid CDN wasn't ready yet, retry on next open.
if (!modal.querySelector("code.language-mermaid")) {
modal.dataset.mermaidRendered = "1";
}
})
.catch(() => { /* CDN offline: keep the code block readable */ });
}
// Fetch the generated guide (auth headers + cookie) and trigger the browser
// download, mirroring viewer.downloadExport().
async function downloadGuide(format) {
showToast(t("viewer.export_start"), "info");
try {
const headers = AuthManager.getAuthHeaders ? AuthManager.getAuthHeaders() || {} : {};
const res = await fetch(
`/api/guide/download?format=${format}&lang=${encodeURIComponent(getLocale() || "fr")}`,
{ credentials: "include", headers },
);
if (!res.ok) {
let detail = "";
try { detail = (await res.json()).detail || ""; } catch (_) { /* ignore */ }
throw new Error(detail || "HTTP " + res.status);
}
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `ObsiGate-Guide-${getLocale() || "fr"}.${format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
showToast(t("viewer.export_done"), "success");
} catch (err) {
console.error("Guide export error:", err);
showToast(t("viewer.export_error") + " " + err.message, "error");
}
}
function initEditorPocBtn() {
var btn = document.getElementById("editor-poc-btn");
if (!btn) return;
btn.addEventListener("click", function() {
window.open("/editor-poc", "_blank");
});
}
function initHelpNavigation() {
const helpBody = document.getElementById("help-body");
const navList = document.getElementById("help-nav-list");
const nav = document.getElementById("help-nav");
const searchInput = document.getElementById("help-nav-search");
const helpContent = document.querySelector(".help-content");
if (!helpBody || !navList || !nav) return;
const allLinks = [...navList.querySelectorAll(".help-nav-link")];
const allSections = [...document.querySelectorAll(".help-section")];
const sectionMap = {};
allSections.forEach(s => { sectionMap[s.id] = s; });
// Build text index for content search
const sectionTextCache = {};
allSections.forEach(s => {
sectionTextCache[s.id] = (s.textContent || '').toLowerCase();
});
// ── Mobile: simple display toggle ──
function isMobile() { return window.innerWidth <= 768; }
function toggleTOC() {
if (nav.style.display === 'none' || nav.style.display === '') {
nav.style.display = 'flex';
if (searchInput) setTimeout(() => searchInput.focus(), 150);
} else {
nav.style.display = 'none';
}
}
// ── Scroll to section ──
function scrollToSection(targetId) {
const section = sectionMap[targetId];
if (!section) return;
section.scrollIntoView({ behavior: "smooth", block: "start" });
section.setAttribute("tabindex", "-1");
if (isMobile()) nav.style.display = 'none';
}
// ── Focus the content pane ──
function focusContent() {
const activeLink = navList.querySelector(".help-nav-link.active:not(.hidden)");
if (activeLink) {
const targetId = activeLink.getAttribute("href").substring(1);
const section = sectionMap[targetId];
if (section) {
const h2 = section.querySelector("h2, h3");
if (h2) { h2.setAttribute("tabindex", "-1"); h2.focus({ preventScroll: true }); return; }
}
}
if (helpContent) { helpContent.setAttribute("tabindex", "-1"); helpContent.focus({ preventScroll: true }); }
}
// ── Focus TOC active link ──
function focusTOC() {
if (isMobile() && (nav.style.display === 'none' || nav.style.display === '')) {
nav.style.display = 'flex';
}
const activeLink = navList.querySelector(".help-nav-link.active:not(.hidden)");
if (activeLink) { activeLink.focus(); return; }
const firstLink = navList.querySelector(".help-nav-link:not(.hidden)");
if (firstLink) firstLink.focus();
}
// ── Nav link click handler ──
allLinks.forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault();
const targetId = link.getAttribute("href").substring(1);
scrollToSection(targetId);
});
});
// ── Keyboard navigation inside nav list ──
navList.addEventListener("keydown", (e) => {
const items = [...navList.querySelectorAll(".help-nav-link:not(.hidden)")];
if (!items.length) return;
const currentIdx = items.indexOf(document.activeElement);
if (e.key === "ArrowDown") {
e.preventDefault();
const next = (currentIdx + 1) % items.length;
items[next].focus();
} else if (e.key === "ArrowUp") {
e.preventDefault();
const prev = (currentIdx - 1 + items.length) % items.length;
items[prev].focus();
} else if (e.key === "ArrowRight") {
e.preventDefault();
focusContent();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
const prev = (currentIdx - 1 + items.length) % items.length;
items[prev].focus();
} else if (e.key === "Home") {
e.preventDefault();
items[0].focus();
} else if (e.key === "End") {
e.preventDefault();
items[items.length - 1].focus();
} else if (e.key === "Enter" && currentIdx >= 0) {
e.preventDefault();
const targetId = items[currentIdx].getAttribute("href").substring(1);
scrollToSection(targetId);
setTimeout(() => focusContent(), 400);
}
});
navList.querySelectorAll("li").forEach((li) => li.setAttribute("role", "none"));
// ── Keyboard navigation in content pane ──
if (helpContent) {
helpContent.setAttribute("tabindex", "-1");
helpContent.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
focusTOC();
} else if (e.key === "ArrowUp" && document.activeElement === helpContent) {
e.preventDefault();
focusTOC();
}
});
}
// ── Keyboard navigation in search input ──
if (searchInput) {
searchInput.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
const firstLink = navList.querySelector(".help-nav-link:not(.hidden)");
if (firstLink) firstLink.focus();
}
});
}
// ── Search: filter TOC + hide non-matching sections + highlight text ──
let currentQuery = "";
if (searchInput) {
searchInput.addEventListener("input", () => {
currentQuery = searchInput.value.toLowerCase().trim();
applySearchFilter();
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Escape" && currentQuery) {
searchInput.value = "";
currentQuery = "";
applySearchFilter();
}
});
}
function clearHighlights() {
allSections.forEach(section => {
section.querySelectorAll(".help-search-highlight").forEach(el => {
const parent = el.parentNode;
parent.replaceChild(document.createTextNode(el.textContent), el);
parent.normalize();
});
});
}
function highlightText(root, query) {
if (!query || query.length < 2) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
const textNodes = [];
while (walker.nextNode()) {
const node = walker.currentNode;
// Skip nodes inside script, style, or already-highlighted
if (node.parentElement && !node.parentElement.closest("script, style, .help-search-highlight, .help-nav")) {
textNodes.push(node);
}
}
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
textNodes.forEach(node => {
const text = node.textContent;
if (!regex.test(text)) return;
regex.lastIndex = 0;
const frag = document.createDocumentFragment();
let lastIdx = 0;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIdx) {
frag.appendChild(document.createTextNode(text.slice(lastIdx, match.index)));
}
const mark = document.createElement("mark");
mark.className = "help-search-highlight";
mark.textContent = match[0];
frag.appendChild(mark);
lastIdx = regex.lastIndex;
if (match[0].length === 0) regex.lastIndex++;
}
if (lastIdx < text.length) {
frag.appendChild(document.createTextNode(text.slice(lastIdx)));
}
node.parentNode.replaceChild(frag, node);
});
}
function applySearchFilter() {
const q = currentQuery;
// Clear previous highlights
clearHighlights();
// Filter TOC links
allLinks.forEach((link) => {
const text = link.textContent.toLowerCase();
if (!q || text.includes(q)) {
link.classList.remove("hidden");
} else {
link.classList.add("hidden");
}
});
// Filter content sections + highlight
allSections.forEach((section) => {
if (!q) {
section.classList.remove("help-hidden");
return;
}
const sectionText = sectionTextCache[section.id] || '';
const sectionTitle = (section.querySelector("h2, h3")?.textContent || '').toLowerCase();
if (sectionTitle.includes(q) || sectionText.includes(q)) {
section.classList.remove("help-hidden");
// Highlight matching text in this section
highlightText(section, q);
} else {
section.classList.add("help-hidden");
}
});
updateActiveLink();
ensureNoResultsMessage(q);
}
function ensureNoResultsMessage(q) {
const existing = document.getElementById("help-no-results");
const visibleSections = allSections.filter(s => !s.classList.contains("help-hidden"));
if (q && visibleSections.length === 0) {
if (!existing) {
const msg = document.createElement("div");
msg.id = "help-no-results";
msg.style.cssText = "padding:40px;text-align:center;color:var(--text-muted);font-size:0.9rem";
msg.innerHTML = `<div style="font-size:2rem;margin-bottom:8px">🔍</div><p>Aucun résultat pour <strong>"${q}"</strong></p><p style="font-size:0.75rem">Essayez un autre terme</p>`;
if (helpContent) helpContent.appendChild(msg);
}
} else if (existing) {
existing.remove();
}
}
// ── Scroll spy ──
function updateActiveLink() {
const visibleLinks = allLinks.filter(l => !l.classList.contains("hidden"));
const visibleSections = allSections.filter(s => !s.classList.contains("help-hidden"));
let activeId = null;
for (const section of visibleSections) {
const rect = section.getBoundingClientRect();
const contentRect = helpContent ? helpContent.getBoundingClientRect() : null;
if (rect.top < (contentRect ? contentRect.top + 120 : 200)) {
activeId = section.id;
}
}
visibleLinks.forEach((link) => {
const isMatch = link.getAttribute("href") === `#${activeId}`;
link.classList.toggle("active", isMatch);
});
}
const observer = new IntersectionObserver(
(entries) => {
let bestEntry = null;
for (const entry of entries) {
if (entry.isIntersecting) {
if (!bestEntry || entry.boundingClientRect.top < bestEntry.boundingClientRect.top) {
bestEntry = entry;
}
}
}
if (bestEntry) {
const id = bestEntry.target.getAttribute("id");
allLinks.forEach((link) => {
const isMatch = link.getAttribute("href") === `#${id}`;
link.classList.toggle("active", isMatch);
});
}
},
{
root: helpBody,
rootMargin: "-10% 0px -75% 0px",
threshold: [0, 0.25, 0.5],
}
);
allSections.forEach((section) => observer.observe(section));
// ── Keyboard: Ctrl+↑/↓ to jump between sections ──
helpBody.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
e.preventDefault();
const visibleSections = allSections.filter(s => !s.classList.contains("help-hidden"));
const currentSection = visibleSections.find((s) => {
const rect = s.getBoundingClientRect();
const contentRect = helpContent ? helpContent.getBoundingClientRect() : null;
return rect.top >= (contentRect ? contentRect.top : 0) && rect.top < (contentRect ? contentRect.top + 120 : 120);
});
if (!currentSection) return;
const currentIdx = visibleSections.indexOf(currentSection);
if (e.key === "ArrowDown" && currentIdx < visibleSections.length - 1) {
visibleSections[currentIdx + 1].scrollIntoView({ behavior: "smooth", block: "start" });
} else if (e.key === "ArrowUp" && currentIdx > 0) {
visibleSections[currentIdx - 1].scrollIntoView({ behavior: "smooth", block: "start" });
}
}
});
}
function closeHelpModal() {
const modal = document.getElementById("help-modal");
if (modal) modal.classList.remove("active");
}
// ── Vaults & root directories — desktop only (#159) ──────────────────────
// Lists the roots the Tauri shell injects into the backend (vaults +
// directories) and offers add/remove. The whole section — including its TOC
// entry — is hidden outside the desktop app.
async function renderDesktopRoots() {
const section = document.getElementById("cfg-desktop-roots");
const navLink = document.querySelector('#config-nav a[href="#cfg-desktop-roots"]');
if (!section) return;
if (!isTauriEnv()) {
section.style.display = "none";
if (navLink && navLink.parentElement) navLink.parentElement.style.display = "none";
return;
}
section.style.display = "";
if (navLink && navLink.parentElement) navLink.parentElement.style.display = "";
const vaults = (await invoke("list_vaults")) || [];
const dirs = (await invoke("list_dirs")) || [];
const list = document.getElementById("desktop-roots-list");
if (!list) return;
const row = (name, path, kind) =>
'<div class="desktop-roots-item">' +
`<i data-lucide="${kind === "vault" ? "vault" : "folder"}" class="desktop-roots-icon"></i>` +
'<div class="desktop-roots-text">' +
`<span class="desktop-roots-name">${escapeHtml(name)}</span>` +
`<span class="desktop-roots-path" title="${escapeHtml(path)}">${escapeHtml(path)}</span>` +
"</div>" +
`<button class="config-btn-secondary config-btn-sm" data-root="${escapeHtml(name)}">` +
`<i data-lucide="folder-minus" class="desktop-roots-btn-icon"></i>${escapeHtml(t("desktop.remove_root"))}</button>` +
"</div>";
if (!vaults.length && !dirs.length) {
list.innerHTML = `<div class="desktop-roots-empty">${escapeHtml(t("desktop.roots_empty"))}</div>`;
} else {
list.innerHTML = vaults.map((v) => row(v.name, v.path, "vault")).join("") +
dirs.map((d) => row(d.name, d.path, "dir")).join("");
for (const btn of list.querySelectorAll("button[data-root]")) {
// onclick (not addEventListener): the modal re-renders on every open.
btn.onclick = () => removeRoot(btn.dataset.root, t("desktop.remove_root_confirm", { name: btn.dataset.root }));
}
}
const addVaultBtn = document.getElementById("cfg-desktop-add-vault");
const addDirBtn = document.getElementById("cfg-desktop-add-dir");
if (addVaultBtn) addVaultBtn.onclick = () => pickAndAddVault();
if (addDirBtn) addDirBtn.onclick = () => pickAndAddDir();
safeCreateIcons();
}
function initConfigModal() {
const openBtn = document.getElementById("config-open-btn");
const closeBtn = document.getElementById("config-close");
const modal = document.getElementById("config-modal");
const addBtn = document.getElementById("config-add-btn");
const patternInput = document.getElementById("config-pattern-input");
if (!openBtn || !closeBtn || !modal) return;
openBtn.addEventListener("click", async () => {
modal.classList.add("active");
closeHeaderMenu();
// BUG-071/#114: reset the TOC to the CSS default (mobile: hidden drawer,
// desktop: visible sidebar) like the help modal does on open. Clear the
// stale inline display and the drawer-open class so a previous mobile
// session cannot leave the nav stuck open.
var configNavOnOpen = document.getElementById("config-nav");
if (configNavOnOpen) configNavOnOpen.style.display = '';
modal.classList.remove("config-toc-open");
var configHamburgerOnOpen = document.getElementById("config-hamburger");
if (configHamburgerOnOpen) configHamburgerOnOpen.classList.remove("active");
renderConfigFilters();
loadConfigFields();
loadDiagnostics();
loadAbout();
await loadHiddenFilesSettings();
loadWebhooksUI();
loadTokensUI();
loadSharesUI();
loadToolKeys();
renderDesktopRoots();
safeCreateIcons();
});
closeBtn.addEventListener("click", closeConfigModal);
modal.addEventListener("click", (e) => {
if (e.target === modal) {
// #114: a tap on the backdrop (or outside the drawer) first closes the
// TOC drawer; only a second tap closes the whole modal.
if (modal.classList.contains("config-toc-open")) {
_setConfigNav(false);
return;
}
closeConfigModal();
}
});
addBtn.addEventListener("click", addConfigFilter);
patternInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
addConfigFilter();
}
});
patternInput.addEventListener("input", updateRegexPreview);
// Frontend config fields — save to localStorage on change
["cfg-debounce", "cfg-results-per-page", "cfg-min-query", "cfg-timeout"].forEach((id) => {
const input = document.getElementById(id);
if (input) input.addEventListener("change", saveFrontendConfig);
});
// Backend save button
const saveBtn = document.getElementById("cfg-save-backend");
if (saveBtn) saveBtn.addEventListener("click", saveBackendConfig);
// Force reindex
const reindexBtn = document.getElementById("cfg-reindex");
if (reindexBtn) reindexBtn.addEventListener("click", forceReindex);
// Reset defaults
const resetBtn = document.getElementById("cfg-reset-defaults");
if (resetBtn) resetBtn.addEventListener("click", resetConfigDefaults);
// Refresh diagnostics
const diagBtn = document.getElementById("cfg-refresh-diag");
if (diagBtn) diagBtn.addEventListener("click", loadDiagnostics);
// Hidden files configuration
const saveHiddenBtn = document.getElementById("cfg-save-hidden-files");
if (saveHiddenBtn) saveHiddenBtn.addEventListener("click", saveHiddenFilesSettings);
// AI Keys
const saveAIKeysBtn = document.getElementById("cfg-save-ai-keys");
if (saveAIKeysBtn) saveAIKeysBtn.addEventListener("click", saveAIKeys);
const testAIKeysBtn = document.getElementById("cfg-test-ai-keys");
if (testAIKeysBtn) testAIKeysBtn.addEventListener("click", testAIKeys);
// Provider search filter (#104)
const aiProviderSearch = document.getElementById("cfg-ai-search");
if (aiProviderSearch) {
aiProviderSearch.addEventListener("input", () => filterAIProviders(aiProviderSearch.value));
}
// Tool & connected-source keys (#103)
const saveToolKeysBtn = document.getElementById("cfg-save-tool-keys");
if (saveToolKeysBtn) saveToolKeysBtn.addEventListener("click", saveToolKeys);
// Default provider/model selection
const aiDefaultProviderSel = document.getElementById("cfg-ai-default-provider");
if (aiDefaultProviderSel) {
aiDefaultProviderSel.addEventListener("change", async () => {
let cfg = {};
try { cfg = await api("/api/config"); } catch(e) {}
const models = cfg.ai_default_models || {};
await _populateAIModelOptions(aiDefaultProviderSel.value, models[aiDefaultProviderSel.value] || "");
});
}
const aiDefaultModelSel = document.getElementById("cfg-ai-default-model");
if (aiDefaultModelSel) {
aiDefaultModelSel.addEventListener("change", () => {
const providerSel = document.getElementById("cfg-ai-default-provider");
_renderConfigModelCaps(providerSel ? providerSel.value : null, aiDefaultModelSel.value);
});
}
// Load existing keys/defaults on modal open
document.getElementById("config-open-btn").addEventListener("click", loadAIKeys);
document.getElementById("config-open-btn").addEventListener("click", loadAIDefaults);
// Profile menu button: open config modal and scroll to profile section
var profileBtn = document.getElementById("profile-open-btn");
if (profileBtn) {
profileBtn.addEventListener("click", function() {
var modal = document.getElementById("config-modal");
if (modal) modal.classList.add("active");
setTimeout(function() {
var el = document.getElementById("cfg-profile");
if (el) el.scrollIntoView({ behavior: 'smooth' });
}, 300);
});
}
// Logout button — wired here with addEventListener (#87, no inline onclick)
// Config nav search
var cfgSearch = document.getElementById("config-nav-search");
if (cfgSearch) {
cfgSearch.addEventListener("input", function() {
var q = cfgSearch.value.toLowerCase();
document.querySelectorAll("#config-scroll .config-section").forEach(function(s) {
var text = s.textContent.toLowerCase();
s.style.display = (q && !text.includes(q)) ? "none" : "";
});
document.querySelectorAll("#config-nav .help-nav-link").forEach(function(a) {
var li = a.closest("li");
if (li) li.style.display = (q && !a.textContent.toLowerCase().includes(q)) ? "none" : "";
});
});
}
// Config search clear button (#87, ex-onclick inline).
var cfgSearchClear = document.getElementById("config-search-clear");
if (cfgSearchClear && cfgSearch) {
cfgSearchClear.addEventListener("click", function() {
cfgSearch.value = "";
cfgSearch.dispatchEvent(new Event("input"));
cfgSearch.focus();
});
}
// BUG-071/#114: mobile table of contents. #config-nav shares the .help-nav
// rule that hides it below 768px, but — unlike the help modal — the config
// modal had no toggle to reveal it, leaving mobile users with no way to
// reach a section. The header hamburger opens it as a left slide-over
// drawer (backdrop via .config-toc-open on the modal); picking a section
// smooth-scrolls inside the modal and collapses it on mobile.
var configNav = document.getElementById("config-nav");
var configHamburger = document.getElementById("config-hamburger");
function _isConfigMobile() { return window.innerWidth <= 768; }
function _setConfigNav(open) {
if (!configNav) return;
configNav.style.display = open ? "flex" : "none";
modal.classList.toggle("config-toc-open", !!open && _isConfigMobile());
if (configHamburger) configHamburger.classList.toggle("active", !!open);
}
if (configHamburger) {
configHamburger.addEventListener("click", function(e) {
e.stopPropagation();
var hidden = !configNav || configNav.style.display === "none" || configNav.style.display === "";
_setConfigNav(hidden);
});
}
// #114: close button inside the drawer header.
var configTocClose = document.getElementById("config-toc-close");
if (configTocClose) {
configTocClose.addEventListener("click", function(e) {
e.stopPropagation();
_setConfigNav(false);
});
}
if (configNav) {
configNav.querySelectorAll(".help-nav-link").forEach(function(a) {
a.addEventListener("click", function(e) {
var hash = a.getAttribute("href");
if (!hash || hash.charAt(0) !== "#") return;
var target = document.getElementById(hash.slice(1));
if (!target) return;
e.preventDefault();
configNav.querySelectorAll(".help-nav-link").forEach(function(o) { o.classList.remove("active"); });
a.classList.add("active");
if (typeof target.scrollIntoView === "function") {
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
if (_isConfigMobile()) _setConfigNav(false);
});
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
// #114: Escape closes the TOC drawer first, then the modal.
if (modal.classList.contains("config-toc-open")) {
_setConfigNav(false);
return;
}
closeConfigModal();
}
});
// Load saved frontend config on startup
applyFrontendConfig();
// Init theme picker
initThemePicker();
// Init profile
initProfile();
// Init MFA settings (security tab)
initMfaSettings();
}
function closeConfigModal() {
const modal = document.getElementById("config-modal");
if (modal) {
modal.classList.remove("active");
// #114: never leave the drawer-open class (backdrop) behind.
modal.classList.remove("config-toc-open");
const nav = document.getElementById("config-nav");
if (nav) nav.style.display = '';
}
}
// --- Config field helpers ---
const _FRONTEND_CONFIG_KEY = "obsigate-perf-config";
function _getFrontendConfig() {
try {
return JSON.parse(localStorage.getItem(_FRONTEND_CONFIG_KEY) || "{}");
} catch {
return {};
}
}
function applyFrontendConfig() {
const cfg = _getFrontendConfig();
if (cfg.debounce_ms) {
/* applied dynamically in debounce setTimeout */
}
if (cfg.results_per_page) {
/* used as ADVANCED_SEARCH_LIMIT override */
}
if (cfg.min_query_length) {
/* used as MIN_SEARCH_LENGTH override */
}
if (cfg.search_timeout_ms) {
/* used as SEARCH_TIMEOUT_MS override */
}
}
export function _getEffective(key, fallback) {
const cfg = _getFrontendConfig();
return cfg[key] !== undefined ? cfg[key] : fallback;
}
async function loadConfigFields() {
// Frontend fields from localStorage
const cfg = _getFrontendConfig();
_setField("cfg-debounce", cfg.debounce_ms || 300);
_setField("cfg-results-per-page", cfg.results_per_page || 50);
_setField("cfg-min-query", cfg.min_query_length || 2);
_setField("cfg-timeout", cfg.search_timeout_ms || 30000);
// Backend fields from API
try {
const data = await api("/api/config");
_setField("cfg-workers", data.search_workers);
_setField("cfg-max-content", data.max_content_size);
_setField("cfg-title-boost", data.title_boost);
_setField("cfg-tag-boost", data.tag_boost);
_setField("cfg-prefix-exp", data.prefix_max_expansions);
_setField("cfg-recent-limit", data.recent_files_limit || 20);
_setField("cfg-max-backups", data.max_backups_per_file || 10);
// Watcher config
_setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false);
_setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true);
_setField("cfg-watcher-interval", data.watcher_polling_interval || 5);
_setField("cfg-watcher-debounce", data.watcher_debounce || 2);
} catch (err) {
console.error("Failed to load backend config:", err);
}
}
function _setField(id, value) {
const el = document.getElementById(id);
if (el && value !== undefined) el.value = value;
}
function _setCheckbox(id, checked) {
const el = document.getElementById(id);
if (el) el.checked = !!checked;
}
function _getCheckbox(id) {
const el = document.getElementById(id);
return el ? el.checked : false;
}
function _getFieldNum(id, fallback) {
const el = document.getElementById(id);
if (!el) return fallback;
const v = parseFloat(el.value);
return isNaN(v) ? fallback : v;
}
function saveFrontendConfig() {
const cfg = {
debounce_ms: _getFieldNum("cfg-debounce", 300),
results_per_page: _getFieldNum("cfg-results-per-page", 50),
min_query_length: _getFieldNum("cfg-min-query", 2),
search_timeout_ms: _getFieldNum("cfg-timeout", 30000),
};
localStorage.setItem(_FRONTEND_CONFIG_KEY, JSON.stringify(cfg));
showToast(t("toast.config_client_saved"), "success");
}
async function saveBackendConfig() {
const body = {
search_workers: _getFieldNum("cfg-workers", 2),
max_content_size: _getFieldNum("cfg-max-content", 100000),
title_boost: _getFieldNum("cfg-title-boost", 3.0),
tag_boost: _getFieldNum("cfg-tag-boost", 2.0),
prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50),
recent_files_limit: _getFieldNum("cfg-recent-limit", 20),
max_backups_per_file: _getFieldNum("cfg-max-backups", 10),
watcher_enabled: _getCheckbox("cfg-watcher-enabled"),
watcher_use_polling: _getCheckbox("cfg-watcher-polling"),
watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0),
watcher_debounce: _getFieldNum("cfg-watcher-debounce", 2.0),
};
try {
const res = await fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
showToast(t("toast.config_saved"), "success");
} else {
const errorData = await res.json().catch(() => ({}));
showToast(errorData.detail || "Erreur de sauvegarde", "error");
}
} catch (err) {
console.error("Failed to save backend config:", err);
showToast(t("config.save_error"), "error");
}
}
async function forceReindex() {
const btn = document.getElementById("cfg-reindex");
if (btn) {
btn.disabled = true;
btn.textContent = t('config.reindexing');
}
try {
await api("/api/index/reload");
showToast(t("toast.reindex_done"), "success");
loadDiagnostics();
await Promise.all([loadVaults(), loadTags()]);
} catch (err) {
console.error("Reindex error:", err);
showToast(t("config.reindex_error"), "error");
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = t('config.force_reindex');
}
}
}
async function resetConfigDefaults() {
// Reset frontend
localStorage.removeItem(_FRONTEND_CONFIG_KEY);
// Reset backend
try {
await fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
search_workers: 2,
debounce_ms: 300,
results_per_page: 50,
min_query_length: 2,
search_timeout_ms: 30000,
max_content_size: 100000,
title_boost: 3.0,
path_boost: 1.5,
tag_boost: 2.0,
prefix_max_expansions: 50,
snippet_context_chars: 120,
max_snippet_highlights: 5,
}),
});
} catch (err) {
console.error("Reset config error:", err);
}
loadConfigFields();
showToast(t("toast.config_reset"), "success");
}
async function loadDiagnostics() {
const container = document.getElementById("config-diagnostics");
if (!container) return;
container.innerHTML = '<div class="config-diag-loading">Chargement...</div>';
try {
const data = await api("/api/diagnostics");
renderDiagnostics(container, data);
} catch (err) {
container.innerHTML = '<div class="config-diag-loading">Erreur de chargement</div>';
}
}
function renderDiagnostics(container, data) {
container.innerHTML = "";
const sections = [
{
title: "Index",
rows: [
[t("about.files_indexed"), data.index.total_files],
[t("dashboard.stats_tags"), data.index.total_tags],
[t("dashboard.stats_vaults"), Object.keys(data.index.vaults).join(", ")],
],
},
{
title: "Index inversé",
rows: [
["Tokens uniques", data.inverted_index.unique_tokens.toLocaleString()],
["Postings total", data.inverted_index.total_postings.toLocaleString()],
["Documents", data.inverted_index.documents],
["Mémoire estimée", data.inverted_index.memory_estimate_mb + " MB"],
["Index prêt", data.inverted_index.is_ready ? "Oui" : "Non"],
],
},
{
title: "Moteur de recherche",
rows: [
["Executor actif", data.search_executor.active ? "Oui" : "Non"],
["Workers max", data.search_executor.max_workers],
],
},
];
sections.forEach((section) => {
const div = document.createElement("div");
div.className = "config-diag-section";
const title = document.createElement("div");
title.className = "config-diag-section-title";
title.textContent = section.title;
div.appendChild(title);
section.rows.forEach(([label, value]) => {
const row = document.createElement("div");
row.className = "config-diag-row";
row.innerHTML = `<span class="diag-label">${label}</span><span class="diag-value">${value}</span>`;
div.appendChild(row);
});
container.appendChild(div);
});
}
// --- About Section ---
function loadAbout() {
const container = document.getElementById("config-about");
if (!container) return;
// Fetch health info for version
api("/api/health").then((health) => {
container.innerHTML = "";
const sections = [
{
title: t("about.section_app"),
rows: [
[t("about.name"), "ObsiGate"],
[t("about.version"), health.version || "—"],
[t("about.status"), health.status || "—"],
],
},
{
title: t("about.section_env"),
rows: [
[t("about.vaults_configured"), health.vaults || "—"],
[t("about.files_indexed"), health.total_files || "—"],
[t("about.browser"), navigator.userAgent.split(" ").pop()],
[t("about.platform"), navigator.platform || "—"],
[t("about.language"), navigator.language || "—"],
],
},
{
title: t("about.section_components"),
rows: [
[t("about.backend"), "FastAPI (Python)"],
[t("about.markdown_renderer"), "mistune"],
[t("about.file_watcher"), "watchdog"],
[t("about.frontend"), "Vanilla JavaScript"],
[t("about.icons"), "Lucide Icons"],
[t("about.syntax_highlight"), "highlight.js"],
[t("about.editor"), "CodeMirror 6"],
],
},
];
sections.forEach((section) => {
const div = document.createElement("div");
div.className = "config-diag-section";
const title = document.createElement("div");
title.className = "config-diag-section-title";
title.textContent = section.title;
div.appendChild(title);
section.rows.forEach(([label, value]) => {
const row = document.createElement("div");
row.className = "config-diag-row";
row.innerHTML = `<span class="diag-label">${label}</span><span class="diag-value">${value}</span>`;
div.appendChild(row);
});
container.appendChild(div);
});
}).catch(() => {
container.innerHTML = '<div class="config-diag-loading">Erreur de chargement</div>';
});
}
// --- Hidden Files Configuration ---
async function loadHiddenFilesSettings() {
const container = document.getElementById("hidden-files-vault-list");
if (!container) return;
container.innerHTML = '<div style="padding:12px;color:var(--text-muted)">Chargement...</div>';
try {
const settings = await api("/api/vaults/settings/all");
renderHiddenFilesSettings(container, settings);
} catch (err) {
console.error("Failed to load hidden files settings:", err);
container.innerHTML = '<div style="padding:12px;color:var(--error)">Erreur de chargement</div>';
}
}
function renderHiddenFilesSettings(container, allSettings) {
container.innerHTML = "";
if (!state.allVaults || state.allVaults.length === 0) {
container.innerHTML = '<div style="padding:12px;color:var(--text-muted)">Aucun vault configuré</div>';
return;
}
state.allVaults.forEach((vault) => {
const settings = allSettings[vault.name] || { hideHiddenFiles: false };
const vaultCard = el("div", { class: "hidden-files-vault-card", "data-vault": vault.name });
// Vault header
const header = el("div", { class: "hidden-files-vault-header" }, [el("h3", {}, [document.createTextNode(vault.name)]), el("span", { class: "hidden-files-vault-type" }, [document.createTextNode(vault.type || "VAULT")])]);
// Hide hidden files toggle
const toggleRow = el("div", { class: "config-row" }, [
el("label", { class: "config-label", for: `hide-hidden-${vault.name}` }, [document.createTextNode("Masquer les fichiers/dossiers cachés")]),
el("label", { class: "config-toggle" }, [
el("input", {
type: "checkbox",
id: `hide-hidden-${vault.name}`,
"data-vault": vault.name,
checked: settings.hideHiddenFiles ? "true" : false,
}),
el("span", { class: "config-toggle-slider" }),
]),
el("span", { class: "config-hint" }, [document.createTextNode("Masquer les fichiers/dossiers commençant par un point dans l'interface (ils restent indexés et cherchables)")]),
]);
vaultCard.appendChild(header);
vaultCard.appendChild(toggleRow);
container.appendChild(vaultCard);
});
}
async function saveHiddenFilesSettings() {
const btn = document.getElementById("cfg-save-hidden-files");
if (btn) {
btn.disabled = true;
btn.textContent = t("config.saving") + "...";
}
try {
const vaultCards = document.querySelectorAll(".hidden-files-vault-card");
const promises = [];
vaultCards.forEach((card) => {
const vaultName = card.dataset.vault;
const hideHiddenFiles = document.getElementById(`hide-hidden-${vaultName}`)?.checked || false;
const settings = {
hideHiddenFiles,
};
promises.push(
api(`/api/vaults/${encodeURIComponent(vaultName)}/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
}),
);
});
await Promise.all(promises);
// Reload vault settings to update the cache
await loadVaultSettings();
showToast(t("config.settings_saved"), "success");
// Refresh the UI to apply the filter
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
} catch (err) {
console.error("Failed to save hidden files settings:", err);
const errorMsg = err.message || t("config.error_unknown");
showToast(t("config.error_prefix") + ": " + errorMsg, "error");
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = "💾 " + t("config.save");
}
}
}
// ── Webhooks UI ──
async function loadWebhooksUI() {
const list = document.getElementById("webhooks-list");
if (!list) return;
try {
const webhooks = await api("/api/webhooks");
renderWebhooksUI(webhooks);
} catch { list.innerHTML = '<div class="config-description">Admin uniquement</div>'; }
}
function renderWebhooksUI(webhooks) {
const list = document.getElementById("webhooks-list");
if (!list) return;
if (!webhooks.length) { list.innerHTML = '<div class="config-description">Aucun webhook configuré.</div>'; return; }
list.innerHTML = webhooks.map(w => `
<div class="webhook-item">
<span class="webhook-name">${escapeHtml(w.name)}</span>
<span class="webhook-url">${escapeHtml(w.url)}</span>
<span class="webhook-events">${(w.events||[]).join(", ")}</span>
<button class="webhook-delete" data-id="${w.id}">✕</button>
</div>
`).join("");
list.querySelectorAll(".webhook-delete").forEach(b => b.addEventListener("click", async () => {
await api(`/api/webhooks/${b.dataset.id}`, { method: "DELETE" });
loadWebhooksUI();
}));
}
document.addEventListener("click", function(e) {
if (e.target.id === "webhook-add-btn") {
const name = document.getElementById("webhook-name-input").value.trim();
const url = document.getElementById("webhook-url-input").value.trim();
if (!url) { showToast(t("config.url_required"), "error"); return; }
api("/api/webhooks", { method: "POST", body: JSON.stringify({ name: name || "Webhook", url, events: ["file_created","file_deleted","file_modified","file_renamed"] }) }).then(() => { loadWebhooksUI(); document.getElementById("webhook-name-input").value = ""; document.getElementById("webhook-url-input").value = ""; }).catch(err => showToast(err.message, "error"));
}
});
// ── API / MCP tokens UI (#107) ──
let _tokensBound = false;
async function loadTokensUI() {
const list = document.getElementById("tokens-list");
if (!list) return;
try {
const data = await api("/api/auth/tokens");
renderTokensUI(data.tokens || []);
bindTokenEvents();
} catch (err) {
list.innerHTML = '<div class="config-description">' + escapeHtml(t("config.error_prefix") + ": " + (err.message || "")) + "</div>";
}
}
function _formatTokenDate(unixSec) {
if (!unixSec) return "";
return new Date(unixSec * 1000).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
}
function _tokenExpiryLabel(tok) {
if (!tok.expires_at) return t("config.token_expiry_never");
const map = { "1d": "config.token_expiry_1d", "30d": "config.token_expiry_30d", "180d": "config.token_expiry_180d", "365d": "config.token_expiry_365d" };
return map[tok.expiry_key] ? t(map[tok.expiry_key]) : _formatTokenDate(tok.expires_at);
}
function renderTokensUI(tokens) {
const list = document.getElementById("tokens-list");
if (!list) return;
if (!tokens.length) {
list.innerHTML = '<div class="config-description">' + escapeHtml(t("config.tokens_empty")) + "</div>";
return;
}
list.innerHTML = tokens.map(tok => {
const expired = tok.expired || (tok.expires_at && tok.expires_at * 1000 < Date.now());
const status = expired
? '<span class="token-badge token-badge-expired">' + escapeHtml(t("config.token_status_expired")) + "</span>"
: '<span class="token-badge token-badge-active">' + escapeHtml(t("config.token_status_active")) + "</span>";
const meta = [
t("config.token_created") + " " + _formatTokenDate(tok.created_at),
t("config.token_expires") + " " + _tokenExpiryLabel(tok),
tok.last_used_at ? t("config.token_last_used") + " " + _formatTokenDate(tok.last_used_at) : t("config.token_never_used")
].join(" · ");
return '<div class="token-item" data-jti="' + escapeHtml(tok.jti) + '">' +
'<span class="token-name">' + escapeHtml(tok.name) + "</span>" +
status +
'<span class="token-meta">' + escapeHtml(meta) + "</span>" +
'<button class="token-delete" data-jti="' + escapeHtml(tok.jti) + '" data-name="' + escapeHtml(tok.name) + '" title="' + escapeHtml(t("config.token_revoke")) + '">✕</button>' +
"</div>";
}).join("");
list.querySelectorAll(".token-delete").forEach(btn => btn.addEventListener("click", async () => {
const name = btn.dataset.name;
if (!confirm(t("config.token_revoke_confirm") + " \"" + name + "\" ?")) return;
try {
await api("/api/auth/tokens/" + btn.dataset.jti, { method: "DELETE" });
showToast(t("config.token_revoked_toast"), "success");
loadTokensUI();
} catch (err) {
showToast(err.message || t("config.error_unknown"), "error");
}
}));
}
function bindTokenEvents() {
if (_tokensBound) return;
_tokensBound = true;
const createBtn = document.getElementById("token-create-btn");
if (!createBtn) return;
createBtn.addEventListener("click", async () => {
const name = document.getElementById("token-name-input").value.trim();
const expiry = document.getElementById("token-expiry-select").value;
if (!name) { showToast(t("config.token_name_required"), "error"); return; }
createBtn.disabled = true;
try {
const res = await api("/api/auth/tokens", { method: "POST", body: JSON.stringify({ name, expiry }) });
const area = document.getElementById("token-secret-area");
const ta = document.getElementById("token-secret-value");
ta.value = res.token;
area.classList.remove("hidden");
ta.select();
document.getElementById("token-name-input").value = "";
showToast(t("config.token_created_toast"), "success");
loadTokensUI();
} catch (err) {
showToast(err.message || t("config.error_unknown"), "error");
} finally {
createBtn.disabled = false;
}
});
const copyBtn = document.getElementById("token-copy-btn");
if (copyBtn) copyBtn.addEventListener("click", async () => {
const ta = document.getElementById("token-secret-value");
try { await navigator.clipboard.writeText(ta.value); }
catch { ta.select(); document.execCommand("copy"); }
showToast(t("config.token_copied"), "success");
});
const dismissBtn = document.getElementById("token-dismiss-btn");
if (dismissBtn) dismissBtn.addEventListener("click", () => {
document.getElementById("token-secret-area").classList.add("hidden");
document.getElementById("token-secret-value").value = "";
});
}
// ── Shares UI ──
async function loadSharesUI() {
const list = document.getElementById("shares-list");
if (!list) return;
try {
const shares = await api("/api/shares");
renderSharesUI(shares);
} catch { list.innerHTML = '<div class="config-description">Chargement...</div>'; }
}
function renderSharesUI(shares) {
const list = document.getElementById("shares-list");
if (!list) return;
if (!shares.length) { list.innerHTML = '<div class="config-description">Aucun partage actif.</div>'; return; }
list.innerHTML = shares.map(s => `
<div class="share-item">
<span class="share-path">${escapeHtml(s.vault)}/${escapeHtml(s.path)}</span>
<span class="share-url"><a href="${s.url}" target="_blank">${s.url}</a></span>
<span class="share-meta">${s.access_count} vue(s)${s.expires_at ? ' · Expire' : ''}</span>
<button class="share-revoke" data-id="${s.id}">Révoquer</button>
</div>
`).join("");
list.querySelectorAll(".share-revoke").forEach(b => b.addEventListener("click", async () => {
await api(`/api/share/${b.dataset.id}`, { method: "DELETE" });
loadSharesUI();
}));
}
// ── Share Dialog (professional) ──
export async function openShareDialog(vault, path) {
// First check if already shared
let existingShare = null;
try {
const shares = await api("/api/shares");
existingShare = shares.find(s => s.vault === vault && s.path === path);
} catch (e) { /* ignore */ }
const div = document.createElement("div");
div.className = "share-dialog-overlay";
const renderContent = () => {
if (existingShare) {
const url = window.location.origin + existingShare.url;
const expiresInfo = existingShare.expires_at
? `<p style="font-size:0.75rem;color:var(--text-muted)">Expire le ${new Date(existingShare.expires_at).toLocaleDateString("fr-FR")}</p>`
: '<p style="font-size:0.75rem;color:var(--text-muted)">Sans expiration</p>';
div.innerHTML = `
<div class="share-dialog">
<h3>📤 Document partagé</h3>
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:4px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
${expiresInfo}
<p style="font-size:0.75rem;color:var(--text-muted);margin-bottom:8px">${existingShare.access_count} vue(s)</p>
<input type="text" class="share-url-input" value="${url}" readonly>
<div class="share-dialog-actions">
<button class="share-copy-btn">📋 Copier le lien</button>
<button class="share-revoke-btn">🗑 Révoquer</button>
<button class="share-close-btn">Fermer</button>
</div>
</div>`;
const shareUrlInput = div.querySelector(".share-url-input");
if (shareUrlInput) shareUrlInput.addEventListener("click", function() { shareUrlInput.select(); });
div.querySelector(".share-copy-btn").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(url);
} catch (e) {
// Fallback for non-HTTPS contexts
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(t("config.link_copied"), "success");
div.remove();
});
div.querySelector(".share-revoke-btn").addEventListener("click", async () => {
try {
await api(`/api/share/${existingShare.id}`, { method: "DELETE" });
showToast(t("config.share_revoked"), "success");
existingShare = null;
renderContent();
} catch (err) { showToast("Erreur: " + err.message, "error"); }
});
} else {
div.innerHTML = `
<div class="share-dialog">
<h3>📤 Partager ce document</h3>
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:12px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
<p style="font-size:0.8rem;margin-bottom:8px">Ce lien sera accessible <strong>publiquement, sans authentification</strong>.</p>
<label style="font-size:0.8rem;display:flex;align-items:center;gap:8px;margin-bottom:12px">
Expiration :
<select id="share-expiry" style="padding:4px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem">
<option value="">Jamais</option>
<option value="1">1 heure</option>
<option value="24">24 heures</option>
<option value="168">7 jours</option>
<option value="720">30 jours</option>
</select>
</label>
<div class="share-dialog-actions">
<button class="share-create-btn">🔗 Créer le lien</button>
<button class="share-close-btn">Fermer</button>
</div>
</div>`;
div.querySelector(".share-create-btn").addEventListener("click", async () => {
try {
const expiry = document.getElementById("share-expiry")?.value;
const share = await api(`/api/share/${encodeURIComponent(vault)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null }),
});
existingShare = share;
renderContent();
showToast(t("config.link_created"), "success");
} catch (err) { showToast("Erreur: " + err.message, "error"); }
});
}
div.querySelector(".share-close-btn").addEventListener("click", () => div.remove());
div.addEventListener("click", (e) => { if (e.target === div) div.remove(); });
};
renderContent();
document.body.appendChild(div);
}
function renderConfigFilters() {
const config = TagFilterService.getConfig();
const filters = config.tagFilters || TagFilterService.defaultFilters;
const container = document.getElementById("config-filters-list");
container.innerHTML = "";
filters.forEach((filter, index) => {
const badge = el("div", { class: `config-filter-badge ${!filter.enabled ? "disabled" : ""}` }, [
el("span", {}, [document.createTextNode(filter.pattern)]),
el(
"button",
{
class: "config-filter-toggle",
title: filter.enabled ? "Désactiver" : "Activer",
type: "button",
},
[document.createTextNode(filter.enabled ? "✓" : "○")],
),
el(
"button",
{
class: "config-filter-remove",
title: "Supprimer",
type: "button",
},
[document.createTextNode("×")],
),
]);
const toggleBtn = badge.querySelector(".config-filter-toggle");
const removeBtn = badge.querySelector(".config-filter-remove");
toggleBtn.addEventListener("click", (e) => {
e.stopPropagation();
toggleConfigFilter(index);
});
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
removeConfigFilter(index);
});
container.appendChild(badge);
});
}
function toggleConfigFilter(index) {
const config = TagFilterService.getConfig();
const filters = config.tagFilters || TagFilterService.defaultFilters;
if (filters[index]) {
filters[index].enabled = !filters[index].enabled;
config.tagFilters = filters;
TagFilterService.saveConfig(config);
renderConfigFilters();
refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err));
}
}
function removeConfigFilter(index) {
const config = TagFilterService.getConfig();
let filters = config.tagFilters || TagFilterService.defaultFilters;
filters = filters.filter((_, i) => i !== index);
config.tagFilters = filters;
TagFilterService.saveConfig(config);
renderConfigFilters();
refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err));
}
function addConfigFilter() {
const input = document.getElementById("config-pattern-input");
const pattern = input.value.trim();
if (!pattern) return;
const regex = TagFilterService.patternToRegex(pattern);
const config = TagFilterService.getConfig();
const filters = config.tagFilters || TagFilterService.defaultFilters;
const newFilter = { pattern, regex, enabled: true };
filters.push(newFilter);
config.tagFilters = filters;
TagFilterService.saveConfig(config);
input.value = "";
renderConfigFilters();
refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err));
updateRegexPreview();
}
function updateRegexPreview() {
const input = document.getElementById("config-pattern-input");
const preview = document.getElementById("config-regex-preview");
const code = document.getElementById("config-regex-code");
const pattern = input.value.trim();
if (pattern) {
const regex = TagFilterService.patternToRegex(pattern);
code.textContent = `^${regex}$`;
preview.style.display = "block";
} else {
preview.style.display = "none";
}
}
// ── AI Keys management (#104 — accordion redesign) ──
const AI_KEY_MAP = {
"cfg-deepseek-key": "DEEPSEEK_API_KEY",
"cfg-openrouter-key": "OPENROUTER_API_KEY",
"cfg-gemini-key": "GEMINI_API_KEY",
"cfg-nvidia-key": "NVIDIA_API_KEY",
"cfg-qwencloud-key": "QWENCLOUD_API_KEY",
"cfg-xiaomi-key": "XIAOMI_API_KEY",
"cfg-mistral-key": "MISTRAL_API_KEY",
};
const AI_PROVIDER_NAMES = ["deepseek","openrouter","gemini","nvidia","qwencloud","xiaomi","mistral"];
// Display metadata for the provider accordion cards (#104).
const AI_PROVIDER_META = {
deepseek: { name: "DeepSeek", placeholder: "sk-..." },
openrouter: { name: "OpenRouter", placeholder: "sk-or-..." },
gemini: { name: "Gemini", placeholder: "AIza..." },
nvidia: { name: "NVIDIA", placeholder: "nvapi-..." },
qwencloud: { name: "QwenCloud", placeholder: "sk-..." },
xiaomi: { name: "Xiaomi", placeholder: "xm-..." },
mistral: { name: "Mistral", placeholder: "sk-..." },
};
function _renderAIProviderCards() {
const host = document.getElementById("cfg-ai-providers");
if (!host) return;
host.innerHTML = "";
for (const p of AI_PROVIDER_NAMES) {
const meta = AI_PROVIDER_META[p] || { name: p, placeholder: "sk-..." };
const card = el("div", { class: "ai-provider-card", "data-provider": p });
// Header row (div + role=button so the per-provider delete button can
// live inside without nesting two interactive elements).
const head = el("div", {
class: "ai-provider-head",
role: "button",
tabindex: "0",
"aria-expanded": "false",
});
head.appendChild(el("span", { class: "ai-provider-logo", "aria-hidden": "true" }, [document.createTextNode(meta.name.charAt(0))]));
head.appendChild(el("span", { class: "ai-provider-name" }, [document.createTextNode(meta.name)]));
head.appendChild(el("span", { class: "ai-provider-badge", id: `cfg-${p}-badge` }));
const delBtn = el("button", {
type: "button",
class: "ai-provider-delete",
id: `cfg-${p}-delete`,
title: t("config.ai_delete_key_title"),
});
delBtn.style.display = "none";
delBtn.appendChild(icon("trash-2", 14));
delBtn.addEventListener("click", (e) => {
e.stopPropagation();
deleteAIKey(`cfg-${p}-key`);
});
head.appendChild(delBtn);
const chevron = icon("chevron-down", 16);
chevron.classList.add("ai-provider-chevron");
head.appendChild(chevron);
const toggle = () => _toggleAICard(card);
head.addEventListener("click", toggle);
head.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); }
});
// Collapsible body: API key (60%) + model (40%), labels above inputs.
const body = el("div", { class: "ai-provider-body hidden" });
const fields = el("div", { class: "ai-provider-fields" });
const keyField = el("div", { class: "ai-field ai-field-key" });
keyField.appendChild(el("label", { class: "ai-field-label", for: `cfg-${p}-key` }, [document.createTextNode(`${meta.name} API Key`)]));
const keyInput = document.createElement("input");
keyInput.type = "password";
keyInput.id = `cfg-${p}-key`;
keyInput.className = "config-input";
keyInput.placeholder = meta.placeholder;
keyInput.autocomplete = "off";
keyField.appendChild(keyInput);
const modelField = el("div", { class: "ai-field ai-field-model" });
modelField.appendChild(el("label", { class: "ai-field-label", for: `cfg-${p}-model` }, [document.createTextNode(t("config.ai_model"))]));
const modelSel = document.createElement("select");
modelSel.id = `cfg-${p}-model`;
modelSel.className = "config-select";
modelSel.innerHTML = '<option value="">-- Modele --</option>';
modelField.appendChild(modelSel);
fields.appendChild(keyField);
fields.appendChild(modelField);
body.appendChild(fields);
card.appendChild(head);
card.appendChild(body);
host.appendChild(card);
}
safeCreateIcons();
}
function _toggleAICard(card) {
const open = card.classList.toggle("open");
const body = card.querySelector(".ai-provider-body");
if (body) body.classList.toggle("hidden", !open);
const head = card.querySelector(".ai-provider-head");
if (head) head.setAttribute("aria-expanded", open ? "true" : "false");
}
/** Filter the provider accordion cards by the section search input (#104). */
export function filterAIProviders(query) {
const host = document.getElementById("cfg-ai-providers");
const emptyMsg = document.getElementById("cfg-ai-providers-empty");
if (!host) return;
const q = _sidebarNorm(query);
let visible = 0;
host.querySelectorAll(".ai-provider-card").forEach((card) => {
const p = card.dataset.provider;
const meta = AI_PROVIDER_META[p] || { name: p };
const match = !q || _sidebarNorm(meta.name).includes(q) || p.includes(q);
card.style.display = match ? "" : "none";
if (match) visible++;
});
if (emptyMsg) emptyMsg.classList.toggle("hidden", visible > 0);
}
function _setAIKeyBadge(inputId, hasKey) {
const provider = inputId.replace("-key", "");
const badge = document.getElementById(`${provider}-badge`);
const delBtn = document.getElementById(`${provider}-delete`);
if (badge) {
badge.textContent = hasKey
? "\u2713 " + t("config.ai_status_configured")
: t("config.ai_status_not_configured");
badge.classList.toggle("configured", !!hasKey);
}
if (delBtn) delBtn.style.display = hasKey ? "inline-flex" : "none";
}
async function loadAIKeys() {
_renderAIProviderCards();
try {
const data = await api("/api/config/ai-keys");
for (const [inputId, envName] of Object.entries(AI_KEY_MAP)) {
const input = document.getElementById(inputId);
const val = data[envName] || "";
if (input) {
const meta = AI_PROVIDER_META[inputId.replace("-key", "")] || {};
input.placeholder = val || meta.placeholder || "sk-...";
}
_setAIKeyBadge(inputId, !!val);
}
} catch(e) {}
}
async function loadAIDefaults() {
const providerSel = document.getElementById("cfg-ai-default-provider");
if (!providerSel) return;
if (!providerSel.dataset.populated) {
providerSel.innerHTML = '<option value="">--</option>';
AI_PROVIDER_NAMES.forEach(function(p) {
providerSel.innerHTML += '<option value="' + p + '">' + p + '</option>';
});
providerSel.dataset.populated = "1";
}
let cfg = {};
try { cfg = await api("/api/config"); } catch(e) {}
const provider = cfg.ai_default_provider || "deepseek";
providerSel.value = provider;
const models = cfg.ai_default_models || {};
await _populateAIModelOptions(provider, models[provider] || "");
}
async function _populateAIModelOptions(provider, selected) {
const modelSel = document.getElementById("cfg-ai-default-model");
if (!modelSel) return;
modelSel.innerHTML = '<option value="">--</option>';
if (!provider) {
_renderConfigModelCaps(null, null);
return;
}
try {
const m = await api("/api/config/ai-models?provider=" + encodeURIComponent(provider));
const list = (m && m.models) || [];
list.forEach(function(md) {
modelSel.innerHTML += '<option value="' + md + '">' + md + '</option>';
});
if (selected && list.indexOf(selected) === -1) {
modelSel.innerHTML += '<option value="' + selected + '">' + selected + '</option>';
}
modelSel.value = selected || "";
_renderConfigModelCaps(provider, modelSel.value);
} catch(e) { console.warn("Failed to load default models for", provider, e); }
}
async function _renderConfigModelCaps(provider, model) {
const host = document.getElementById("cfg-ai-default-model-caps");
if (!host) return;
host.innerHTML = "";
if (!provider || !model) return;
const caps = await getModelCapabilities(provider, model);
if (!caps) return;
host.appendChild(renderCapabilityBadges(caps));
}
async function saveAIKeys() {
const keys = {};
for (const [id, name] of Object.entries(AI_KEY_MAP)) {
const input = document.getElementById(id);
if (input && input.value.trim()) keys[name] = input.value.trim();
}
// Collect default provider + per-provider default models
const providerSel = document.getElementById("cfg-ai-default-provider");
const modelSel = document.getElementById("cfg-ai-default-model");
const defaults = {};
const models = {};
for (const p of AI_PROVIDER_NAMES) {
const sel = document.getElementById("cfg-" + p + "-model");
if (sel && sel.value) models[p] = sel.value;
}
if (providerSel && providerSel.value) {
defaults.ai_default_provider = providerSel.value;
if (modelSel && modelSel.value) models[providerSel.value] = modelSel.value;
}
if (Object.keys(models).length) defaults.ai_default_models = models;
if (!Object.keys(keys).length && !Object.keys(defaults).length) {
showToast(t("config.no_keys"), "warning"); return;
}
try {
if (Object.keys(keys).length) {
await api("/api/config/ai-keys", { method: "POST", body: JSON.stringify(keys) });
}
if (Object.keys(defaults).length) {
await api("/api/config", { method: "POST", body: JSON.stringify(defaults) });
}
showToast(t("config.api_keys_saved"), "success");
Object.keys(AI_KEY_MAP).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
loadAIKeys();
loadAIDefaults();
// The assistant sidebar lists the providers it found when the picker was
// built: rebuild it so a newly configured provider shows up immediately.
refreshAIPickers();
} catch(e) { showToast("Erreur: " + e.message, "error"); }
}
async function deleteAIKey(inputId) {
const envName = AI_KEY_MAP[inputId];
if (!envName) return;
const provider = envName.replace("_API_KEY", "").replace(/_/g, " ");
if (!confirm("Supprimer la cl\u00e9 API " + provider + " ?\nLe fournisseur ne sera plus disponible.")) return;
try {
await api("/api/config/ai-keys/" + envName, { method: "DELETE" });
showToast("Cl\u00e9 " + provider + " supprim\u00e9e", "success");
loadAIKeys();
// Drop the removed provider from the assistant sidebar's provider list.
refreshAIPickers();
} catch(e) { showToast("Erreur: " + e.message, "error"); }
}
async function testAIKeys() {
const status = document.getElementById("cfg-ai-status");
if (status) status.textContent = "Test en cours...";
try {
const results = await api("/api/config/ai-keys/test", { method: "POST" });
var msg = Object.entries(results).map(([k,v]) => k + ": " + v).join(" | ");
if (status) status.textContent = msg;
for (const p of AI_PROVIDER_NAMES) {
const inputId = "cfg-" + p + "-key";
if (results[p] === "ok") {
_setAIKeyBadge(inputId, true);
} else if (results[p] && results[p].includes("non")) {
_setAIKeyBadge(inputId, false);
}
}
for (const p of AI_PROVIDER_NAMES) {
if (results[p] === "ok") {
try {
const m = await api("/api/config/ai-models?provider=" + p);
var sel = document.getElementById("cfg-" + p + "-model");
if (sel && m.models && m.models.length) {
sel.innerHTML = '<option value="">-- Modele (' + m.models.length + ') --</option>';
m.models.forEach(function(md) {
sel.innerHTML += '<option value="' + md + '">' + md + '</option>';
});
}
} catch(e) { console.warn("Failed to fetch models for", p, e); }
}
}
} catch(e) {
if (status) status.textContent = "Erreur: " + e.message;
}
}
// ── Tool & connected-source keys (#103) ──
const TOOL_KEY_MAP = {
"cfg-tavily-key": "OBSIGATE_TAVILY_API_KEY",
"cfg-brave-key": "OBSIGATE_BRAVE_API_KEY",
"cfg-serpapi-key": "OBSIGATE_SERPAPI_API_KEY",
"cfg-exa-key": "OBSIGATE_EXA_API_KEY",
"cfg-gitea-url": "OBSIGATE_GITEA_URL",
"cfg-gitea-token": "OBSIGATE_GITEA_TOKEN",
"cfg-github-token": "OBSIGATE_GITHUB_TOKEN",
};
function _ensureToolKeyUI() {
for (const [inputId] of Object.entries(TOOL_KEY_MAP)) {
const input = document.getElementById(inputId);
if (!input) continue;
const row = input.closest(".config-row");
if (!row || row.dataset.toolKeyEnhanced) continue;
row.dataset.toolKeyEnhanced = "1";
row.style.cssText += "display:flex;align-items:center;gap:8px;flex-wrap:wrap;";
const badge = document.createElement("span");
badge.id = inputId + "-badge";
badge.style.cssText = "font-size:11px;padding:2px 8px;border-radius:10px;white-space:nowrap;";
row.appendChild(badge);
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.id = inputId + "-delete";
delBtn.className = "config-btn-secondary";
delBtn.style.cssText = "font-size:11px;padding:4px 10px;color:var(--danger,#e74c3c);border-color:var(--danger,#e74c3c);cursor:pointer;display:none;";
delBtn.textContent = "\u00d7 " + t("config.delete_key");
delBtn.addEventListener("click", () => deleteToolKey(inputId));
row.appendChild(delBtn);
}
}
function _setToolKeyBadge(inputId, hasKey) {
const badge = document.getElementById(inputId + "-badge");
const delBtn = document.getElementById(inputId + "-delete");
if (badge) {
if (hasKey) {
badge.textContent = "\u2713 " + t("config.key_set");
badge.style.background = "var(--success-bg, #27ae6022)";
badge.style.color = "var(--success, #27ae60)";
badge.style.border = "1px solid var(--success, #27ae60)";
} else {
badge.textContent = t("config.key_unset");
badge.style.background = "var(--muted-bg, #ffffff10)";
badge.style.color = "var(--text-muted, #888)";
badge.style.border = "1px solid var(--border, #444)";
}
}
if (delBtn) delBtn.style.display = hasKey ? "inline-block" : "none";
}
async function loadToolKeys() {
_ensureToolKeyUI();
try {
const data = await api("/api/config/tool-keys");
for (const [inputId, name] of Object.entries(TOOL_KEY_MAP)) {
const input = document.getElementById(inputId);
const val = data[name] || "";
if (input && !input.value.trim()) input.placeholder = val || input.placeholder;
_setToolKeyBadge(inputId, !!val);
}
} catch(e) { /* non-admin: the section stays inert */ }
}
async function saveToolKeys() {
const keys = {};
for (const [id, name] of Object.entries(TOOL_KEY_MAP)) {
const input = document.getElementById(id);
if (input && input.value.trim()) keys[name] = input.value.trim();
}
if (!Object.keys(keys).length) {
showToast(t("config.no_keys"), "warning");
return;
}
try {
await api("/api/config/tool-keys", { method: "POST", body: JSON.stringify(keys) });
showToast(t("config.api_keys_saved"), "success");
Object.keys(TOOL_KEY_MAP).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
loadToolKeys();
} catch(e) { showToast("Erreur: " + e.message, "error"); }
}
async function deleteToolKey(inputId) {
const name = TOOL_KEY_MAP[inputId];
if (!name) return;
if (!confirm(t("config.delete_key_confirm") + " " + name + " ?")) return;
try {
await api("/api/config/tool-keys/" + name, { method: "DELETE" });
showToast(t("config.key_deleted") + " " + name, "success");
loadToolKeys();
} catch(e) { showToast("Erreur: " + e.message, "error"); }
}
export {
initSidebarTabs,
initConfigModal,
initConfigModal as initConfigPanel,
initHelpModal,
closeHelpModal,
initRecentTab,
initEditorPocBtn,
loadAbout as initAboutSection,
loadHiddenFilesSettings as initHiddenFilesConfig,
initThemePicker,
initAboutModal,
initProfile,
switchSidebarTab,
populateVersions,
loadAIKeys,
saveAIKeys,
};
// Populate every version display (header badge, About modal, help guide footer)
// from a single source of truth so they always match.
function populateVersions(version) {
var v = version || window.__OBSIGATE_VERSION || '—';
var els = [
document.getElementById('version-badge'),
document.getElementById('about-version'),
document.getElementById('help-version'),
];
for (var i = 0; i < els.length; i++) {
if (els[i]) els[i].textContent = v;
}
return v;
}
// ── Theme Picker ─────────────────────────────────────────────────────
function initThemePicker() {
import('./themes.js').then(function(mod) {
var THEMES = mod.THEMES;
var AVAILABLE_MODES = mod.AVAILABLE_MODES;
var applyTheme = mod.applyTheme;
var getCurrentTheme = mod.getCurrentTheme;
var getCurrentMode = mod.getCurrentMode;
var exportAllThemes = mod.exportAllThemes;
var importTheme = mod.importTheme;
var deleteCustomTheme = mod.deleteCustomTheme;
var loadCustomThemes = mod.loadCustomThemes;
var grid = document.getElementById('theme-grid');
if (!grid) return;
// Mode label map (uses i18n t() when available, fallback to English)
var modeLabels = {
'dark': function() { return t('theme.dark'); },
'light': function() { return t('theme.light'); },
'high-contrast': function() { return t('theme.high_contrast'); },
'sepia': function() { return t('theme.sepia'); }
};
function renderCards() {
var themeKeys = Object.keys(THEMES);
var current = getCurrentTheme();
var mode = getCurrentMode();
grid.innerHTML = '';
themeKeys.forEach(function(key) {
var th = THEMES[key];
// Use dark mode for preview if mode not resolved yet
var v = th.modes[mode] || th.modes.dark || th.modes.light;
if (!v) return;
var card = document.createElement('div');
card.className = 'theme-card' + (key === current ? ' active' : '');
var isCustom = th.desc === 'Custom theme';
card.innerHTML =
'<div class="theme-card-preview">' +
'<div class="theme-card-preview-bar" style="background:' + (v['--bg-sidebar'] || v['--bg-primary']) + '"></div>' +
'<div class="theme-card-preview-body" style="background:' + (v['--bg-primary'] || '#000') + '">' +
'<div class="theme-card-preview-btn" style="background:' + (v['--accent'] || '#58a6ff') + '"></div>' +
'</div>' +
'</div>' +
'<div class="theme-card-name">' + th.name + (isCustom ? ' <span class="theme-custom-badge">✦</span>' : '') + '</div>' +
'<div class="theme-card-desc">' + th.desc + '</div>';
card.addEventListener('click', function() {
applyTheme(key, mode);
renderCards();
});
// Right-click to delete custom themes
if (isCustom) {
card.addEventListener('contextmenu', function(e) {
e.preventDefault();
if (confirm(t('theme.delete_confirm'))) {
deleteCustomTheme(key);
renderCards();
}
});
}
grid.appendChild(card);
});
// Mode toggle — 4 modes
var toggle = document.createElement('div');
toggle.className = 'theme-mode-toggle';
AVAILABLE_MODES.forEach(function(m) {
var btn = document.createElement('button');
btn.className = 'theme-mode-btn' + (m === mode ? ' active' : '');
btn.textContent = (modeLabels[m] || function() { return m; })();
btn.addEventListener('click', function() {
applyTheme(getCurrentTheme(), m);
renderCards();
});
toggle.appendChild(btn);
});
grid.appendChild(toggle);
// Import/Export buttons
var actions = document.createElement('div');
actions.className = 'theme-actions';
var exportBtn = document.createElement('button');
exportBtn.className = 'theme-action-btn';
exportBtn.textContent = t('theme.export_all');
exportBtn.addEventListener('click', function() {
var json = exportAllThemes();
var blob = new Blob([json], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = 'obsigate-themes.json'; a.click();
URL.revokeObjectURL(url);
});
var importBtn = document.createElement('button');
importBtn.className = 'theme-action-btn';
importBtn.textContent = t('theme.import');
importBtn.addEventListener('click', function() {
var input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.addEventListener('change', function(e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(ev) {
var content = ev.target.result;
// Support single theme or all-themes format
try {
var data = JSON.parse(content);
if (data.themes) {
// Multi-theme export: import each
var count = 0;
Object.keys(data.themes).forEach(function(k) {
var res = importTheme(JSON.stringify(data.themes[k]));
if (res.ok) count++;
});
alert(t('theme.import_success', { count: count }));
} else {
var res = importTheme(content);
if (res.ok) {
alert(t('theme.import_success', { count: 1 }));
} else {
alert(t('theme.import_error') + ': ' + res.error);
}
}
} catch(ex) {
alert(t('theme.import_error') + ': ' + ex.message);
}
renderCards();
};
reader.readAsText(file);
});
input.click();
});
actions.appendChild(exportBtn);
actions.appendChild(importBtn);
grid.appendChild(actions);
}
renderCards();
});
}
// ── About Modal ──────────────────────────────────────────────────────
function initAboutModal() {
var overlay = document.getElementById('about-overlay');
var closeBtn = document.getElementById('about-close');
if (!overlay) return;
// Open button
var openBtn = document.getElementById('about-open-btn');
if (openBtn) {
openBtn.addEventListener('click', function() {
overlay.classList.add('active');
populateAbout();
});
}
// Close
closeBtn.addEventListener('click', function() { overlay.classList.remove('active'); });
overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.classList.remove('active'); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && overlay.classList.contains('active')) overlay.classList.remove('active'); });
function populateAbout() {
// Live stats + version from health endpoint (single source of truth).
// Fallback silencieux : si le fetch échoue, on garde la version pré-chargée
// (window.__OBSIGATE_VERSION) au lieu d'un "—" trompeur.
fetch('/api/health')
.then(function(r) { return r.json(); })
.then(function(d) {
var fEl = document.getElementById('about-stat-files');
var vEl = document.getElementById('about-stat-vaults');
if (fEl && d.total_files) fEl.textContent = d.total_files;
if (vEl && d.vaults) vEl.textContent = d.vaults;
// Single source of truth: populate the header badge, About and
// help-guide footer with the same version value.
if (d && (d.version || d.git_describe)) {
populateVersions((d && d.version) || window.__OBSIGATE_VERSION);
}
var commitEl = document.getElementById('about-commit');
if (commitEl) commitEl.textContent = (d && d.git_describe) || (d && d.git_commit) || '—';
})
.catch(function() {
populateVersions();
});
// Build date (today)
var now = new Date();
var months = [t('month.january'),t('month.february'),t('month.march'),t('month.april'),t('month.may'),t('month.june'),t('month.july'),t('month.august'),t('month.september'),t('month.october'),t('month.november'),t('month.december')];
var dateEl = document.getElementById('about-build-date');
if (dateEl) dateEl.textContent = months[now.getMonth()] + ' ' + now.getFullYear();
// Random joke
var jokes = [
t('about.joke_0'),
t('about.joke_1'),
t('about.joke_2'),
t('about.joke_3'),
t('about.joke_4'),
t('about.joke_5'),
t('about.joke_6'),
t('about.joke_7'),
t('about.joke_8'),
t('about.joke_9')
];
var jokeEl = document.getElementById('about-joke');
if (jokeEl) jokeEl.textContent = jokes[Math.floor(Math.random() * jokes.length)];
}
}
// ── Profile ──────────────────────────────────────────────────────────
// #113 — profile picture: pick/import an image, center-crop it to a 256 px
// square JPEG, persist it on the account (PATCH /api/auth/me) and show it in
// the sidebar circle (#sidebar-user-avatar). Initials remain the fallback.
const _AVATAR_MAX_FILE_BYTES = 8 * 1024 * 1024;
const _AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"];
const _AVATAR_SIZE = 256;
function _profileInitials(name) {
var parts = String(name || "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
var first = parts[0][0] || "";
var last = parts.length > 1 ? parts[parts.length - 1][0] : "";
return (first + last).toUpperCase() || "?";
}
/** Center-crop the picked image to a square and export it as a JPEG data-URL. */
function _resizeAvatarFile(file) {
return new Promise(function (resolve, reject) {
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () {
URL.revokeObjectURL(url);
try {
var canvas = document.createElement("canvas");
canvas.width = _AVATAR_SIZE;
canvas.height = _AVATAR_SIZE;
var ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, _AVATAR_SIZE, _AVATAR_SIZE);
var side = Math.min(img.naturalWidth, img.naturalHeight);
var sx = (img.naturalWidth - side) / 2;
var sy = (img.naturalHeight - side) / 2;
ctx.drawImage(img, sx, sy, side, side, 0, 0, _AVATAR_SIZE, _AVATAR_SIZE);
resolve(canvas.toDataURL("image/jpeg", 0.85));
} catch (err) {
reject(err);
}
};
img.onerror = function () {
URL.revokeObjectURL(url);
reject(new Error("image decode failed"));
};
img.src = url;
});
}
function _profileAvatarError(key) {
var err = document.getElementById("profile-avatar-error");
if (!err) return;
if (!key) {
err.textContent = "";
err.classList.add("hidden");
return;
}
err.textContent = t(key);
err.classList.remove("hidden");
}
/** Paint the settings preview (image or initials) for the given avatar. */
function _renderProfileAvatar(dataUrl) {
var img = document.getElementById("profile-avatar-img");
var initialsEl = document.getElementById("profile-avatar-initials");
var removeBtn = document.getElementById("profile-avatar-remove");
if (!img || !initialsEl) return;
if (dataUrl) {
img.src = dataUrl;
img.hidden = false;
initialsEl.textContent = "";
if (removeBtn) removeBtn.hidden = false;
} else {
img.removeAttribute("src");
img.hidden = true;
var user = AuthManager.getUser() || {};
var name = localStorage.getItem("obsigate-name") || user.display_name || user.username || "";
initialsEl.textContent = _profileInitials(name);
if (removeBtn) removeBtn.hidden = true;
}
}
/** PATCH /api/auth/me with an avatar value (data-URL, or "" to clear). */
async function _patchProfileAvatar(value) {
var headers = { "Content-Type": "application/json" };
var authHeaders = AuthManager.getAuthHeaders ? AuthManager.getAuthHeaders() : null;
if (authHeaders) headers = Object.assign(headers, authHeaders);
var res = await fetch("/api/auth/me", {
method: "PATCH",
headers: headers,
credentials: "include",
body: JSON.stringify({ avatar: value }),
});
if (!res.ok) {
var detail = "";
try {
detail = (await res.json()).detail || "";
} catch (e) { /* no json body */ }
throw new Error(detail || "HTTP " + res.status);
}
return res.json();
}
function initProfile() {
var langSel = document.getElementById('profile-lang');
var nameInp = document.getElementById('profile-name');
var saveBtn = document.getElementById('profile-save');
var logoutBtn = document.getElementById('profile-logout');
var savedEl = document.getElementById('profile-saved');
if (!langSel || !saveBtn) return;
// Load saved profile
try {
var lang = getLocale();
var name = localStorage.getItem('obsigate-name') || '';
if (langSel) langSel.value = lang;
if (nameInp) nameInp.value = name;
} catch(e) {}
saveBtn.addEventListener('click', async function() {
try {
var newLang = langSel.value;
await setLocale(newLang); // Live-reload all translations
if (nameInp) localStorage.setItem('obsigate-name', nameInp.value.trim());
// Persist language to server (survives clear site data)
try {
await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ language: newLang }),
});
} catch (e) { /* non-bloquant */ }
if (savedEl) { savedEl.style.display = 'inline'; setTimeout(function() { savedEl.style.display = 'none'; }, 2000); }
} catch(e) {}
});
// Logout button (#87, ex-onclick inline in index.html).
if (logoutBtn && window.handleLogout) {
logoutBtn.addEventListener('click', function() { window.handleLogout(); });
}
// ── Avatar (#113) ────────────────────────────────────────────────
var avatarField = document.getElementById('profile-avatar-field');
var avatarInput = document.getElementById('profile-avatar-input');
var chooseBtn = document.getElementById('profile-avatar-choose');
var overlayBtn = document.getElementById('profile-avatar-overlay');
var previewBox = document.getElementById('profile-avatar-preview');
var removeBtn = document.getElementById('profile-avatar-remove');
var presetsBox = document.getElementById('profile-avatar-presets');
if (!avatarField || !avatarInput || !chooseBtn) return;
// The avatar only exists on a real account — hide it when auth is off.
if (!AuthManager.isAuthEnabled || !AuthManager.isAuthEnabled()) {
avatarField.hidden = true;
return;
}
_renderProfileAvatar((AuthManager.getUser() || {}).avatar || null);
// Refresh from the server so the preview survives a page reload.
fetch('/api/auth/me', { credentials: 'include' })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (user) {
if (!user) return;
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || null);
})
.catch(function () { /* non-bloquant */ });
// ── Preset avatars (#117) ────────────────────────────────────────
var PRESET_STORAGE_KEY = 'obsigate-avatar-preset';
function markActivePreset(name) {
if (!presetsBox) return;
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
var on = !!name && btn.getAttribute('data-avatar') === name;
btn.classList.toggle('active', on);
btn.setAttribute('aria-checked', on ? 'true' : 'false');
});
}
function clearActivePreset() {
try { localStorage.removeItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
markActivePreset(null);
}
function rememberActivePreset(name) {
try { localStorage.setItem(PRESET_STORAGE_KEY, name); } catch (e) { /* ignore */ }
markActivePreset(name);
}
var savedPreset = null;
try { savedPreset = localStorage.getItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
markActivePreset(savedPreset);
if (presetsBox) {
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
btn.addEventListener('click', async function () {
var preset = btn.getAttribute('data-avatar');
if (!preset) return;
_profileAvatarError(null);
var all = presetsBox.querySelectorAll('.profile-avatar-preset');
all.forEach(function (b) { b.disabled = true; });
try {
// Load the bundled image, then reuse the upload pipeline (center-crop
// + 256 px JPEG) so it stores exactly like an imported picture.
var res = await fetch('/static/icons/avatar/' + encodeURIComponent(preset), { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status);
var blob = await res.blob();
var dataUrl = await _resizeAvatarFile(blob);
var user = await _patchProfileAvatar(dataUrl);
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || dataUrl);
AuthManager.renderUserSection();
rememberActivePreset(preset);
showToast(t('config.avatar_updated'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
console.error('Avatar preset failed:', err);
} finally {
all.forEach(function (b) { b.disabled = false; });
}
});
});
}
function openPicker() {
_profileAvatarError(null);
avatarInput.click();
}
chooseBtn.addEventListener('click', openPicker);
if (overlayBtn) overlayBtn.addEventListener('click', openPicker);
if (previewBox) {
previewBox.addEventListener('click', function (e) {
if (e.target.closest('button')) return;
openPicker();
});
}
avatarInput.addEventListener('change', async function () {
var file = avatarInput.files && avatarInput.files[0];
avatarInput.value = ""; // allow re-picking the same file
if (!file) return;
_profileAvatarError(null);
if (_AVATAR_TYPES.indexOf(file.type) === -1) {
_profileAvatarError('config.avatar_invalid_type');
return;
}
if (file.size > _AVATAR_MAX_FILE_BYTES) {
_profileAvatarError('config.avatar_too_large');
return;
}
chooseBtn.disabled = true;
try {
var dataUrl = await _resizeAvatarFile(file);
var user = await _patchProfileAvatar(dataUrl);
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || dataUrl);
AuthManager.renderUserSection();
clearActivePreset(); // an imported photo is not a preset
showToast(t('config.avatar_updated'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
console.error('Avatar upload failed:', err);
} finally {
chooseBtn.disabled = false;
}
});
if (removeBtn) {
removeBtn.addEventListener('click', async function () {
_profileAvatarError(null);
removeBtn.disabled = true;
try {
var user = await _patchProfileAvatar("");
AuthManager.updateCachedUser(user);
_renderProfileAvatar(null);
AuthManager.renderUserSection();
clearActivePreset();
showToast(t('config.avatar_removed'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
console.error('Avatar removal failed:', err);
} finally {
removeBtn.disabled = false;
}
});
}
}