/** * static/js/app.js * Application bootstrap – initialises all modules, kicks off data fetching, * and exports shared utilities like `toast`. */ import { api, setUnauthorizedHandler, getStoredToken, setStoredToken } from './api.js'; import { state } from './state.js'; import { connectSSE } from './events.js'; import { initInstructions, initComposer, refreshTimestamps } from './instructions.js'; import { initStatus, initConfig, refreshStatusTimestamps } from './status.js'; import { initTheme } from './theme.js'; // ── Toast notification ──────────────────────────────────────────────────── const _toastContainer = document.getElementById('toast-container'); export function toast(message, type = 'info') { const el = document.createElement('div'); el.className = `toast toast--${type}`; el.textContent = message; _toastContainer.appendChild(el); setTimeout(() => { el.style.animation = 'toast-out 240ms cubic-bezier(0.4,0,1,1) forwards'; el.addEventListener('animationend', () => el.remove()); }, 3000); } // ── Token auth modal ────────────────────────────────────────────────────── function showTokenModal(onSuccess) { // Remove any existing modal document.getElementById('auth-modal')?.remove(); const overlay = document.createElement('div'); overlay.id = 'auth-modal'; overlay.className = 'auth-overlay'; overlay.innerHTML = `

Authentication Required

This server requires a Bearer token.
Enter the value of API_TOKEN to continue.

`; document.body.appendChild(overlay); const form = overlay.querySelector('#auth-form'); const input = overlay.querySelector('#auth-token-input'); const errorMsg = overlay.querySelector('#auth-error'); input.focus(); form.addEventListener('submit', async (e) => { e.preventDefault(); const token = input.value.trim(); if (!token) return; // Optimistically store and try a quick auth ping setStoredToken(token); try { await api.status(); overlay.remove(); onSuccess(); } catch (err) { // 401 means wrong token setStoredToken(''); input.value = ''; input.focus(); errorMsg.style.display = ''; } }); } // ── Document title badge ────────────────────────────────────────────────── function updateTitle(instructions) { const pending = (instructions || []).filter(i => i.status === 'pending').length; document.title = pending > 0 ? `(${pending}) local-mcp` : 'local-mcp'; } // ── Reconnecting indicator ──────────────────────────────────────────────── function initReconnectingIndicator() { const serverLed = document.getElementById('led-server'); state.subscribe('sseReconnecting', (reconnecting) => { if (!serverLed) return; if (reconnecting) { serverLed.className = 'led led--amber led--pulse'; serverLed.querySelector('.led__label').textContent = 'Reconnecting…'; } }); } // ── Startup data load ───────────────────────────────────────────────────── async function loadInitialData() { try { const [instructions, status, config] = await Promise.all([ api.listInstructions('all'), api.status(), api.getConfig(), ]); state.set('instructions', instructions.items); state.set('status', status); state.set('config', config); state.set('serverOnline', true); state.set('sseReconnecting', false); } catch (err) { console.error('Failed to load initial data:', err); state.set('serverOnline', false); } } // ── Periodic refresh (fallback for SSE gaps) ────────────────────────────── function startPolling() { setInterval(async () => { try { const s = await api.status(); state.set('status', s); state.set('serverOnline', true); } catch { state.set('serverOnline', false); } }, 15_000); // Refresh relative timestamps every 20 seconds setInterval(() => { refreshTimestamps(); refreshStatusTimestamps(); }, 20_000); } // ── Subscribe to state changes ──────────────────────────────────────────── function initGlobalSubscriptions() { state.subscribe('instructions', updateTitle); } // ── Init ────────────────────────────────────────────────────────────────── async function bootstrap() { initTheme(); initReconnectingIndicator(); initStatus(); initConfig(); initInstructions(); initComposer(); initGlobalSubscriptions(); // Hook 401 handler so mid-session token expiry shows the modal again setUnauthorizedHandler(() => { showTokenModal(async () => { await loadInitialData(); connectSSE(); }); }); // Check whether auth is required before doing any authenticated calls const authInfo = await api.authCheck().catch(() => ({ auth_required: false })); if (authInfo.auth_required && !getStoredToken()) { // Show modal before loading data showTokenModal(async () => { await loadInitialData(); connectSSE(); startPolling(); }); } else { // No auth, or token already stored — proceed normally await loadInitialData(); connectSSE(); startPolling(); } } document.addEventListener('DOMContentLoaded', bootstrap);