Initialize i18n before auth so t() works when rendering auth-dependent UI, and build the command palette lazily so translations are loaded first. Bump service worker cache version.
385 lines
13 KiB
JavaScript
385 lines
13 KiB
JavaScript
/**
|
|
* Vault Home Page module for ObsiGate.
|
|
*
|
|
* 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 visual style mirrors the search results (.search-result-item) for
|
|
* consistency.
|
|
*
|
|
* Usage:
|
|
* import { showVaultHome, updateVaultHomeDir, isVaultHomeActive } from './vaulthome.js';
|
|
*/
|
|
|
|
import { api } from './auth.js';
|
|
import { el } from './viewer.js';
|
|
import { getFileIcon, safeCreateIcons } from './utils.js';
|
|
import { TabManager } from './ui.js';
|
|
import { t, getLocale } from './i18n.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const state = {
|
|
active: false,
|
|
currentVault: null,
|
|
currentDir: '', // empty = vault root
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Check if vault home is currently displayed. */
|
|
export function isVaultHomeActive() {
|
|
return state.active;
|
|
}
|
|
|
|
/**
|
|
* Display the vault home page for a vault directory.
|
|
* @param {string} vaultName - Vault name
|
|
* @param {string} dirPath - Directory path (empty for root)
|
|
*/
|
|
export async function showVaultHome(vaultName, dirPath = '') {
|
|
if (vaultName === 'all' || !vaultName) {
|
|
state.active = false;
|
|
return;
|
|
}
|
|
|
|
state.active = true;
|
|
state.currentVault = vaultName;
|
|
state.currentDir = dirPath || '';
|
|
|
|
const area = document.getElementById('content-area');
|
|
if (!area) return;
|
|
|
|
// Remove any existing dashboard
|
|
const home = document.getElementById('dashboard-home');
|
|
if (home) home.remove();
|
|
|
|
// Build vault home container
|
|
area.innerHTML = '';
|
|
|
|
const container = el('div', { class: 'vault-home', id: 'vault-home' });
|
|
area.appendChild(container);
|
|
|
|
// Breadcrumb
|
|
renderBreadcrumb(container, vaultName, state.currentDir);
|
|
|
|
// 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>`;
|
|
|
|
await loadAndRender(vaultName, state.currentDir, resultsArea);
|
|
|
|
// 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) {
|
|
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>`;
|
|
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);
|
|
}
|
|
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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)
|
|
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.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;
|
|
const sep = el('span', { class: 'vault-home-breadcrumb-sep' }, [document.createTextNode(' / ')]);
|
|
parentEl.appendChild(sep);
|
|
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.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)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function loadRecentSection(vaultName, container) {
|
|
try {
|
|
const data = await api(`/api/recent?mode=opened&vault=${encodeURIComponent(vaultName)}&limit=5`);
|
|
const files = data.files || [];
|
|
if (files.length === 0) 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>
|
|
<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) {
|
|
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;
|
|
}
|