Bruno Charest 58f7173cc3
All checks were successful
CI / lint (push) Successful in 11s
CI / security (push) Successful in 9s
CI / test (push) Successful in 14s
CI / build (push) Successful in 2s
fix: add missing exports to regenerated ui.js and search.js
2026-05-28 17:09:10 -04:00

1624 lines
53 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.

/* ObsiGate — UI module */
import { state } from './state.js';
import { openFile } from './viewer.js';
import { safeCreateIcons } from './utils.js';
// ---------------------------------------------------------------------------
// Right Sidebar Manager
// ---------------------------------------------------------------------------
export const RightSidebarManager = {
init() {
this.loadState();
this.initToggle();
this.initResize();
},
loadState() {
const savedVisible = localStorage.getItem("obsigate-right-sidebar-visible");
const savedWidth = localStorage.getItem("obsigate-right-sidebar-width");
if (savedVisible !== null) {
state.rightSidebarVisible = savedVisible === "true";
}
if (savedWidth) {
state.rightSidebarWidth = parseInt(savedWidth) || 280;
}
this.applyState();
},
applyState() {
const sidebar = document.getElementById("right-sidebar");
const handle = document.getElementById("right-sidebar-resize-handle");
const tocBtn = document.getElementById("toc-toggle-btn");
const headerToggleBtn = document.getElementById("right-sidebar-toggle-btn");
if (!sidebar) return;
if (rightSidebarVisible) {
sidebar.classList.remove("hidden");
sidebar.style.width = `${rightSidebarWidth}px`;
if (handle) handle.classList.remove("hidden");
if (tocBtn) {
tocBtn.classList.add("active");
tocBtn.title = "Masquer le sommaire";
}
if (headerToggleBtn) {
headerToggleBtn.title = "Masquer le panneau";
headerToggleBtn.setAttribute("aria-label", "Masquer le panneau");
}
} else {
sidebar.classList.add("hidden");
if (handle) handle.classList.add("hidden");
if (tocBtn) {
tocBtn.classList.remove("active");
tocBtn.title = "Afficher le sommaire";
}
if (headerToggleBtn) {
headerToggleBtn.title = "Afficher le panneau";
headerToggleBtn.setAttribute("aria-label", "Afficher le panneau");
}
}
// Update icons
safeCreateIcons();
},
toggle() {
state.rightSidebarVisible = !state.rightSidebarVisible;
localStorage.setItem("obsigate-right-sidebar-visible", rightSidebarVisible);
this.applyState();
},
initToggle() {
const toggleBtn = document.getElementById("right-sidebar-toggle-btn");
if (toggleBtn) {
toggleBtn.addEventListener("click", () => this.toggle());
}
},
initResize() {
const handle = document.getElementById("right-sidebar-resize-handle");
const sidebar = document.getElementById("right-sidebar");
if (!handle || !sidebar) return;
let isResizing = false;
let startX = 0;
let startWidth = 0;
const onMouseDown = (e) => {
isResizing = true;
startX = e.clientX;
startWidth = sidebar.offsetWidth;
handle.classList.add("active");
document.body.style.cursor = "ew-resize";
document.body.style.userSelect = "none";
};
const onMouseMove = (e) => {
if (!isResizing) return;
const delta = startX - e.clientX;
let newWidth = startWidth + delta;
// Constrain width
newWidth = Math.max(200, Math.min(400, newWidth));
sidebar.style.width = `${newWidth}px`;
state.rightSidebarWidth = newWidth;
};
const onMouseUp = () => {
if (!isResizing) return;
isResizing = false;
handle.classList.remove("active");
document.body.style.cursor = "";
document.body.style.userSelect = "";
localStorage.setItem("obsigate-right-sidebar-width", rightSidebarWidth);
};
handle.addEventListener("mousedown", onMouseDown);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
},
};
// ---------------------------------------------------------------------------
// Theme
// ---------------------------------------------------------------------------
export function initTheme() {
const saved = localStorage.getItem("obsigate-theme") || "dark";
applyTheme(saved);
}
export function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("obsigate-theme", theme);
// Update theme button icon and label
const themeBtn = document.getElementById("theme-toggle");
const themeLabel = document.getElementById("theme-label");
if (themeBtn && themeLabel) {
const icon = themeBtn.querySelector("i");
if (icon) {
icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun");
}
themeLabel.textContent = theme === "dark" ? "Sombre" : "Clair";
safeCreateIcons();
}
// Swap highlight.js theme
const darkSheet = document.getElementById("hljs-theme-dark");
const lightSheet = document.getElementById("hljs-theme-light");
if (darkSheet && lightSheet) {
darkSheet.disabled = theme !== "dark";
lightSheet.disabled = theme !== "light";
}
}
export function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme");
applyTheme(current === "dark" ? "light" : "dark");
}
export function initHeaderMenu() {
const menuBtn = document.getElementById("header-menu-btn");
const menuDropdown = document.getElementById("header-menu-dropdown");
if (!menuBtn || !menuDropdown) return;
menuBtn.addEventListener("click", (e) => {
e.stopPropagation();
menuBtn.classList.toggle("active");
menuDropdown.classList.toggle("active");
});
// Close menu when clicking outside
document.addEventListener("click", (e) => {
if (!menuDropdown.contains(e.target) && e.target !== menuBtn) {
menuBtn.classList.remove("active");
menuDropdown.classList.remove("active");
}
});
// Prevent menu from closing when clicking inside
menuDropdown.addEventListener("click", (e) => {
e.stopPropagation();
});
}
function closeHeaderMenu() {
const menuBtn = document.getElementById("header-menu-btn");
const menuDropdown = document.getElementById("header-menu-dropdown");
if (!menuBtn || !menuDropdown) return;
menuBtn.classList.remove("active");
menuDropdown.classList.remove("active");
}
// ---------------------------------------------------------------------------
// Custom Dropdowns
// ---------------------------------------------------------------------------
export function initCustomDropdowns() {
document.querySelectorAll(".custom-dropdown").forEach((dropdown) => {
const trigger = dropdown.querySelector(".custom-dropdown-trigger");
const options = dropdown.querySelectorAll(".custom-dropdown-option");
const hiddenInput = dropdown.querySelector('input[type="hidden"]');
const selectedText = dropdown.querySelector(".custom-dropdown-selected");
const menu = dropdown.querySelector(".custom-dropdown-menu");
if (!trigger) return;
// Toggle dropdown
trigger.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = dropdown.classList.contains("open");
// Close all other dropdowns
document.querySelectorAll(".custom-dropdown.open").forEach((d) => {
if (d !== dropdown) d.classList.remove("open");
});
dropdown.classList.toggle("open", !isOpen);
trigger.setAttribute("aria-expanded", !isOpen);
// Position fixed menu for sidebar dropdowns
if (!isOpen && dropdown.classList.contains("sidebar-dropdown") && menu) {
const rect = trigger.getBoundingClientRect();
menu.style.top = `${rect.bottom + 4}px`;
menu.style.left = `${rect.left}px`;
menu.style.width = `${rect.width}px`;
}
});
// Handle option selection
options.forEach((option) => {
option.addEventListener("click", (e) => {
e.stopPropagation();
const value = option.getAttribute("data-value");
const text = option.textContent;
// Update hidden input
if (hiddenInput) {
hiddenInput.value = value;
// Trigger change event
hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
}
// Update selected text
if (selectedText) {
selectedText.textContent = text;
}
// Update visual selection
options.forEach((opt) => opt.classList.remove("selected"));
option.classList.add("selected");
// Close dropdown
dropdown.classList.remove("open");
trigger.setAttribute("aria-expanded", "false");
});
});
});
// Close dropdowns when clicking outside
document.addEventListener("click", () => {
document.querySelectorAll(".custom-dropdown.open").forEach((dropdown) => {
dropdown.classList.remove("open");
const trigger = dropdown.querySelector(".custom-dropdown-trigger");
if (trigger) trigger.setAttribute("aria-expanded", "false");
});
});
}
// Helper to populate custom dropdown options
function populateCustomDropdown(dropdownId, optionsList, defaultValue) {
const dropdown = document.getElementById(dropdownId);
if (!dropdown) return;
const optionsContainer = dropdown.querySelector(".custom-dropdown-menu");
const hiddenInput = dropdown.querySelector('input[type="hidden"]');
const selectedText = dropdown.querySelector(".custom-dropdown-selected");
if (!optionsContainer) return;
// Clear existing options (keep the first one if it's the default)
optionsContainer.innerHTML = "";
// Add new options
optionsList.forEach((opt) => {
const li = document.createElement("li");
li.className = "custom-dropdown-option";
li.setAttribute("role", "option");
li.setAttribute("data-value", opt.value);
li.textContent = opt.text;
if (opt.value === defaultValue) {
li.classList.add("selected");
if (selectedText) selectedText.textContent = opt.text;
if (hiddenInput) hiddenInput.value = opt.value;
}
optionsContainer.appendChild(li);
});
// Re-initialize click handlers
optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((option) => {
option.addEventListener("click", (e) => {
e.stopPropagation();
const value = option.getAttribute("data-value");
const text = option.textContent;
if (hiddenInput) {
hiddenInput.value = value;
hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
}
if (selectedText) {
selectedText.textContent = text;
}
optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((opt) => opt.classList.remove("selected"));
option.classList.add("selected");
dropdown.classList.remove("open");
const trigger = dropdown.querySelector(".custom-dropdown-trigger");
if (trigger) trigger.setAttribute("aria-expanded", "false");
});
});
}
// ---------------------------------------------------------------------------
// Toast notifications
// ---------------------------------------------------------------------------
/** Display a brief toast message at the bottom of the viewport. */
export function showToast(message, type) {
console.log("showToast called with:", message, type);
type = type || "info";
let container = document.getElementById("toast-container");
if (!container) {
container = document.createElement("div");
container.id = "toast-container";
container.className = "toast-container";
container.setAttribute("aria-live", "polite");
document.body.appendChild(container);
}
var toast = document.createElement("div");
toast.className = "toast toast-" + type;
toast.textContent = message;
container.appendChild(toast);
// Trigger entrance animation
requestAnimationFrame(function () {
toast.classList.add("show");
});
setTimeout(function () {
toast.classList.remove("show");
toast.addEventListener("transitionend", function () {
toast.remove();
});
}, 3500);
}
// ---------------------------------------------------------------------------
// Sidebar toggle (desktop)
// ---------------------------------------------------------------------------
export function initSidebarToggle() {
const toggleBtn = document.getElementById("sidebar-toggle-btn");
const sidebar = document.getElementById("sidebar");
const resizeHandle = document.getElementById("sidebar-resize-handle");
if (!toggleBtn || !sidebar || !resizeHandle) return;
// Restore saved state
const savedState = localStorage.getItem("obsigate-sidebar-hidden");
if (savedState === "true") {
sidebar.classList.add("hidden");
resizeHandle.classList.add("hidden");
toggleBtn.classList.add("active");
}
toggleBtn.addEventListener("click", () => {
const isHidden = sidebar.classList.toggle("hidden");
resizeHandle.classList.toggle("hidden", isHidden);
toggleBtn.classList.toggle("active", isHidden);
localStorage.setItem("obsigate-sidebar-hidden", isHidden ? "true" : "false");
});
}
// ---------------------------------------------------------------------------
// Mobile sidebar
// ---------------------------------------------------------------------------
export function initMobile() {
const hamburger = document.getElementById("hamburger-btn");
const overlay = document.getElementById("sidebar-overlay");
const sidebar = document.getElementById("sidebar");
hamburger.addEventListener("click", () => {
sidebar.classList.toggle("mobile-open");
overlay.classList.toggle("active");
});
overlay.addEventListener("click", () => {
sidebar.classList.remove("mobile-open");
overlay.classList.remove("active");
});
}
function closeMobileSidebar() {
const sidebar = document.getElementById("sidebar");
const overlay = document.getElementById("sidebar-overlay");
if (sidebar) sidebar.classList.remove("mobile-open");
if (overlay) overlay.classList.remove("active");
}
// ---------------------------------------------------------------------------
// Resizable sidebar (horizontal)
// ---------------------------------------------------------------------------
export function initSidebarResize() {
const handle = document.getElementById("sidebar-resize-handle");
const sidebar = document.getElementById("sidebar");
if (!handle || !sidebar) return;
// Restore saved width
const savedWidth = localStorage.getItem("obsigate-sidebar-width");
if (savedWidth) {
sidebar.style.width = savedWidth + "px";
}
let startX = 0;
let startWidth = 0;
function onMouseMove(e) {
const newWidth = Math.min(500, Math.max(200, startWidth + (e.clientX - startX)));
sidebar.style.width = newWidth + "px";
}
function onMouseUp() {
document.body.classList.remove("resizing");
handle.classList.remove("active");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
localStorage.setItem("obsigate-sidebar-width", parseInt(sidebar.style.width));
}
handle.addEventListener("mousedown", (e) => {
e.preventDefault();
startX = e.clientX;
startWidth = sidebar.getBoundingClientRect().width;
document.body.classList.add("resizing");
handle.classList.add("active");
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
}
// ---------------------------------------------------------------------------
// Resizable tag section (vertical)
// ---------------------------------------------------------------------------
export function initTagResize() {
const handle = document.getElementById("tag-resize-handle");
const tagSection = document.getElementById("tag-cloud-section");
if (!handle || !tagSection) return;
// Restore saved height
const savedHeight = localStorage.getItem("obsigate-tag-height");
if (savedHeight) {
tagSection.style.height = savedHeight + "px";
}
let startY = 0;
let startHeight = 0;
function onMouseMove(e) {
// Dragging up increases height, dragging down decreases
const newHeight = Math.min(400, Math.max(60, startHeight - (e.clientY - startY)));
tagSection.style.height = newHeight + "px";
}
function onMouseUp() {
document.body.classList.remove("resizing-v");
handle.classList.remove("active");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
localStorage.setItem("obsigate-tag-height", parseInt(tagSection.style.height));
}
handle.addEventListener("mousedown", (e) => {
e.preventDefault();
startY = e.clientY;
startHeight = tagSection.getBoundingClientRect().height;
document.body.classList.add("resizing-v");
handle.classList.add("active");
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
}
// ---------------------------------------------------------------------------
// Frontmatter Accent Card Builder
// ---------------------------------------------------------------------------
function buildFrontmatterCard(frontmatter) {
// Helper: format date
function formatDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
const date = d.toISOString().slice(0, 10);
const time = d.toTimeString().slice(0, 5);
return `${date} · ${time}`;
}
// Extract boolean flags
const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] }));
// Toggle state
let isOpen = true;
// Build header with chevron
const chevron = el("span", { class: "fm-chevron open" });
chevron.innerHTML = '<i data-lucide="chevron-down" style="width:14px;height:14px"></i>';
const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]);
// ZONE 1: Top strip
const topBadges = [];
// Title badge
const title = frontmatter.titre || frontmatter.title || "";
if (title) {
topBadges.push(el("span", { class: "ac-title" }, [document.createTextNode(`"${title}"`)]));
}
// Status badge
if (frontmatter.statut) {
const statusBadge = el("span", { class: "ac-badge green" }, [el("span", { class: "ac-dot" }), document.createTextNode(frontmatter.statut)]);
topBadges.push(statusBadge);
}
// Category badge
if (frontmatter.catégorie || frontmatter.categorie) {
const cat = frontmatter.catégorie || frontmatter.categorie;
const catBadge = el("span", { class: "ac-badge blue" }, [document.createTextNode(cat)]);
topBadges.push(catBadge);
}
// Publish badge
if (frontmatter.publish) {
topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("publié")]));
}
// Favoris badge
if (frontmatter.favoris) {
topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("favori")]));
}
const acTop = el("div", { class: "ac-top" }, topBadges);
// ZONE 2: Body 2 columns
const leftCol = el("div", { class: "ac-col" }, [
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("auteur")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.auteur || "—")])]),
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("catégorie")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.catégorie || frontmatter.categorie || "—")])]),
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("statut")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.statut || "—")])]),
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("aliases")]), el("span", { class: "ac-v muted" }, [document.createTextNode(frontmatter.aliases && frontmatter.aliases.length > 0 ? frontmatter.aliases.join(", ") : "[]")])]),
]);
const rightCol = el("div", { class: "ac-col" }, [
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("creation_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.creation_date))])]),
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("modification_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.modification_date))])]),
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("publish")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.publish || false))])]),
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("favoris")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.favoris || false))])]),
]);
const acBody = el("div", { class: "ac-body" }, [leftCol, rightCol]);
// ZONE 3: Tags row
const tagPills = [];
if (frontmatter.tags && frontmatter.tags.length > 0) {
frontmatter.tags.forEach((tag) => {
tagPills.push(el("span", { class: "ac-tag" }, [document.createTextNode(tag)]));
});
}
const acTagsRow = el("div", { class: "ac-tags-row" }, [el("span", { class: "ac-tags-k" }, [document.createTextNode("tags")]), el("div", { class: "ac-tags-wrap" }, tagPills)]);
// ZONE 4: Flags row
const flagChips = [];
booleanFlags.forEach((flag) => {
const chipClass = flag.value ? "flag-chip on" : "flag-chip off";
flagChips.push(el("span", { class: chipClass }, [el("span", { class: "flag-dot" }), document.createTextNode(flag.key)]));
});
const acFlagsRow = el("div", { class: "ac-flags-row" }, [el("span", { class: "ac-flags-k" }, [document.createTextNode("flags")]), ...flagChips]);
// Assemble the card
const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]);
// Toggle functionality
fmHeader.addEventListener("click", () => {
isOpen = !isOpen;
if (isOpen) {
acCard.style.display = "block";
chevron.classList.remove("closed");
chevron.classList.add("open");
} else {
acCard.style.display = "none";
chevron.classList.remove("open");
chevron.classList.add("closed");
}
safeCreateIcons();
});
// Wrap in section
const fmSection = el("div", { class: "fm-section" }, [fmHeader, acCard]);
return fmSection;
}
// ---------------------------------------------------------------------------
// File Operations Manager
// ---------------------------------------------------------------------------
const FileOperations = {
showCreateDirectoryModal(vault, parentPath) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
modal.innerHTML = `
<div class="obsigate-modal-header">
<h3 class="obsigate-modal-title">Créer un dossier</h3>
</div>
<div class="obsigate-modal-body">
<div class="modal-form-group">
<label class="modal-label">Nom du dossier</label>
<input type="text" class="modal-input" id="dir-name-input" placeholder="nouveau-dossier" />
<div class="modal-hint">Dans: ${parentPath || '/'}</div>
<div class="modal-error" id="dir-error" style="display:none;"></div>
</div>
</div>
<div class="obsigate-modal-footer">
<button class="modal-btn" id="dir-cancel-btn">Annuler</button>
<button class="modal-btn primary" id="dir-create-btn">Créer</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => overlay.classList.add('active'), 10);
const input = modal.querySelector('#dir-name-input');
const errorDiv = modal.querySelector('#dir-error');
const createBtn = modal.querySelector('#dir-create-btn');
const cancelBtn = modal.querySelector('#dir-cancel-btn');
input.focus();
const validateName = (name) => {
if (!name.trim()) return 'Le nom ne peut pas être vide';
if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |';
return null;
};
input.addEventListener('input', () => {
const error = validateName(input.value);
if (error) {
errorDiv.textContent = error;
errorDiv.style.display = 'block';
input.classList.add('error');
} else {
errorDiv.style.display = 'none';
input.classList.remove('error');
}
});
const create = async () => {
const name = input.value.trim();
const error = validateName(name);
if (error) {
errorDiv.textContent = error;
errorDiv.style.display = 'block';
return;
}
const path = parentPath ? `${parentPath}/${name}` : name;
createBtn.disabled = true;
createBtn.textContent = 'Création...';
try {
await api(`/api/directory/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
showToast(`Dossier "${name}" créé`, 'success');
this._closeModal(overlay);
await refreshSidebarTreePreservingState();
} catch (err) {
showToast(err.message || 'Erreur lors de la création', 'error');
createBtn.disabled = false;
createBtn.textContent = 'Créer';
}
};
createBtn.addEventListener('click', create);
cancelBtn.addEventListener('click', () => this._closeModal(overlay));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') create();
if (e.key === 'Escape') this._closeModal(overlay);
});
},
showCreateFileModal(vault, parentPath) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
modal.innerHTML = `
<div class="obsigate-modal-header">
<h3 class="obsigate-modal-title">Créer un fichier</h3>
</div>
<div class="obsigate-modal-body">
<div class="modal-form-group">
<label class="modal-label">Nom du fichier</label>
<input type="text" class="modal-input" id="file-name-input" placeholder="note.md" />
<div class="modal-hint">Dans: ${parentPath || '/'}</div>
<div class="modal-error" id="file-error" style="display:none;"></div>
</div>
<div class="modal-form-group">
<label class="modal-label">Type de fichier</label>
<select class="modal-select" id="file-ext-select">
<option value=".md">Markdown (.md)</option>
<option value=".txt">Texte (.txt)</option>
<option value=".py">Python (.py)</option>
<option value=".js">JavaScript (.js)</option>
<option value=".json">JSON (.json)</option>
<option value=".yaml">YAML (.yaml)</option>
<option value=".sh">Shell (.sh)</option>
<option value=".ps1">PowerShell (.ps1)</option>
</select>
</div>
</div>
<div class="obsigate-modal-footer">
<button class="modal-btn" id="file-cancel-btn">Annuler</button>
<button class="modal-btn primary" id="file-create-btn">Créer</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => overlay.classList.add('active'), 10);
const input = modal.querySelector('#file-name-input');
const extSelect = modal.querySelector('#file-ext-select');
const errorDiv = modal.querySelector('#file-error');
const createBtn = modal.querySelector('#file-create-btn');
const cancelBtn = modal.querySelector('#file-cancel-btn');
input.focus();
const validateName = (name) => {
if (!name.trim()) return 'Le nom ne peut pas être vide';
if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |';
return null;
};
input.addEventListener('input', () => {
const error = validateName(input.value);
if (error) {
errorDiv.textContent = error;
errorDiv.style.display = 'block';
input.classList.add('error');
} else {
errorDiv.style.display = 'none';
input.classList.remove('error');
}
});
const create = async () => {
let name = input.value.trim();
const error = validateName(name);
if (error) {
errorDiv.textContent = error;
errorDiv.style.display = 'block';
return;
}
const ext = extSelect.value;
if (!name.endsWith(ext)) {
name += ext;
}
const path = parentPath ? `${parentPath}/${name}` : name;
createBtn.disabled = true;
createBtn.textContent = 'Création...';
try {
await api(`/api/file/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, content: '' }),
});
showToast(`Fichier "${name}" créé`, 'success');
this._closeModal(overlay);
await refreshSidebarTreePreservingState();
openFile(vault, path);
} catch (err) {
showToast(err.message || 'Erreur lors de la création', 'error');
createBtn.disabled = false;
createBtn.textContent = 'Créer';
}
};
createBtn.addEventListener('click', create);
cancelBtn.addEventListener('click', () => this._closeModal(overlay));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') create();
if (e.key === 'Escape') this._closeModal(overlay);
});
},
async startInlineRename(vault, path, type) {
const item = document.querySelector(`.tree-item[data-vault="${CSS.escape(vault)}"][data-path="${CSS.escape(path)}"]`);
if (!item) {
showToast('Élément introuvable dans larborescence', 'error');
return;
}
const textNode = Array.from(item.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
if (!textNode) {
showToast('Impossible de renommer cet élément', 'error');
return;
}
const originalText = textNode.textContent;
const trimmedOriginal = originalText.trim();
const currentName = path.split('/').pop() || trimmedOriginal;
const baseName = type === 'file' ? currentName.replace(/(\.[^./\\]+)$/i, '') : currentName;
const extension = type === 'file' ? (currentName.match(/(\.[^./\\]+)$/i)?.[1] || '') : '';
const input = document.createElement('input');
input.type = 'text';
input.className = 'sidebar-item-input';
input.value = baseName;
textNode.textContent = ' ';
const badge = item.querySelector('.badge-small');
if (badge) {
item.insertBefore(input, badge);
} else {
item.appendChild(input);
}
const restore = () => {
input.remove();
textNode.textContent = originalText;
};
const validateName = (name) => {
if (!name.trim()) return 'Le nom ne peut pas être vide';
if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |';
return null;
};
const submit = async () => {
const name = input.value.trim();
const error = validateName(name);
if (error) {
showToast(error, 'error');
input.focus();
input.select();
return;
}
const newName = `${name}${extension}`;
if (newName === currentName) {
restore();
return;
}
input.disabled = true;
try {
const endpoint = type === 'directory' ? `/api/directory/${encodeURIComponent(vault)}` : `/api/file/${encodeURIComponent(vault)}`;
const result = await api(endpoint, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, new_name: newName }),
});
const nextPath = result.new_path;
await refreshSidebarTreePreservingState();
if (type === 'file' && state.currentVault === vault && state.currentPath === path) {
await openFile(vault, nextPath);
} else if (type === 'directory' && state.currentVault === vault && currentPath && (state.currentPath === path || currentPath.startsWith(`${path}/`))) {
const suffix = state.currentPath === path ? '' : currentPath.slice(path.length);
state.currentPath = `${nextPath}${suffix}`;
await focusPathInSidebar(vault, currentPath, { alignToTop: false });
}
showToast(type === 'directory' ? 'Dossier renommé' : 'Fichier renommé', 'success');
} catch (err) {
input.disabled = false;
showToast(err.message || 'Erreur lors du renommage', 'error');
input.focus();
input.select();
return;
}
};
input.addEventListener('click', (e) => e.stopPropagation());
input.addEventListener('keydown', async (e) => {
e.stopPropagation();
if (e.key === 'Enter') {
e.preventDefault();
await submit();
}
if (e.key === 'Escape') {
e.preventDefault();
restore();
}
});
input.addEventListener('blur', async () => {
if (!input.disabled) {
await submit();
}
});
input.focus();
input.setSelectionRange(0, input.value.length);
},
confirmDeleteDirectory(vault, path) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
modal.innerHTML = `
<div class="obsigate-modal-header">
<h3 class="obsigate-modal-title">Supprimer le dossier</h3>
</div>
<div class="obsigate-modal-body">
<div class="modal-warning">
<i data-lucide="alert-triangle" class="icon"></i>
<div>
<strong>Attention !</strong> Cette action est irréversible.
<br>Tous les fichiers et sous-dossiers seront supprimés définitivement.
</div>
</div>
<div class="modal-form-group">
<label class="modal-label">Dossier à supprimer:</label>
<div style="font-family: 'JetBrains Mono', monospace; color: var(--text-muted);">${path}</div>
</div>
</div>
<div class="obsigate-modal-footer">
<button class="modal-btn" id="del-cancel-btn">Annuler</button>
<button class="modal-btn danger" id="del-confirm-btn">Supprimer définitivement</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => overlay.classList.add('active'), 10);
safeCreateIcons();
const confirmBtn = modal.querySelector('#del-confirm-btn');
const cancelBtn = modal.querySelector('#del-cancel-btn');
const deleteDir = async () => {
confirmBtn.disabled = true;
confirmBtn.textContent = 'Suppression...';
try {
const result = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
});
showToast(`Dossier supprimé (${result.deleted_count} fichiers)`, 'success');
this._closeModal(overlay);
await refreshSidebarTreePreservingState();
if (state.currentVault === vault && currentPath && currentPath.startsWith(path)) {
showWelcome();
}
} catch (err) {
showToast(err.message || 'Erreur lors de la suppression', 'error');
confirmBtn.disabled = false;
confirmBtn.textContent = 'Supprimer définitivement';
}
};
confirmBtn.addEventListener('click', deleteDir);
cancelBtn.addEventListener('click', () => this._closeModal(overlay));
},
confirmDeleteFile(vault, path) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
modal.innerHTML = `
<div class="obsigate-modal-header">
<h3 class="obsigate-modal-title">Supprimer le fichier</h3>
</div>
<div class="obsigate-modal-body">
<div class="modal-warning">
<i data-lucide="alert-triangle" class="icon"></i>
<div>
<strong>Attention !</strong> Cette action est irréversible.
</div>
</div>
<div class="modal-form-group">
<label class="modal-label">Fichier à supprimer:</label>
<div style="font-family: 'JetBrains Mono', monospace; color: var(--text-muted);">${path}</div>
</div>
</div>
<div class="obsigate-modal-footer">
<button class="modal-btn" id="del-cancel-btn">Annuler</button>
<button class="modal-btn danger" id="del-confirm-btn">Supprimer définitivement</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => overlay.classList.add('active'), 10);
safeCreateIcons();
const confirmBtn = modal.querySelector('#del-confirm-btn');
const cancelBtn = modal.querySelector('#del-cancel-btn');
const deleteFile = async () => {
confirmBtn.disabled = true;
confirmBtn.textContent = 'Suppression...';
try {
await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
});
showToast('Fichier supprimé', 'success');
this._closeModal(overlay);
await refreshSidebarTreePreservingState();
if (state.currentVault === vault && state.currentPath === path) {
showWelcome();
}
} catch (err) {
showToast(err.message || 'Erreur lors de la suppression', 'error');
confirmBtn.disabled = false;
confirmBtn.textContent = 'Supprimer définitivement';
}
};
confirmBtn.addEventListener('click', deleteFile);
cancelBtn.addEventListener('click', () => this._closeModal(overlay));
},
_createModalOverlay() {
const overlay = document.createElement('div');
overlay.className = 'obsigate-modal-overlay';
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
this._closeModal(overlay);
}
});
return overlay;
},
_closeModal(overlay) {
overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 200);
}
};
// ---------------------------------------------------------------------------
// Find in Page Manager
// ---------------------------------------------------------------------------
export const FindInPageManager = {
isOpen: false,
searchTerm: "",
matches: [],
currentIndex: -1,
options: {
caseSensitive: false,
wholeWord: false,
useRegex: false,
},
debounceTimer: null,
previousFocus: null,
init() {
const bar = document.getElementById("find-in-page-bar");
const input = document.getElementById("find-input");
const prevBtn = document.getElementById("find-prev");
const nextBtn = document.getElementById("find-next");
const closeBtn = document.getElementById("find-close");
const caseSensitiveBtn = document.getElementById("find-case-sensitive");
const wholeWordBtn = document.getElementById("find-whole-word");
const regexBtn = document.getElementById("find-regex");
if (!bar || !input) return;
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
// Ctrl+F or Cmd+F to open
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
e.preventDefault();
this.open();
}
// Escape to close
if (e.key === "Escape" && this.isOpen) {
e.preventDefault();
this.close();
}
// Enter to go to next
if (e.key === "Enter" && this.isOpen && document.activeElement === input) {
e.preventDefault();
if (e.shiftKey) {
this.goToPrevious();
} else {
this.goToNext();
}
}
// F3 for next/previous
if (e.key === "F3" && this.isOpen) {
e.preventDefault();
if (e.shiftKey) {
this.goToPrevious();
} else {
this.goToNext();
}
}
});
// Input event with debounce
input.addEventListener("input", (e) => {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.search(e.target.value);
}, 250);
});
// Navigation buttons
prevBtn.addEventListener("click", () => this.goToPrevious());
nextBtn.addEventListener("click", () => this.goToNext());
// Close button
closeBtn.addEventListener("click", () => this.close());
// Option toggles
caseSensitiveBtn.addEventListener("click", () => {
this.options.caseSensitive = !this.options.caseSensitive;
caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive);
this.saveState();
if (this.searchTerm) this.search(this.searchTerm);
});
wholeWordBtn.addEventListener("click", () => {
this.options.wholeWord = !this.options.wholeWord;
wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord);
this.saveState();
if (this.searchTerm) this.search(this.searchTerm);
});
regexBtn.addEventListener("click", () => {
this.options.useRegex = !this.options.useRegex;
regexBtn.setAttribute("aria-pressed", this.options.useRegex);
this.saveState();
if (this.searchTerm) this.search(this.searchTerm);
});
// Load saved state
this.loadState();
},
open() {
const bar = document.getElementById("find-in-page-bar");
const input = document.getElementById("find-input");
if (!bar || !input) return;
this.previousFocus = document.activeElement;
this.isOpen = true;
bar.hidden = false;
input.focus();
input.select();
safeCreateIcons();
},
close() {
const bar = document.getElementById("find-in-page-bar");
if (!bar) return;
this.isOpen = false;
bar.hidden = true;
this.clearHighlights();
this.matches = [];
this.currentIndex = -1;
this.searchTerm = "";
// Restore previous focus
if (this.previousFocus && this.previousFocus.focus) {
this.previousFocus.focus();
}
},
search(term) {
this.searchTerm = term;
this.clearHighlights();
this.hideError();
if (!term || term.trim().length === 0) {
this.updateCounter();
this.updateNavButtons();
return;
}
const contentArea = document.querySelector(".md-content");
if (!contentArea) {
this.updateCounter();
this.updateNavButtons();
return;
}
try {
const regex = this.createRegex(term);
this.matches = [];
this.findMatches(contentArea, regex);
this.currentIndex = this.matches.length > 0 ? 0 : -1;
this.highlightMatches();
this.updateCounter();
this.updateNavButtons();
if (this.matches.length > 0) {
this.scrollToMatch(0);
}
} catch (err) {
this.showError(err.message);
this.matches = [];
this.currentIndex = -1;
this.updateCounter();
this.updateNavButtons();
}
},
createRegex(term) {
let pattern = term;
if (!this.options.useRegex) {
// Escape special regex characters
pattern = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
if (this.options.wholeWord) {
pattern = "\\b" + pattern + "\\b";
}
const flags = this.options.caseSensitive ? "g" : "gi";
return new RegExp(pattern, flags);
},
findMatches(container, regex) {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
// Skip code blocks, scripts, styles
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
const tagName = parent.tagName.toLowerCase();
if (["code", "pre", "script", "style"].includes(tagName)) {
return NodeFilter.FILTER_REJECT;
}
// Skip empty text nodes
if (!node.textContent || node.textContent.trim().length === 0) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
let node;
while ((node = walker.nextNode())) {
const text = node.textContent;
let match;
regex.lastIndex = 0; // Reset regex
while ((match = regex.exec(text)) !== null) {
this.matches.push({
node: node,
index: match.index,
length: match[0].length,
text: match[0],
});
// Prevent infinite loop with zero-width matches
if (match.index === regex.lastIndex) {
regex.lastIndex++;
}
}
}
},
highlightMatches() {
const matchesByNode = new Map();
this.matches.forEach((match, idx) => {
if (!matchesByNode.has(match.node)) {
matchesByNode.set(match.node, []);
}
matchesByNode.get(match.node).push({ match, idx });
});
matchesByNode.forEach((entries, node) => {
if (!node || !node.parentNode) return;
const text = node.textContent || "";
let cursor = 0;
const fragment = document.createDocumentFragment();
entries.sort((a, b) => a.match.index - b.match.index);
entries.forEach(({ match, idx }) => {
if (match.index > cursor) {
fragment.appendChild(document.createTextNode(text.substring(cursor, match.index)));
}
const matchText = text.substring(match.index, match.index + match.length);
const mark = document.createElement("mark");
mark.className = idx === this.currentIndex ? "find-highlight find-highlight-active" : "find-highlight";
mark.textContent = matchText;
mark.setAttribute("data-find-index", idx);
fragment.appendChild(mark);
match.element = mark;
cursor = match.index + match.length;
});
if (cursor < text.length) {
fragment.appendChild(document.createTextNode(text.substring(cursor)));
}
node.parentNode.replaceChild(fragment, node);
});
},
clearHighlights() {
const contentArea = document.querySelector(".md-content");
if (!contentArea) return;
const marks = contentArea.querySelectorAll("mark.find-highlight");
marks.forEach((mark) => {
if (!mark.parentNode) return;
const text = mark.textContent;
const textNode = document.createTextNode(text);
mark.parentNode.replaceChild(textNode, mark);
});
// Normalize text nodes to merge adjacent text nodes
contentArea.normalize();
},
goToNext() {
if (this.matches.length === 0) return;
// Remove active class from current
if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) {
this.matches[this.currentIndex].element.classList.remove("find-highlight-active");
}
// Move to next (with wrapping)
this.currentIndex = (this.currentIndex + 1) % this.matches.length;
// Add active class to new current
if (this.matches[this.currentIndex].element) {
this.matches[this.currentIndex].element.classList.add("find-highlight-active");
}
this.scrollToMatch(this.currentIndex);
this.updateCounter();
},
goToPrevious() {
if (this.matches.length === 0) return;
// Remove active class from current
if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) {
this.matches[this.currentIndex].element.classList.remove("find-highlight-active");
}
// Move to previous (with wrapping)
this.currentIndex = this.currentIndex <= 0 ? this.matches.length - 1 : this.currentIndex - 1;
// Add active class to new current
if (this.matches[this.currentIndex].element) {
this.matches[this.currentIndex].element.classList.add("find-highlight-active");
}
this.scrollToMatch(this.currentIndex);
this.updateCounter();
},
scrollToMatch(index) {
if (index < 0 || index >= this.matches.length) return;
const match = this.matches[index];
if (!match.element) return;
const contentArea = document.getElementById("content-area");
if (!contentArea) {
match.element.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
// Calculate position with offset for header
const elementTop = match.element.offsetTop;
const offset = 100; // Offset for header
contentArea.scrollTo({
top: elementTop - offset,
behavior: "smooth",
});
},
updateCounter() {
const counter = document.getElementById("find-counter");
if (!counter) return;
const count = this.matches.length;
if (count === 0) {
counter.textContent = "0 occurrence";
} else if (count === 1) {
counter.textContent = "1 occurrence";
} else {
counter.textContent = `${count} occurrences`;
}
},
updateNavButtons() {
const prevBtn = document.getElementById("find-prev");
const nextBtn = document.getElementById("find-next");
if (!prevBtn || !nextBtn) return;
const hasMatches = this.matches.length > 0;
prevBtn.disabled = !hasMatches;
nextBtn.disabled = !hasMatches;
},
showError(message) {
const errorEl = document.getElementById("find-error");
if (!errorEl) return;
errorEl.textContent = message;
errorEl.hidden = false;
},
hideError() {
const errorEl = document.getElementById("find-error");
if (!errorEl) return;
errorEl.hidden = true;
},
saveState() {
try {
const state = {
options: this.options,
};
localStorage.setItem("obsigate-find-in-page-state", JSON.stringify(state));
} catch (e) {
// Ignore localStorage errors
}
},
loadState() {
try {
const saved = localStorage.getItem("obsigate-find-in-page-state");
if (saved) {
const state = JSON.parse(saved);
if (state.options) {
this.options = { ...this.options, ...state.options };
// Update button states
const caseSensitiveBtn = document.getElementById("find-case-sensitive");
const wholeWordBtn = document.getElementById("find-whole-word");
const regexBtn = document.getElementById("find-regex");
if (caseSensitiveBtn) caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive);
if (wholeWordBtn) wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord);
if (regexBtn) regexBtn.setAttribute("aria-pressed", this.options.useRegex);
}
}
} catch (e) {
// Ignore localStorage errors
}
},
};
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
async function init() {
initTheme();
initHeaderMenu();
initCustomDropdowns();
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
document.getElementById("header-logo").addEventListener("click", goHome);
const refreshBtn = document.getElementById("header-refresh-btn");
if (refreshBtn) refreshBtn.addEventListener("click", goHome);
initSearch();
initSidebarToggle();
initMobile();
initVaultContext();
initSidebarTabs();
initHelpModal();
initConfigModal();
initSidebarFilter();
initSidebarResize();
initEditor();
initLoginForm();
initRecentTab();
RightSidebarManager.init();
FindInPageManager.init();
ContextMenuManager.init();
// Check auth status first
const authOk = await AuthManager.initAuth();
if (authOk) {
// Start SSE sync AFTER auth is established (cookie available)
initSyncStatus();
try {
await Promise.all([loadVaultSettings(), loadVaults(), loadTags()]);
// Initialize dashboard widgets now that vaults are loaded
if (typeof DashboardRecentWidget !== "undefined") {
DashboardRecentWidget.init();
}
// Check for popup mode query parameter
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("popup") === "true") {
document.body.classList.add("popup-mode");
}
// Handle direct deep-link to file via #file=...
if (window.location.hash && window.location.hash.startsWith("#file=")) {
const hashVal = window.location.hash.substring(6);
const sepIndex = hashVal.indexOf(":");
if (sepIndex > -1) {
const vault = decodeURIComponent(hashVal.substring(0, sepIndex));
const path = decodeURIComponent(hashVal.substring(sepIndex + 1));
openFile(vault, path);
}
} else if (urlParams.get("popup") !== "true") {
// Default to dashboard if no deep link and not in popup mode
showWelcome();
}
} catch (err) {
console.error("Failed to initialize ObsiGate:", err);
showToast("Erreur lors de l'initialisation", "error");
}
}
safeCreateIcons();
}
// ---- Modify openFile to use TabManager ----
const _originalOpenFile = openFile;
openFile = function(vault, path) {
TabManager.open(vault, path);
};
// ---- Keyboard shortcuts for tabs ----
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === "w" || e.key === "W") {
e.preventDefault();
if (TabManager._activeTabId) {
TabManager.close(TabManager._activeTabId);
}
} else if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
const tabs = TabManager._tabs;
const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId);
if (currentIdx >= 0 && tabs.length > 1) {
const nextIdx = (currentIdx + 1) % tabs.length;
TabManager.activate(tabs[nextIdx].id);
}
} else if (e.key === "Tab" && e.shiftKey) {
e.preventDefault();
const tabs = TabManager._tabs;
const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId);
if (currentIdx >= 0 && tabs.length > 1) {
const prevIdx = (currentIdx - 1 + tabs.length) % tabs.length;
TabManager.activate(tabs[prevIdx].id);
}
}
}
});
// ---- Modify init to include TabManager ----
const _origInit2 = init;
init = function() {
_origInit2();
TabManager.init();
};