zetviel/static/index.html
Emil Lerch 0b804a0d63
All checks were successful
Generic zig build / build (push) Successful in 52s
add authn
2025-10-15 22:41:44 -07:00

400 lines
16 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zetviel</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.5; background: #1e1e1e; color: #e0e0e0; }
header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid #333; background: #252525; }
h1 { font-size: 1.5rem; }
.header-left { display: flex; align-items: center; gap: 1rem; }
.help-icon { cursor: pointer; font-size: 1.2rem; color: #0066cc; }
.help-icon:hover { color: #0052a3; }
.status-ok { color: #0f0; }
.status-error { color: #f00; }
.status-loading { color: #ff0; }
.search-bar { padding: 1rem; border-bottom: 1px solid #333; background: #252525; }
#search { width: 70%; padding: 0.5rem; border: 1px solid #444; border-radius: 4px; background: #2a2a2a; color: #e0e0e0; }
button { padding: 0.5rem 1rem; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #0052a3; }
button:disabled { background: #444; cursor: not-allowed; }
.container { display: flex; height: calc(100vh - 140px); }
.thread-list { width: 40%; overflow-y: auto; border-right: 1px solid #333; }
.thread { padding: 1rem; border-bottom: 1px solid #2a2a2a; cursor: pointer; }
.thread:hover { background: #2a2a2a; }
.thread-subject { font-weight: bold; margin-bottom: 0.25rem; }
.thread-authors { color: #999; font-size: 0.9rem; }
.thread-date { color: #666; font-size: 0.85rem; }
.message-view { width: 60%; overflow-y: auto; padding: 1rem; }
.message { margin-bottom: 2rem; padding: 1rem; border: 1px solid #333; border-radius: 4px; background: #252525; }
.message-header { margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid #333; }
.message-content { margin-top: 1rem; }
.content { padding: 1rem; background: #2a2a2a; border-radius: 4px; }
.content pre { white-space: pre-wrap; word-wrap: break-word; }
.attachments { margin-top: 1rem; padding: 0.5rem; background: #3a3a00; border-radius: 4px; }
.loading { padding: 1rem; text-align: center; color: #999; }
.error { padding: 1rem; background: #3a0000; border: 1px solid #600; border-radius: 4px; margin: 1rem; }
.help-overlay { display: none; position: fixed; bottom: 1rem; right: 1rem; width: 425px; max-height: 500px; overflow-y: auto; background: #252525; border: 1px solid #444; border-radius: 8px; padding: 1rem; box-shadow: 0 4px 12px rgba(0,0,0,0.5); z-index: 1000; font-size: 0.85rem; }
.help-overlay.visible { display: block; }
.help-overlay h3 { margin-bottom: 0.5rem; color: #0066cc; }
.help-overlay code { background: #2a2a2a; padding: 0.2rem 0.4rem; border-radius: 3px; color: #4ec9b0; }
.help-overlay ul { list-style: none; margin-top: 0.5rem; }
.help-overlay li { margin: 0.5rem 0; }
.login-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 2000; align-items: center; justify-content: center; }
.login-overlay.visible { display: flex; }
.login-box { background: #252525; border: 1px solid #444; border-radius: 8px; padding: 2rem; width: 300px; }
.login-box h2 { margin-bottom: 1rem; }
.login-box input { width: 100%; padding: 0.5rem; margin-bottom: 1rem; border: 1px solid #444; border-radius: 4px; background: #2a2a2a; color: #e0e0e0; }
.login-box button { width: 100%; }
</style>
</head>
<body>
<div id="app">
<header>
<div class="header-left">
<h1>Zetviel</h1>
<span class="help-icon" onclick="toggleHelp()" title="Help (?)">?</span>
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
<span id="auth-status" style="font-size: 0.9rem; color: #999;"></span>
<div id="status" class="status-ok"></div>
</div>
</header>
<div class="search-bar">
<input type="text" id="search" placeholder="Search (e.g., tag:inbox)">
<button onclick="search()">Search</button>
</div>
<div class="container">
<div id="thread-list" class="thread-list"></div>
<div id="message-view" class="message-view"></div>
</div>
<div id="help-overlay" class="help-overlay">
<h3>Notmuch Search Syntax</h3>
<ul>
<li><code>tag:inbox</code> - Messages with inbox tag</li>
<li><code>from:alice</code> - From alice</li>
<li><code>to:bob</code> - To bob</li>
<li><code>subject:meeting</code> - Subject contains "meeting"</li>
<li><code>date:today</code> - Messages from today</li>
<li><code>date:yesterday</code> - Messages from yesterday</li>
<li><code>date:7d..</code> - Last 7 days</li>
<li><code>tag:unread AND from:alice</code> - Combine with AND</li>
<li><code>tag:inbox OR tag:sent</code> - Combine with OR</li>
<li><code>NOT tag:spam</code> - Exclude spam</li>
<li><code>attachment:pdf</code> - Has PDF attachment</li>
<li><code>*</code> - All messages</li>
</ul>
<h3 style="margin-top: 1rem;">Keyboard Shortcuts</h3>
<ul>
<li><code>/</code> - Focus search</li>
<li><code>j</code> - Next thread</li>
<li><code>k</code> - Previous thread</li>
<li><code>t</code> - Toggle HTML/text</li>
<li><code>?</code> - Toggle this help</li>
</ul>
</div>
<div id="login-overlay" class="login-overlay">
<div class="login-box">
<h2>Login Required</h2>
<input type="text" id="login-username" placeholder="Username" autocomplete="username">
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password">
<button onclick="submitLogin()">Login</button>
</div>
</div>
</div>
<script>
let currentQuery = 'tag:inbox';
let isLoading = false;
let currentThreadIndex = -1;
let threads = [];
let currentMessageId = null;
let authCredentials = null;
async function api(endpoint) {
const headers = {};
if (authCredentials) {
headers['Authorization'] = `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}`;
}
const res = await fetch(`/api/${endpoint}`, { headers });
if (res.status === 401) {
const success = await promptLogin();
if (!success) throw new Error('Authentication required');
headers['Authorization'] = `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}`;
const retryRes = await fetch(`/api/${endpoint}`, { headers });
if (!retryRes.ok) throw new Error(`API error: ${retryRes.status}`);
return retryRes.json();
}
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
async function checkAuthStatus() {
try {
await api('auth/status');
document.getElementById('auth-status').textContent = 'Logged in';
document.getElementById('auth-status').style.color = '#0f0';
return true;
} catch (e) {
document.getElementById('auth-status').textContent = '';
return false;
}
}
let loginResolve = null;
function promptLogin() {
return new Promise((resolve) => {
loginResolve = resolve;
document.getElementById('login-overlay').classList.add('visible');
document.getElementById('login-username').value = '';
document.getElementById('login-password').value = '';
document.getElementById('login-username').focus();
});
}
async function submitLogin() {
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
if (!username || !password) return;
authCredentials = { username, password };
try {
await fetch('/api/auth/status', {
headers: { 'Authorization': `Basic ${btoa(`${username}:${password}`)}` }
}).then(r => {
if (!r.ok) throw new Error('Invalid credentials');
});
sessionStorage.setItem('authCredentials', JSON.stringify(authCredentials));
document.getElementById('auth-status').textContent = 'Logged in';
document.getElementById('auth-status').style.color = '#0f0';
document.getElementById('login-overlay').classList.remove('visible');
if (loginResolve) loginResolve(true);
} catch (e) {
authCredentials = null;
alert('Login failed');
if (loginResolve) loginResolve(false);
}
}
function setStatus(state) {
const status = document.getElementById('status');
status.className = state === 'loading' ? 'status-loading' : state === 'ok' ? 'status-ok' : 'status-error';
}
function setLoading(loading) {
isLoading = loading;
const btn = document.querySelector('.search-bar button');
btn.disabled = loading;
btn.textContent = loading ? 'Loading...' : 'Search';
}
async function search() {
if (isLoading) return;
const query = document.getElementById('search').value;
currentQuery = query;
history.pushState({ query }, '', `/?q=${encodeURIComponent(query)}`);
await loadThreads(query);
}
async function loadThreads(query) {
setLoading(true);
setStatus('loading');
const list = document.getElementById('thread-list');
try {
threads = await api(`query/${encodeURIComponent(query)}`);
currentThreadIndex = -1;
if (!threads || threads.length === 0) {
list.innerHTML = '<div class="loading">No threads found</div>';
} else {
list.innerHTML = threads.map((t, i) => `
<div class="thread" data-index="${i}" onclick="loadThread('${t.thread_id}', ${i})">
<div class="thread-subject">${escapeHtml(t.subject)}</div>
<div class="thread-authors">${escapeHtml(t.authors)}</div>
<div class="thread-date">${new Date(t.newest_date * 1000).toLocaleString()}</div>
</div>
`).join('');
}
setStatus('ok');
} catch (e) {
list.innerHTML = `<div class="error">Error loading threads: ${escapeHtml(e.message)}</div>`;
setStatus('error');
} finally {
setLoading(false);
}
}
async function loadThread(threadId, index) {
currentThreadIndex = index;
highlightThread(index);
setStatus('loading');
const view = document.getElementById('message-view');
view.innerHTML = '<div class="loading">Loading messages...</div>';
try {
const messages = await api(`thread/${threadId}`);
view.innerHTML = messages.map(m => `
<div class="message">
<div class="message-header">
<strong>From:</strong> ${escapeHtml(m.from || '')}<br>
<strong>To:</strong> ${escapeHtml(m.to || '')}<br>
<strong>Date:</strong> ${escapeHtml(m.date || '')}<br>
<strong>Subject:</strong> ${escapeHtml(m.subject || '')}
</div>
<div id="msg-${m.message_id}" class="message-content"><div class="loading">Loading content...</div></div>
</div>
`).join('');
setStatus('ok');
messages.forEach(m => loadMessageContent(m.message_id));
if (messages.length > 0) currentMessageId = messages[0].message_id;
} catch (e) {
view.innerHTML = `<div class="error">Error loading thread: ${escapeHtml(e.message)}</div>`;
setStatus('error');
}
}
function highlightThread(index) {
document.querySelectorAll('.thread').forEach(t => t.style.background = '');
const thread = document.querySelector(`.thread[data-index="${index}"]`);
if (thread) {
thread.style.background = '#2a2a2a';
thread.scrollIntoView({ block: 'nearest' });
}
}
async function loadMessageContent(messageId) {
const div = document.getElementById(`msg-${messageId}`);
try {
const msg = await api(`message/${messageId}`);
div.innerHTML = `
${msg.html_content ? `<button onclick="showHtml('${messageId}')">Show HTML Version</button>` : ''}
<br>
<div class="content"><pre>${escapeHtml(msg.text_content)}</pre></div>
${msg.attachments.length ? `<div class="attachments">Attachments: ${msg.attachments.map(a => escapeHtml(a.filename)).join(', ')}</div>` : ''}
`;
div.dataset.html = msg.html_content || '';
div.dataset.showingHtml = 'false';
} catch (e) {
div.innerHTML = `<div class="error">Error loading message: ${escapeHtml(e.message)}</div>`;
}
}
function showHtml(messageId) {
const div = document.getElementById(`msg-${messageId}`);
const html = div.dataset.html;
if (html) {
div.innerHTML = `
<button onclick="loadMessageContent('${messageId}')">Show Text Version</button>
<br>
<div class="content">${html}</div>
`;
div.dataset.showingHtml = 'true';
}
}
function toggleHtmlText() {
if (!currentMessageId) return;
const div = document.getElementById(`msg-${currentMessageId}`);
if (!div) return;
if (div.dataset.showingHtml === 'true') {
loadMessageContent(currentMessageId);
} else {
showHtml(currentMessageId);
}
}
function toggleHelp() {
const overlay = document.getElementById('help-overlay');
overlay.classList.toggle('visible');
}
function moveThread(direction) {
if (threads.length === 0) return;
const newIndex = currentThreadIndex + direction;
if (newIndex >= 0 && newIndex < threads.length) {
loadThread(threads[newIndex].thread_id, newIndex);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
window.addEventListener('DOMContentLoaded', async () => {
// Restore credentials from sessionStorage
const stored = sessionStorage.getItem('authCredentials');
if (stored) {
authCredentials = JSON.parse(stored);
document.getElementById('auth-status').textContent = 'Logged in';
document.getElementById('auth-status').style.color = '#0f0';
}
const params = new URLSearchParams(location.search);
const query = params.get('q');
if (query) {
document.getElementById('search').value = query;
if (authCredentials) {
loadThreads(query);
}
}
document.getElementById('search').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
search();
e.target.blur();
}
});
document.addEventListener('keydown', (e) => {
if (document.activeElement.id === 'search') return;
if (document.getElementById('login-overlay').classList.contains('visible')) {
if (e.key === 'Enter') {
e.preventDefault();
submitLogin();
}
return;
}
if (e.key === '/') {
e.preventDefault();
document.getElementById('search').focus();
} else if (e.key === 'j') {
e.preventDefault();
moveThread(1);
} else if (e.key === 'k') {
e.preventDefault();
moveThread(-1);
} else if (e.key === 't') {
e.preventDefault();
toggleHtmlText();
} else if (e.key === '?') {
e.preventDefault();
toggleHelp();
}
});
});
window.addEventListener('popstate', (e) => {
if (e.state?.query) {
document.getElementById('search').value = e.state.query;
loadThreads(e.state.query);
}
});
</script>
</body>
</html>