/** * Vault Home / navigation page module for ObsiGate (#158). * * The view is mounted by `TabManager.openNav(vault, dir)` as a dedicated * **navigation tab**: it opens on every directory click (vault root included, * vault focus mode *and* "All" mode), coexists with open file tabs, and * re-renders when the user drills into a subdirectory or the breadcrumb. * * Content: breadcrumb, sort/save toolbar, facets panel (Vaults · Tags · * Extensions — same mechanism and look as the search page), subdirectory list, * recent files (root only) and the non-recursive file listing of the current * directory. * * Usage: * import { showVaultHome, isVaultHomeActive } from './vaulthome.js'; * // …but in practice: TabManager.openNav(vault, dirPath) */ import { api } from './auth.js'; import { el } from './viewer.js'; import { getFileIcon, safeCreateIcons } from './utils.js'; import { TabManager, ContextMenuManager } from './ui.js'; import { t, getLocale } from './i18n.js'; import { buildNavFacets, filterNavFiles, sortNavFiles, navDisplayName } from './navfacets.js'; // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- const state = { active: false, currentVault: null, currentDir: '', // empty = vault root sort: 'modified', // 'modified' (Date) | 'relevance' (Pertinence) filterTag: '', filterExt: '', files: [], // last listing, unfiltered dirs: [], // subdirectories of the current directory }; // Handles of the mounted view (only one navigation view exists at a time). let _view = null; let _renderSeq = 0; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** Check if the navigation view is currently displayed. */ export function isVaultHomeActive() { return state.active; } /** * Display the navigation view for a vault directory. * Called by TabManager when the navigation tab is activated. * @param {string} vaultName - Vault name * @param {string} dirPath - Directory path (empty for root) */ export async function showVaultHome(vaultName, dirPath = '') { if (!vaultName || vaultName === 'all') { state.active = false; return; } state.active = true; state.currentVault = vaultName; state.currentDir = dirPath || ''; state.filterTag = ''; state.filterExt = ''; state.sort = 'modified'; const area = document.getElementById('content-area'); if (!area) return; // Remove any existing dashboard const home = document.getElementById('dashboard-home'); if (home) home.remove(); const seq = ++_renderSeq; area.innerHTML = ''; const container = el('div', { class: 'vault-home', id: 'vault-home' }); area.appendChild(container); // Breadcrumb renderBreadcrumb(container, vaultName, state.currentDir); // Sort + save toolbar (same classes as the search results header) container.appendChild(buildToolbar()); // Facets host (filled once the listing is loaded) const facetsHost = el('div', { id: 'vault-home-facets' }); container.appendChild(facetsHost); // Results container const resultsArea = el('div', { class: 'vault-home-results', id: 'vault-home-results' }); container.appendChild(resultsArea); resultsArea.innerHTML = `

${t('vaulthome.loading')}

`; safeCreateIcons(); _view = { container, facetsHost, resultsArea, vault: vaultName, dirs: [] }; // Listing + subdirectories let files; try { const data = await api( `/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(state.currentDir)}&limit=200&recursive=false`, ); files = data.files || []; } catch (err) { if (seq !== _renderSeq) return; const errMsg = escapeHtml( t('vaulthome.error_loading') + ' ' + (err?.detail || err?.message || t('common.unknown')), ); resultsArea.innerHTML = `

${errMsg}

