zetviel/static/index.html
2025-10-15 17:07:05 -07:00

296 lines
12 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; }
</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 id="status" class="status-ok"></div>
</header>
<div class="search-bar">
<input type="text" id="search" placeholder="Search (e.g., tag:inbox)" value="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>
<script>
let currentQuery = 'tag:inbox';
let isLoading = false;
let currentThreadIndex = -1;
let threads = [];
let currentMessageId = null;
async function api(endpoint) {
const res = await fetch(`/api/${endpoint}`);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
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', () => {
const params = new URLSearchParams(location.search);
const query = params.get('q') || 'tag:inbox';
document.getElementById('search').value = query;
loadThreads(query);
document.getElementById('search').addEventListener('keypress', (e) => {
if (e.key === 'Enter') search();
});
document.addEventListener('keydown', (e) => {
if (document.activeElement.id === 'search') 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>