feat: onglet de navigation avec facettes, tris et retour Home complet #158
- clic sur un repertoire de l'arbre (modes focus vault et All) ouvre un onglet de navigation dedie qui coexiste avec les onglets de fichiers ouverts
- vue: sous-repertoires cliquables, facettes Vaults/Tags/Extensions (meme mecanisme que la recherche), tris Pertinence/Date, bouton Sauver; tags indexes exposes par GET /api/vault/{v}/files
- BUG-100: showWelcome re-injecte la section Raccourcis & Astuces et goHome efface la recherche globale + le filtre de la sidebar
This commit is contained in:
+416
-214
@@ -1,17 +1,19 @@
|
||||
/**
|
||||
* Vault Home Page module for ObsiGate.
|
||||
* Vault Home / navigation page module for ObsiGate (#158).
|
||||
*
|
||||
* When a specific vault is selected (not "All"), replaces the dashboard
|
||||
* with a non-recursive file listing sorted by modification time (newest first).
|
||||
* Only files from the selected directory are shown (not subdirectories)
|
||||
* to keep the listing fast and lightweight.
|
||||
* The listing updates automatically when browsing into subdirectories.
|
||||
* 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.
|
||||
*
|
||||
* The visual style mirrors the search results (.search-result-item) for
|
||||
* consistency.
|
||||
* 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, updateVaultHomeDir, isVaultHomeActive } from './vaulthome.js';
|
||||
* import { showVaultHome, isVaultHomeActive } from './vaulthome.js';
|
||||
* // …but in practice: TabManager.openNav(vault, dirPath)
|
||||
*/
|
||||
|
||||
import { api } from './auth.js';
|
||||
@@ -19,6 +21,7 @@ import { el } from './viewer.js';
|
||||
import { getFileIcon, safeCreateIcons } from './utils.js';
|
||||
import { TabManager } from './ui.js';
|
||||
import { t, getLocale } from './i18n.js';
|
||||
import { buildNavFacets, filterNavFiles, sortNavFiles, navDisplayName } from './navfacets.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
@@ -27,25 +30,35 @@ import { t, getLocale } from './i18n.js';
|
||||
const state = {
|
||||
active: false,
|
||||
currentVault: null,
|
||||
currentDir: '', // empty = vault root
|
||||
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 vault home is currently displayed. */
|
||||
/** Check if the navigation view is currently displayed. */
|
||||
export function isVaultHomeActive() {
|
||||
return state.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the vault home page for a vault directory.
|
||||
* 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 === 'all' || !vaultName) {
|
||||
if (!vaultName || vaultName === 'all') {
|
||||
state.active = false;
|
||||
return;
|
||||
}
|
||||
@@ -53,6 +66,9 @@ export async function showVaultHome(vaultName, dirPath = '') {
|
||||
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;
|
||||
@@ -61,7 +77,7 @@ export async function showVaultHome(vaultName, dirPath = '') {
|
||||
const home = document.getElementById('dashboard-home');
|
||||
if (home) home.remove();
|
||||
|
||||
// Build vault home container
|
||||
const seq = ++_renderSeq;
|
||||
area.innerHTML = '';
|
||||
|
||||
const container = el('div', { class: 'vault-home', id: 'vault-home' });
|
||||
@@ -70,117 +86,399 @@ export async function showVaultHome(vaultName, dirPath = '') {
|
||||
// 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);
|
||||
|
||||
// Show loading
|
||||
resultsArea.innerHTML = `
|
||||
<div class="tree-loading" style="padding:2rem;text-align:center">
|
||||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
|
||||
<p data-i18n="vaulthome.loading" style="margin-top:0.75rem;color:var(--text-muted);font-size:0.85rem">${t('vaulthome.loading')}</p>
|
||||
</div>`;
|
||||
safeCreateIcons();
|
||||
|
||||
await loadAndRender(vaultName, state.currentDir, resultsArea);
|
||||
_view = { container, facetsHost, resultsArea, vault: vaultName, dirs: [] };
|
||||
|
||||
// Also load recent files for this vault (only at root level)
|
||||
if (!state.currentDir) {
|
||||
loadRecentSection(vaultName, container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the vault home directory without rebuilding the whole page.
|
||||
* Called when navigating into a subdirectory.
|
||||
* @param {string} vaultName
|
||||
* @param {string} dirPath
|
||||
*/
|
||||
export async function updateVaultHomeDir(vaultName, dirPath) {
|
||||
if (!state.active || state.currentVault !== vaultName) return;
|
||||
|
||||
state.currentDir = dirPath || '';
|
||||
|
||||
const container = document.getElementById('vault-home');
|
||||
if (!container) return;
|
||||
|
||||
// Update breadcrumb
|
||||
const bc = container.querySelector('.vault-home-breadcrumb');
|
||||
if (bc) {
|
||||
bc.innerHTML = '';
|
||||
buildBreadcrumbHTML(bc, vaultName, state.currentDir);
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
// Remove recent section when navigating into subdirectory
|
||||
const recentSection = container.querySelector('.vault-home-recent');
|
||||
if (recentSection) recentSection.remove();
|
||||
|
||||
// Reload results
|
||||
const resultsArea = document.getElementById('vault-home-results');
|
||||
if (resultsArea) {
|
||||
// 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 = `
|
||||
<div class="tree-loading" style="padding:2rem;text-align:center">
|
||||
<div class="loading-spinner" style="width:20px;height:20px;border-width:2px;margin:0 auto"></div>
|
||||
<div class="vault-home-error">
|
||||
<p data-i18n="vaulthome.error_loading">${errMsg}</p>
|
||||
</div>`;
|
||||
await loadAndRender(vaultName, state.currentDir, resultsArea);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree click delegation — update vault home on directory clicks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _treeDelegationInstalled = false;
|
||||
|
||||
function _initTreeDelegation() {
|
||||
if (_treeDelegationInstalled) return;
|
||||
|
||||
const tree = document.getElementById('vault-tree');
|
||||
if (!tree) {
|
||||
// Retry once when DOM might not be ready — use MutationObserver as fallback
|
||||
const observer = new MutationObserver(() => {
|
||||
const t = document.getElementById('vault-tree');
|
||||
if (t) {
|
||||
observer.disconnect();
|
||||
_attachTreeListener(t);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
return;
|
||||
}
|
||||
|
||||
_attachTreeListener(tree);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function _attachTreeListener(tree) {
|
||||
if (_treeDelegationInstalled) return;
|
||||
_treeDelegationInstalled = true;
|
||||
|
||||
tree.addEventListener('click', (e) => {
|
||||
if (!state.active) return;
|
||||
|
||||
// Find the closest tree-item that is a directory
|
||||
const item = e.target.closest('.tree-item');
|
||||
if (!item || !item.dataset.vault || item.dataset.path === undefined) return;
|
||||
|
||||
const vault = item.dataset.vault;
|
||||
const path = item.dataset.path;
|
||||
|
||||
// Only react to directory clicks (items that have a child container)
|
||||
const hasChildContainer = item.nextElementSibling
|
||||
&& item.nextElementSibling.classList.contains('tree-children');
|
||||
if (!hasChildContainer) return;
|
||||
|
||||
// Update vault home dir
|
||||
updateVaultHomeDir(vault, path);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize delegation when module loads
|
||||
_initTreeDelegation();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// 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 = '<i data-lucide="bookmark-plus" style="width:14px;height:14px"></i> 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 = '<i data-lucide="chevron-down" style="width:12px;height:12px"></i>';
|
||||
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 = `
|
||||
<div class="vault-home-empty">
|
||||
<i data-lucide="folder-open" style="width:32px;height:32px;color:var(--text-muted)"></i>
|
||||
<p data-i18n="vaulthome.empty">${t('vaulthome.empty')}</p>
|
||||
</div>`;
|
||||
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 = `
|
||||
<i data-lucide="folders" style="width:14px;height:14px"></i>
|
||||
<span data-i18n="vaulthome.directories">${t('vaulthome.directories')}</span>`;
|
||||
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 });
|
||||
item.innerHTML = `
|
||||
<i data-lucide="folder" style="width:14px;height:14px;opacity:0.6;flex-shrink:0"></i>
|
||||
<span class="vault-home-recent-title">${escapeHtml(d.name)}</span>
|
||||
<span class="vault-home-recent-time">(${d.children_count})</span>`;
|
||||
item.addEventListener('click', () => TabManager.openNav(state.currentVault, d.path));
|
||||
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);
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Breadcrumb
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderBreadcrumb(container, vaultName, dirPath) {
|
||||
@@ -190,12 +488,13 @@ function renderBreadcrumb(container, vaultName, dirPath) {
|
||||
}
|
||||
|
||||
function buildBreadcrumbHTML(parentEl, vaultName, dirPath) {
|
||||
// Vault icon + name (root)
|
||||
// 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', () => updateVaultHomeDir(vaultName, ''));
|
||||
vaultLink.addEventListener('click', () => TabManager.openNav(vaultName, ''));
|
||||
vaultLink.style.cursor = 'pointer';
|
||||
parentEl.appendChild(vaultLink);
|
||||
|
||||
@@ -205,126 +504,31 @@ function buildBreadcrumbHTML(parentEl, vaultName, dirPath) {
|
||||
let cumulative = '';
|
||||
for (const seg of segments) {
|
||||
cumulative = cumulative ? cumulative + '/' + seg : seg;
|
||||
const sep = el('span', { class: 'vault-home-breadcrumb-sep' }, [document.createTextNode(' / ')]);
|
||||
parentEl.appendChild(sep);
|
||||
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', () => updateVaultHomeDir(vaultName, cumulative));
|
||||
segLink.addEventListener('click', () => TabManager.openNav(vaultName, target));
|
||||
segLink.style.cursor = 'pointer';
|
||||
parentEl.appendChild(segLink);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRender(vaultName, dirPath, container) {
|
||||
try {
|
||||
const url = `/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(dirPath)}&limit=200&recursive=false`;
|
||||
const data = await api(url);
|
||||
|
||||
if (!data.files || data.files.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="vault-home-empty">
|
||||
<i data-lucide="folder-open" style="width:32px;height:32px;color:var(--text-muted)"></i>
|
||||
<p data-i18n="vaulthome.empty">${t('vaulthome.empty')}</p>
|
||||
</div>`;
|
||||
safeCreateIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Header
|
||||
const header = el('div', { class: 'vault-home-header' });
|
||||
const countKey = data.files.length > 1 ? 'vaulthome.file_count_plural' : 'vaulthome.file_count';
|
||||
const countStr = t(countKey, { count: data.files.length });
|
||||
const count = el('span', { class: 'vault-home-count', 'data-i18n': countKey }, [
|
||||
document.createTextNode(countStr),
|
||||
]);
|
||||
header.appendChild(count);
|
||||
|
||||
// Directory label
|
||||
const dirLabel = dirPath ? `/${dirPath}` : ` ${t('common.root')}`;
|
||||
const dirInfo = el('span', { class: 'vault-home-dir-info' }, [
|
||||
document.createTextNode(dirLabel),
|
||||
]);
|
||||
header.appendChild(dirInfo);
|
||||
|
||||
container.innerHTML = '';
|
||||
container.appendChild(header);
|
||||
|
||||
// File list — using search-result-item style
|
||||
const list = el('div', { class: 'search-results' });
|
||||
const locale = getLocale();
|
||||
|
||||
data.files.forEach((f) => {
|
||||
const fileIconName = getFileIcon ? getFileIcon(f.name) : 'file';
|
||||
const displayName = f.name.endsWith('.md') ? f.name.slice(0, -3) : f.name;
|
||||
const ext = f.extension || '';
|
||||
|
||||
const titleDiv = el('div', { class: 'search-result-title' }, [
|
||||
document.createTextNode(displayName),
|
||||
]);
|
||||
|
||||
// Format date using current locale
|
||||
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',
|
||||
});
|
||||
|
||||
// Build metadata line: date + size
|
||||
const metaParts = [];
|
||||
metaParts.push(dateStr);
|
||||
metaParts.push(formatSize(f.size));
|
||||
|
||||
const metaDiv = el('div', { class: 'search-result-vault' }, [
|
||||
document.createTextNode(metaParts.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,
|
||||
]);
|
||||
|
||||
item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path));
|
||||
item.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
TabManager.openPersistent(f.vault, f.path);
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
container.appendChild(list);
|
||||
safeCreateIcons();
|
||||
} catch (err) {
|
||||
const errMsg = t('vaulthome.error_loading') + ' ' + escapeHtml(err?.detail || err?.message || t('common.unknown'));
|
||||
container.innerHTML = `
|
||||
<div class="vault-home-error">
|
||||
<p data-i18n="vaulthome.error_loading">${errMsg}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent files section (loaded at vault root only)
|
||||
// Recent files section (root only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadRecentSection(vaultName, container) {
|
||||
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;
|
||||
|
||||
// Insert recent section BEFORE the results area
|
||||
const resultsArea = document.getElementById('vault-home-results');
|
||||
const section = el('div', { class: 'vault-home-recent' });
|
||||
|
||||
const header = el('div', { class: 'vault-home-recent-header' });
|
||||
header.innerHTML = `
|
||||
<i data-lucide="clock" style="width:14px;height:14px"></i>
|
||||
@@ -351,16 +555,14 @@ async function loadRecentSection(vaultName, container) {
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
section.appendChild(list);
|
||||
|
||||
// Insert before the results area
|
||||
if (resultsArea) {
|
||||
if (resultsArea && resultsArea.parentNode === container) {
|
||||
container.insertBefore(section, resultsArea);
|
||||
} else {
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
safeCreateIcons();
|
||||
} catch {
|
||||
// Silently skip if recent files can't be loaded
|
||||
|
||||
Reference in New Issue
Block a user