`; return; } try { const browse = await api( `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(state.currentDir)}`, ); state.dirs = (browse.items || []).filter((i) => i.type === 'directory'); } catch { state.dirs = []; // no subdirectory list — the file listing still renders } if (seq !== _renderSeq) return; state.files = files; _view.dirs = state.dirs; const facets = buildFacets(); if (facets) facetsHost.appendChild(facets); renderResults(); // Recent files for this vault (root level only) if (!state.currentDir) { loadRecentSection(vaultName, container, resultsArea); } } // --------------------------------------------------------------------------- // Sort + save toolbar // --------------------------------------------------------------------------- function buildToolbar() { const header = el('div', { class: 'search-results-header' }); const sortDiv = el('div', { class: 'search-sort' }); const btnRelevance = el('button', { class: 'search-sort__btn' + (state.sort === 'relevance' ? ' active' : ''), type: 'button', }); btnRelevance.textContent = 'Pertinence'; btnRelevance.addEventListener('click', () => { state.sort = 'relevance'; btnRelevance.classList.add('active'); btnDate.classList.remove('active'); renderResults(); }); const btnDate = el('button', { class: 'search-sort__btn' + (state.sort === 'modified' ? ' active' : ''), type: 'button', }); btnDate.textContent = 'Date'; btnDate.addEventListener('click', () => { state.sort = 'modified'; btnDate.classList.add('active'); btnRelevance.classList.remove('active'); renderResults(); }); sortDiv.appendChild(btnRelevance); sortDiv.appendChild(btnDate); header.appendChild(sortDiv); // Save the current directory as a saved search — same payload as the search // page's "save directory" action (query vide + include_paths). const saveBtn = el('button', { class: 'search-save-btn', type: 'button', title: t('search.save_search') }); saveBtn.innerHTML = ' Sauver'; saveBtn.addEventListener('click', async () => { try { await api('/api/saved-searches', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: '', vault: state.currentVault, case_sensitive: false, whole_word: false, regex: false, include_paths: state.currentDir ? state.currentDir + '/**' : '**', exclude_paths: '', }), }); flashSaveBtn(saveBtn, false); import('./viewer.js').then((m) => m.loadSavedSearches()); } catch (err) { flashSaveBtn(saveBtn, true, err); } }); header.appendChild(saveBtn); safeCreateIcons(); return header; } /** Tiny inline feedback on the save button (no toast module dependency). */ function flashSaveBtn(btn, isError, err) { const original = btn.innerHTML; if (isError) { btn.title = String(err?.detail || err?.message || err); } btn.textContent = isError ? '✕' : '✓'; setTimeout(() => { btn.innerHTML = original; safeCreateIcons(); }, 1200); } // --------------------------------------------------------------------------- // Facets panel — same mechanism and visuals as the search page (#157/#158) // --------------------------------------------------------------------------- function buildFacets() { const facets = buildNavFacets(state.files); // Parité avec la page de recherche : pas de panneau vide (listing sans fichier). if (!Object.keys(facets.vaults).length && !Object.keys(facets.tags).length && !Object.keys(facets.extensions).length) { return null; } const collapsed = localStorage.getItem('obsigate_facets_collapsed') === '1'; const facetsDiv = el('div', { class: 'search-facets', 'data-collapsed': collapsed ? 'true' : 'false' }); const toggleBtn = el('button', { class: 'search-facets__toggle', type: 'button', 'aria-expanded': String(!collapsed), title: collapsed ? t('search.facets_expand') : t('search.facets_collapse'), }); toggleBtn.innerHTML = ''; toggleBtn.addEventListener('click', () => { const nowCollapsed = facetsDiv.getAttribute('data-collapsed') !== 'true'; facetsDiv.setAttribute('data-collapsed', nowCollapsed ? 'true' : 'false'); toggleBtn.setAttribute('aria-expanded', String(!nowCollapsed)); toggleBtn.title = nowCollapsed ? t('search.facets_expand') : t('search.facets_collapse'); localStorage.setItem('obsigate_facets_collapsed', nowCollapsed ? '1' : '0'); }); facetsDiv.appendChild(toggleBtn); const body = el('div', { class: 'search-facets__body' }); // Vaults — the view is scoped to one vault; clicking the chip jumps to the // vault root (never a dead control). if (Object.keys(facets.vaults).length > 0) { const group = el('div', { class: 'search-facets__group' }); group.appendChild(facetLabel('Vaults')); for (const [vaultName, count] of Object.entries(facets.vaults)) { const item = facetItem(vaultName, count, false); item.addEventListener('click', () => TabManager.openNav(vaultName, '')); group.appendChild(item); } body.appendChild(group); } // Tags — filter the listing client-side (toggle). if (Object.keys(facets.tags).length > 0) { const group = el('div', { class: 'search-facets__group' }); group.appendChild(facetLabel('Tags')); for (const [tagName, count] of Object.entries(facets.tags).slice(0, 12)) { const item = facetItem('#' + tagName, count, state.filterTag === tagName); item.addEventListener('click', () => { state.filterTag = state.filterTag === tagName ? '' : tagName; refreshView(); }); group.appendChild(item); } body.appendChild(group); } // Extensions — filter the listing client-side (toggle). if (Object.keys(facets.extensions).length > 0) { const group = el('div', { class: 'search-facets__group', 'data-facet': 'extensions' }); group.appendChild(facetLabel('Extensions')); for (const [extName, count] of Object.entries(facets.extensions)) { const item = facetItem('.' + extName, count, state.filterExt === extName); item.addEventListener('click', () => { state.filterExt = state.filterExt === extName ? '' : extName; refreshView(); }); group.appendChild(item); } body.appendChild(group); } facetsDiv.appendChild(body); return facetsDiv; } function facetLabel(text) { const label = el('span', { class: 'search-facets__label' }); label.textContent = text; return label; } function facetItem(label, count, active) { // textContent: facet labels come from user data (names, tags, extensions), // never innerHTML. const item = el('span', { class: 'search-facets__item' + (active ? ' active' : '') }); item.appendChild(document.createTextNode(label + ' ')); const countEl = el('span', { class: 'facet-count' }); countEl.textContent = String(count); item.appendChild(countEl); return item; } /** Re-render facets + listing after a filter change (no refetch). */ function refreshView() { if (!_view) return; _view.facetsHost.replaceChildren(); const facets = buildFacets(); if (facets) _view.facetsHost.appendChild(facets); renderResults(); safeCreateIcons(); } // --------------------------------------------------------------------------- // Listing: subdirectories + files // --------------------------------------------------------------------------- function renderResults() { if (!_view) return; const container = _view.resultsArea; container.innerHTML = ''; const files = filterNavFiles(state.files, { tag: state.filterTag, ext: state.filterExt }); const isEmptyListing = state.files.length === 0; if (isEmptyListing && state.dirs.length === 0) { container.innerHTML = `

