import { isDatabaseReady, getStatistics, closeDatabase } from './database.js';
import { initSearch, clearSearch, getSearchState, setSearchRoute } from './search.js';
import {
initConversationViewer,
loadConversation,
clearViewer,
cleanupConversationViewer,
getCurrentConversation,
} from './conversation.js';
import { createRouter, getRouter, parseSearchParams, buildConversationPath, buildSearchPath } from './router.js';
import { getConversationLink, copyConversationLink, isWebShareAvailable, shareConversation } from './share.js';
import { initStats, renderStatsDashboard, clearStatsCache } from './stats.js';
import { initStorage, StorageKeys } from './storage.js';
import { initSettings, render as renderSettings, cleanupSettings } from './settings.js';
const state = {
view: 'search', conversationId: null,
messageId: null,
searchQuery: '',
searchFilters: {
agent: null,
since: null,
until: null,
timePreset: null,
},
initialized: false,
};
let router = null;
let storageReady = null;
let settingsReady = false;
let waitingForDatabaseReady = false;
let viewerLifecycleEpoch = 0;
let elements = {
appContent: null,
searchView: null,
conversationView: null,
settingsView: null,
statsView: null,
notFoundView: null,
statsDisplay: null,
navBar: null,
};
export function init() {
console.log('[Viewer] Initializing...');
elements.appContent = document.getElementById('app-content');
if (!elements.appContent) {
console.error('[Viewer] App content container not found');
return;
}
if (state.initialized) {
if (!isDatabaseReady()) {
console.log('[Viewer] Waiting for database re-open...');
ensureDatabaseReadyListener();
return;
}
refreshAfterDatabaseReady();
return;
}
if (!isDatabaseReady()) {
console.log('[Viewer] Waiting for database...');
ensureDatabaseReadyListener();
return;
}
initializeViews();
}
function ensureDatabaseReadyListener() {
if (waitingForDatabaseReady) {
return;
}
waitingForDatabaseReady = true;
window.addEventListener('cass:db-ready', handleDatabaseReady);
}
function handleDatabaseReady(event) {
console.log('[Viewer] Database ready:', event.detail);
window.removeEventListener('cass:db-ready', handleDatabaseReady);
waitingForDatabaseReady = false;
if (state.initialized) {
refreshAfterDatabaseReady();
return;
}
initializeViews();
}
function initializeViews() {
const lifecycleEpoch = ++viewerLifecycleEpoch;
elements.appContent.innerHTML = '';
createViewContainers();
window.showNotification = showNotification;
applyStoredTheme();
storageReady = initStorage().then(() => ({ ok: true })).catch((error) => {
console.warn('[Viewer] Storage init failed:', error);
return { ok: false, error };
});
storageReady.then((result) => {
void initializeSettingsAfterStorageReady(result, lifecycleEpoch);
});
initSearch(elements.searchView, handleResultSelect);
initConversationViewer(elements.conversationView, handleBackToSearch);
initStats(elements.statsView);
router = createRouter({
onNavigate: handleRouteChange,
});
window.addEventListener('cass:lock', handleGlobalLock);
state.initialized = true;
console.log('[Viewer] Initialized with hash-based routing');
}
function refreshAfterDatabaseReady() {
if (!state.initialized) {
initializeViews();
return;
}
switch (state.view) {
case 'conversation':
if (state.conversationId) {
handleConversationRoute(state.conversationId, state.messageId);
return;
}
break;
case 'settings':
handleSettingsRoute();
return;
case 'stats':
handleStatsRoute();
return;
case 'not-found':
handleNotFoundRoute(window.location.hash || '/');
return;
default:
break;
}
handleSearchRoute({
query: {
q: state.searchQuery,
agent: state.searchFilters.agent,
since: state.searchFilters.since,
until: state.searchFilters.until,
time: state.searchFilters.timePreset && state.searchFilters.timePreset !== 'custom'
? state.searchFilters.timePreset
: null,
},
});
}
function createViewContainers() {
elements.appContent.innerHTML = `
<nav id="nav-bar" class="nav-bar">
<div class="nav-brand">
<a href="#/" class="nav-logo">cass Archive</a>
</div>
<div class="nav-links">
<a href="#/" class="nav-link" data-view="search">Search</a>
<a href="#/stats" class="nav-link" data-view="stats">Stats</a>
<a href="#/settings" class="nav-link" data-view="settings">Settings</a>
</div>
</nav>
<div id="stats-display" class="stats-display"></div>
<div id="search-view" class="view-container"></div>
<div id="conversation-view" class="view-container hidden"></div>
<div id="settings-view" class="view-container hidden"></div>
<div id="stats-view" class="view-container hidden"></div>
<div id="not-found-view" class="view-container hidden"></div>
`;
elements.navBar = document.getElementById('nav-bar');
elements.searchView = document.getElementById('search-view');
elements.conversationView = document.getElementById('conversation-view');
elements.settingsView = document.getElementById('settings-view');
elements.statsView = document.getElementById('stats-view');
elements.notFoundView = document.getElementById('not-found-view');
elements.statsDisplay = document.getElementById('stats-display');
setupNavLinks();
}
function setupNavLinks() {
const navLinks = elements.navBar.querySelectorAll('.nav-link');
navLinks.forEach(link => {
link.addEventListener('click', (e) => {
updateActiveNavLink(link.dataset.view);
});
});
}
function updateActiveNavLink(activeView) {
const navLinks = elements.navBar.querySelectorAll('.nav-link');
navLinks.forEach(link => {
if (link.dataset.view === activeView) {
link.classList.add('active');
} else {
link.classList.remove('active');
}
});
}
function handleRouteChange(route) {
console.debug('[Viewer] Route change:', route);
const { view, params, query } = route;
const leavingConversation = state.view === 'conversation' && view !== 'conversation';
const leavingSearch = state.view === 'search' && view !== 'search';
const leavingStats = state.view === 'stats' && view !== 'stats';
if (leavingConversation) {
clearViewer();
}
if (leavingSearch) {
clearSearch({ reloadRecent: false });
}
if (leavingStats) {
clearStatsCache();
}
switch (view) {
case 'search':
handleSearchRoute(route);
break;
case 'conversation':
handleConversationRoute(params.conversationId, params.messageId);
break;
case 'settings':
handleSettingsRoute();
break;
case 'stats':
handleStatsRoute();
break;
case 'not-found':
default:
handleNotFoundRoute(params.path || route.raw);
break;
}
}
function handleSearchRoute(route = { query: {} }) {
const searchParams = parseSearchParams(route);
state.view = 'search';
state.conversationId = null;
state.messageId = null;
state.searchQuery = searchParams.query;
state.searchFilters = {
agent: searchParams.agent,
since: searchParams.since,
until: searchParams.until,
timePreset: searchParams.timePreset,
};
showViewContainer('search');
displayStats();
updateActiveNavLink('search');
if (state.searchQuery || state.searchFilters.agent || state.searchFilters.since || state.searchFilters.until || state.searchFilters.timePreset) {
console.debug('[Viewer] Search route from URL:', searchParams);
setSearchRoute(searchParams).catch((error) => {
console.warn('[Viewer] Failed to run search route from URL:', error);
});
return;
}
clearSearch({ reloadRecent: true });
}
function handleConversationRoute(conversationId, messageId = null) {
if (!conversationId) {
handleNotFoundRoute('/c/');
return;
}
state.view = 'conversation';
state.conversationId = conversationId;
state.messageId = messageId;
showViewContainer('conversation');
loadConversation(conversationId, messageId);
if (elements.statsDisplay) {
elements.statsDisplay.classList.add('hidden');
}
updateActiveNavLink(null);
}
function handleSettingsRoute() {
state.view = 'settings';
state.conversationId = null;
state.messageId = null;
showViewContainer('settings');
renderSettingsPanel();
if (elements.statsDisplay) {
elements.statsDisplay.classList.add('hidden');
}
updateActiveNavLink('settings');
}
function handleStatsRoute() {
state.view = 'stats';
state.conversationId = null;
state.messageId = null;
showViewContainer('stats');
renderStatsPanel();
if (elements.statsDisplay) {
elements.statsDisplay.classList.add('hidden');
}
updateActiveNavLink('stats');
}
function handleNotFoundRoute(path) {
state.view = 'not-found';
showViewContainer('not-found');
renderNotFoundPanel(path);
if (elements.statsDisplay) {
elements.statsDisplay.classList.add('hidden');
}
updateActiveNavLink(null);
}
function showViewContainer(viewName) {
elements.searchView.classList.add('hidden');
elements.conversationView.classList.add('hidden');
elements.settingsView.classList.add('hidden');
elements.statsView.classList.add('hidden');
elements.notFoundView.classList.add('hidden');
switch (viewName) {
case 'search':
elements.searchView.classList.remove('hidden');
elements.statsDisplay.classList.remove('hidden');
break;
case 'conversation':
elements.conversationView.classList.remove('hidden');
break;
case 'settings':
elements.settingsView.classList.remove('hidden');
break;
case 'stats':
elements.statsView.classList.remove('hidden');
break;
case 'not-found':
elements.notFoundView.classList.remove('hidden');
break;
}
}
function displayStats() {
try {
const stats = getStatistics();
elements.statsDisplay.innerHTML = `
<div class="stats-container">
<div class="stat-item">
<span class="stat-value">${stats.conversations}</span>
<span class="stat-label">Conversations</span>
</div>
<div class="stat-item">
<span class="stat-value">${stats.messages}</span>
<span class="stat-label">Messages</span>
</div>
<div class="stat-item">
<span class="stat-value">${stats.agents.length}</span>
<span class="stat-label">Agents</span>
</div>
</div>
`;
elements.statsDisplay.classList.remove('hidden');
} catch (error) {
console.error('[Viewer] Failed to display stats:', error);
elements.statsDisplay.innerHTML = '';
}
}
function renderSettingsPanel() {
if (storageReady) {
storageReady.then((result) => {
void renderSettingsPanelAfterStorageReady(result);
});
return;
}
if (settingsReady) {
void renderSettingsPanelNow();
}
}
async function initializeSettingsAfterStorageReady(result, lifecycleEpoch) {
if (lifecycleEpoch !== viewerLifecycleEpoch) {
return;
}
if (!result?.ok) {
settingsReady = false;
return;
}
try {
await initSettings(elements.settingsView, {
onSessionReset: handleSessionReset,
});
if (lifecycleEpoch !== viewerLifecycleEpoch) {
return;
}
settingsReady = true;
} catch (error) {
console.error('[Viewer] Failed to initialize settings:', error);
settingsReady = false;
if (state.initialized && state.view === 'settings') {
renderSettingsErrorPanel('Settings could not be initialized for this archive.');
}
}
}
async function renderSettingsPanelAfterStorageReady(result) {
if (!result?.ok) {
if (state.initialized && state.view === 'settings') {
renderSettingsErrorPanel('Settings are unavailable because browser storage failed to initialize.');
}
return;
}
if (!settingsReady || !state.initialized || state.view !== 'settings') {
return;
}
await renderSettingsPanelNow();
}
async function renderSettingsPanelNow() {
try {
await renderSettings();
} catch (error) {
console.error('[Viewer] Failed to render settings panel:', error);
renderSettingsErrorPanel('Settings could not be rendered for this archive.');
}
}
function applyTheme(theme) {
const root = document.documentElement;
if (theme === 'auto') {
root.removeAttribute('data-theme');
} else {
root.setAttribute('data-theme', theme);
}
}
function applyStoredTheme() {
try {
const theme = localStorage.getItem(StorageKeys.THEME) || 'auto';
applyTheme(theme);
} catch (error) {
}
}
function renderStatsPanel() {
renderStatsDashboard();
}
function renderNotFoundPanel(path) {
elements.notFoundView.innerHTML = `
<div class="panel not-found-panel">
<div class="not-found-content">
<div class="not-found-icon">404</div>
<h2>Page Not Found</h2>
<p>The requested page <code>${escapeHtml(path || 'unknown')}</code> could not be found.</p>
<a href="#/" class="btn btn-primary">Go to Search</a>
</div>
</div>
`;
}
function renderSettingsErrorPanel(message) {
if (!elements.settingsView) {
return;
}
elements.settingsView.innerHTML = `
<div class="panel settings-panel">
<header class="panel-header">
<h2>Settings</h2>
</header>
<div class="panel-content">
<p>${escapeHtml(message)}</p>
</div>
</div>
`;
}
function handleResultSelect(conversationId, messageId = null) {
if (router) {
router.goToConversation(conversationId, messageId);
}
}
function handleBackToSearch() {
clearViewer();
if (router) {
const searchState = getSearchState();
router.navigate(buildSearchPath(searchState.query, searchState.filters));
}
}
function syncLockedViewerState() {
state.view = 'search';
state.conversationId = null;
state.messageId = null;
state.searchQuery = '';
state.searchFilters = {
agent: null,
since: null,
until: null,
timePreset: null,
};
if (window?.location?.href) {
const url = new URL(window.location.href);
url.hash = '/';
if (window.history?.replaceState) {
window.history.replaceState(null, '', url.toString());
} else {
window.location.replace(url.toString());
}
}
}
function handleSessionReset(action) {
syncLockedViewerState();
cleanup();
window.dispatchEvent(new CustomEvent('cass:lock', {
detail: { action, source: 'viewer' },
}));
}
function handleGlobalLock(event) {
if (event?.detail?.source === 'viewer') {
return;
}
syncLockedViewerState();
cleanup();
}
export function navigateToConversation(conversationId, messageId = null) {
if (router) {
router.goToConversation(conversationId, messageId);
}
}
export function navigateToSearch(query = null, filters = {}) {
if (router) {
router.navigate(buildSearchPath(query || '', filters));
}
}
export function getCurrentShareLink() {
if (state.view === 'conversation' && state.conversationId) {
return getConversationLink(state.conversationId, state.messageId);
}
return null;
}
export async function copyCurrentLink() {
if (state.view === 'conversation' && state.conversationId) {
const result = await copyConversationLink(state.conversationId, state.messageId);
if (result.success) {
showNotification('Link copied to clipboard', 'success');
} else {
showNotification('Failed to copy link', 'error');
}
return result;
}
return { success: false, link: null };
}
export async function shareCurrentConversation() {
if (state.view === 'conversation' && state.conversationId) {
const conv = getCurrentConversation();
const title = conv?.title || 'Conversation';
const success = await shareConversation(state.conversationId, title, state.messageId);
return success;
}
return false;
}
function showNotification(message, type = 'info') {
let toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
toastContainer.className = 'toast-container';
document.body.appendChild(toastContainer);
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
toastContainer.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-fade-out');
setTimeout(() => {
toast.remove();
}, 300);
}, 3000);
}
function formatAgentName(agent) {
if (agent === undefined || agent === null || agent === '') return 'Unknown';
const value = String(agent);
return value.charAt(0).toUpperCase() + value.slice(1).replace(/_/g, ' ');
}
function formatDate(timestamp) {
if (!timestamp) return 'Unknown';
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
export function cleanup() {
viewerLifecycleEpoch += 1;
if (router) {
router.destroy();
router = null;
}
window.removeEventListener('cass:db-ready', handleDatabaseReady);
window.removeEventListener('cass:lock', handleGlobalLock);
waitingForDatabaseReady = false;
storageReady = null;
settingsReady = false;
state.initialized = false;
cleanupSettings();
closeDatabase();
clearSearch({ reloadRecent: false });
cleanupConversationViewer();
clearStatsCache();
console.log('[Viewer] Cleaned up');
}
export function getState() {
return { ...state };
}
export function getViewerRouter() {
return router;
}
export default {
init,
cleanup,
getState,
getViewerRouter,
navigateToConversation,
navigateToSearch,
getCurrentShareLink,
copyCurrentLink,
shareCurrentConversation,
};