257 lines
9.0 KiB
JavaScript
257 lines
9.0 KiB
JavaScript
/**
|
||
* 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';
|
||
import { initShortcuts } from './shortcuts.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);
|
||
}
|
||
|
||
export function confirmDialog({
|
||
title,
|
||
message,
|
||
confirmText = 'Confirm',
|
||
cancelText = 'Cancel',
|
||
confirmClass = 'btn--danger',
|
||
}) {
|
||
document.getElementById('confirm-modal')?.remove();
|
||
|
||
return new Promise((resolve) => {
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'confirm-modal';
|
||
overlay.className = 'auth-overlay';
|
||
overlay.tabIndex = -1;
|
||
overlay.innerHTML = `
|
||
<div class="auth-card confirm-card fade-in" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
||
<div class="auth-card__icon confirm-card__icon confirm-card__icon--danger">
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M12 9v4"/>
|
||
<path d="M12 17h.01"/>
|
||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||
</svg>
|
||
</div>
|
||
<h2 id="confirm-title" class="auth-card__title">${title}</h2>
|
||
<p class="auth-card__desc">${message}</p>
|
||
<div class="confirm-card__actions">
|
||
<button type="button" class="btn btn--ghost" data-action="cancel">${cancelText}</button>
|
||
<button type="button" class="btn ${confirmClass}" data-action="confirm">${confirmText}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
const cancelBtn = overlay.querySelector('[data-action="cancel"]');
|
||
const confirmBtn = overlay.querySelector('[data-action="confirm"]');
|
||
|
||
const close = (result) => {
|
||
overlay.remove();
|
||
resolve(result);
|
||
};
|
||
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) close(false);
|
||
});
|
||
|
||
cancelBtn.addEventListener('click', () => close(false));
|
||
confirmBtn.addEventListener('click', () => close(true));
|
||
|
||
overlay.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') close(false);
|
||
});
|
||
|
||
overlay.focus();
|
||
confirmBtn.focus();
|
||
});
|
||
}
|
||
|
||
// ── 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 = `
|
||
<div class="auth-card fade-in">
|
||
<div class="auth-card__icon">
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--cyan)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||
</svg>
|
||
</div>
|
||
<h2 class="auth-card__title">Authentication Required</h2>
|
||
<p class="auth-card__desc">This server requires a Bearer token.<br>Enter the value of <code>API_TOKEN</code> to continue.</p>
|
||
<form id="auth-form" class="auth-card__form">
|
||
<input
|
||
id="auth-token-input"
|
||
type="password"
|
||
class="input"
|
||
placeholder="Enter your API token…"
|
||
autocomplete="current-password"
|
||
spellcheck="false"
|
||
autofocus
|
||
/>
|
||
<button type="submit" class="btn btn--primary" style="width:100%">
|
||
<svg class="icon" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||
Unlock
|
||
</button>
|
||
</form>
|
||
<p id="auth-error" class="auth-card__error" style="display:none">Incorrect token — try again.</p>
|
||
</div>
|
||
`;
|
||
|
||
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();
|
||
initShortcuts();
|
||
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);
|