296 lines
12 KiB
HTML
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>
|