${t('vaulthome.empty')}

`; safeCreateIcons(); return; } // Header: count + directory label const header = el('div', { class: 'vault-home-header' }); const shown = isEmptyListing ? state.dirs.length : files.length; const countKey = shown > 1 ? 'vaulthome.file_count_plural' : 'vaulthome.file_count'; header.appendChild(el('span', { class: 'vault-home-count', 'data-i18n': countKey }, [ document.createTextNode(t(countKey, { count: shown })), ])); header.appendChild(el('span', { class: 'vault-home-dir-info' }, [ document.createTextNode(state.currentDir ? `/${state.currentDir}` : ` ${t('common.root')}`), ])); container.appendChild(header); // Active filter chips (same badges as the search page) if (state.filterTag || state.filterExt) { const activeFilters = el('div', { class: 'search-results-active-tags' }); const addChip = (label, onRemove) => { const removeBtn = el('button', { class: 'search-results-active-tag-remove', title: label }, [ document.createTextNode('×'), ]); removeBtn.addEventListener('click', (e) => { e.stopPropagation(); onRemove(); }); activeFilters.appendChild(el('span', { class: 'search-results-active-tag' }, [ document.createTextNode(label), removeBtn, ])); }; if (state.filterTag) addChip('#' + state.filterTag, () => { state.filterTag = ''; refreshView(); }); if (state.filterExt) addChip('.' + state.filterExt, () => { state.filterExt = ''; refreshView(); }); container.appendChild(activeFilters); } // Subdirectories — clickable, navigate deeper if (state.dirs.length > 0) { const section = el('div', { class: 'vault-home-recent vault-home-dirs' }); const secHeader = el('div', { class: 'vault-home-recent-header' }); secHeader.innerHTML = ` ${t('vaulthome.directories')}`; section.appendChild(secHeader); const list = el('div', { class: 'vault-home-recent-list' }); state.dirs.forEach((d) => { const item = el('div', { class: 'vault-home-recent-item', 'data-path': d.path }); // Même couleur d'icône que l'arbre de la sidebar (.tree-item .icon) item.innerHTML = ` ${escapeHtml(d.name)} (${d.children_count})`; item.addEventListener('click', () => TabManager.openNav(state.currentVault, d.path)); // Menu contextuel identique à celui de la sidebar (#158) item.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); // sinon le handler global de ContextMenuManager referme le menu ContextMenuManager.show(e.clientX, e.clientY, state.currentVault, d.path, 'directory', false); }); list.appendChild(item); }); section.appendChild(list); container.appendChild(section); } // File list — search-result-item style if (files.length === 0) { if (!isEmptyListing) { container.appendChild(el('p', { style: 'color:var(--text-muted);margin-top:20px' }, [ document.createTextNode(t('search.no_results')), ])); } safeCreateIcons(); return; } const list = el('div', { class: 'search-results' }); const locale = getLocale(); sortNavFiles(files, state.sort).forEach((f) => { list.appendChild(buildFileItem(f, locale)); }); container.appendChild(list); safeCreateIcons(); } function buildFileItem(f, locale) { const fileIconName = getFileIcon ? getFileIcon(f.name) : 'file'; const ext = f.extension || ''; const titleDiv = el('div', { class: 'search-result-title' }, [ document.createTextNode(navDisplayName(f)), ]); const modDate = new Date(f.modified * 1000); const dateStr = modDate.toLocaleDateString(locale === 'fr' ? 'fr-CA' : 'en-CA', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); const metaDiv = el('div', { class: 'search-result-vault' }, [ document.createTextNode([dateStr, formatSize(f.size)].join(' · ')), ]); const item = el('div', { class: 'search-result-item vault-home-item', 'data-vault': f.vault, 'data-path': f.path, }, [ el('span', { class: 'search-result-ext' }, [document.createTextNode(ext)]), el('i', { 'data-lucide': fileIconName, style: 'width:14px;height:14px;margin-right:4px;opacity:0.6' }), titleDiv, metaDiv, ]); // Tags — clicking one applies the tag filter (like the search page) if (f.tags && f.tags.length > 0) { const tagsDiv = el('div', { class: 'search-result-tags' }); f.tags.forEach((tag) => { const tagEl = el('span', { class: 'file-tag' }, [document.createTextNode('#' + tag)]); tagEl.addEventListener('click', (e) => { e.stopPropagation(); state.filterTag = tag; refreshView(); }); tagsDiv.appendChild(tagEl); }); item.appendChild(tagsDiv); } item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path)); item.addEventListener('dblclick', (e) => { e.preventDefault(); TabManager.openPersistent(f.vault, f.path); }); // Menu contextuel identique à celui de la sidebar (#158) item.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); ContextMenuManager.show(e.clientX, e.clientY, f.vault, f.path, 'file', false); }); return item; } // --------------------------------------------------------------------------- // Breadcrumb // --------------------------------------------------------------------------- function renderBreadcrumb(container, vaultName, dirPath) { const bc = el('div', { class: 'vault-home-breadcrumb' }); buildBreadcrumbHTML(bc, vaultName, dirPath); container.appendChild(bc); } function buildBreadcrumbHTML(parentEl, vaultName, dirPath) { // Vault icon + name (root) — routed through the navigation tab so the tab // cache and the view never drift apart. const vaultLink = el('span', { class: 'vault-home-breadcrumb-item vault-home-breadcrumb-root' }, [ el('i', { 'data-lucide': 'database', style: 'width:14px;height:14px' }), el('span', {}, [document.createTextNode(vaultName)]), ]); vaultLink.addEventListener('click', () => TabManager.openNav(vaultName, '')); vaultLink.style.cursor = 'pointer'; parentEl.appendChild(vaultLink); if (!dirPath) return; const segments = dirPath.split('/').filter(Boolean); let cumulative = ''; for (const seg of segments) { cumulative = cumulative ? cumulative + '/' + seg : seg; parentEl.appendChild(el('span', { class: 'vault-home-breadcrumb-sep' }, [document.createTextNode(' / ')])); const target = cumulative; const segLink = el('span', { class: 'vault-home-breadcrumb-item' }, [ el('i', { 'data-lucide': 'folder', style: 'width:14px;height:14px' }), el('span', {}, [document.createTextNode(seg)]), ]); segLink.addEventListener('click', () => TabManager.openNav(vaultName, target)); segLink.style.cursor = 'pointer'; parentEl.appendChild(segLink); } } // --------------------------------------------------------------------------- // Recent files section (root only) // --------------------------------------------------------------------------- async function loadRecentSection(vaultName, container, resultsArea) { try { const data = await api(`/api/recent?mode=opened&vault=${encodeURIComponent(vaultName)}&limit=5`); const files = data.files || []; if (files.length === 0) return; // The view may have been replaced while fetching if (!container.isConnected) return; const section = el('div', { class: 'vault-home-recent' }); const header = el('div', { class: 'vault-home-recent-header' }); header.innerHTML = ` ${t('dashboard.recent')}`; section.appendChild(header); const list = el('div', { class: 'vault-home-recent-list' }); files.forEach((f) => { const title = f.title || f.path.split('/').pop() || ''; const displayTitle = title.endsWith('.md') ? title.slice(0, -3) : title; const dir = f.path.includes('/') ? f.path.split('/').slice(0, -1).join('/') + '/' : ''; const item = el('div', { class: 'vault-home-recent-item' }); item.innerHTML = ` ${escapeHtml(displayTitle)} ${escapeHtml(dir)} ${escapeHtml(f.mtime_human || '')}`; item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path)); item.addEventListener('dblclick', (e) => { e.preventDefault(); TabManager.openPersistent(f.vault, f.path); }); list.appendChild(item); }); section.appendChild(list); // Insert before the results area if (resultsArea && resultsArea.parentNode === container) { container.insertBefore(section, resultsArea); } else { container.appendChild(section); } safeCreateIcons(); } catch { // Silently skip if recent files can't be loaded } } // --------------------------------------------------------------------------- // Utility // --------------------------------------------------------------------------- function formatSize(bytes) { if (bytes < 1024) return bytes + ' ' + t('vaulthome.size_bytes'); if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' ' + t('vaulthome.size_kb'); return (bytes / (1024 * 1024)).toFixed(1) + ' ' + t('vaulthome.size_mb'); } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }