- clic sur le titre: la page d'accueil s'ouvre dans un onglet Accueil (icone Maison) epingle en tete de barre, reactivable d'un clic (onglet propre au panneau actif en split) - page de navigation: menus contextuels des repertoires et des fichiers identiques a ceux de la sidebar, clic sur la racine d'un vault qui ouvre la vue, icone dossier a la couleur de l'arbre de navigation - BUG-101: bloc « Keyboard shortcuts for tabs » duplique dans ui.js (un seul Ctrl+W fermait deux onglets) - BUG-102: le menu contextuel se refermait 10 ms apres son ouverture (scroll de reflow des icones lucide)
600 lines
22 KiB
JavaScript
600 lines
22 KiB
JavaScript
/**
|
||
* 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 = `
|
||
<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();
|
||
|
||
_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 = `
|
||
<div class="vault-home-error">
|
||
<p data-i18n="vaulthome.error_loading">${errMsg}</p>
|
||
</div>`;
|
||
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 = '<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 });
|
||
// Même couleur d'icône que l'arbre de la sidebar (.tree-item .icon)
|
||
item.innerHTML = `
|
||
<i data-lucide="folder" class="icon" style="width:16px;height:16px;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));
|
||
// 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 = `
|
||
<i data-lucide="clock" style="width:14px;height:14px"></i>
|
||
<span data-i18n="dashboard.recent">${t('dashboard.recent')}</span>`;
|
||
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 = `
|
||
<i data-lucide="${getFileIcon ? getFileIcon(f.path) : 'file'}" style="width:14px;height:14px;opacity:0.6;flex-shrink:0"></i>
|
||
<span class="vault-home-recent-title">${escapeHtml(displayTitle)}</span>
|
||
<span class="vault-home-recent-dir">${escapeHtml(dir)}</span>
|
||
<span class="vault-home-recent-time">${escapeHtml(f.mtime_human || '')}</span>`;
|
||
|
||
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;
|
||
}
|