Files
2026-09-13 20:07:03 +01:00

870 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
PST Archive — frontend logic
- Setup wizard (first run)
- Login (+ MFA)
- Main app: upload, index, search, view
============================================================ */
const API = "/api";
const state = {
activePstId: null,
activePstName: "",
folder: "",
query: "",
limit: 25,
offset: 0,
total: 0,
refreshTimer: null,
user: null,
folders: [], // [{id, name, file_count}, ...]
psts: [], // last loaded pst list
collapsedFolders: new Set(), // folder IDs (or "__uncat__") that are collapsed
movePstId: null, // currently being moved
};
const $ = (id) => document.getElementById(id);
// ---------- helpers ----------
function fmtBytes(n) {
if (!n) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${units[i]}`;
}
function fmtDate(iso) {
if (!iso) return "—";
try {
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
} catch { return iso; }
}
function fmtDateTime(iso) {
if (!iso) return "—";
try {
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
} catch { return iso; }
}
function toast(msg, ms = 3000) {
const el = $("toast");
el.textContent = msg;
el.classList.remove("hidden");
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.add("hidden"), ms);
}
function escapeHtml(s) {
if (s == null) return "";
return String(s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function renderSnippet(snippet) {
if (!snippet) return "";
return escapeHtml(snippet)
.replace(/&lt;mark&gt;/g, "<mark>")
.replace(/&lt;\/mark&gt;/g, "</mark>");
}
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
// ---------- API ----------
async function api(path, opts = {}) {
const res = await fetch(API + path, {
credentials: "same-origin",
...opts,
});
let body = null;
try { body = await res.json(); } catch {}
if (!res.ok) {
const err = new Error((body && body.detail) || res.statusText);
err.status = res.status;
err.body = body || {};
throw err;
}
return body;
}
// ---------- Screen switching ----------
function showScreen(screen) {
["setupScreen", "loginScreen", "mainApp"].forEach((id) => {
$(id).classList.toggle("hidden", id !== screen);
});
}
// ---------- Bootstrap ----------
async function bootstrap() {
// 1. Try to fetch current user
try {
const { user } = await api("/me");
state.user = user;
enterApp();
return;
} catch (e) {
if (e.status !== 401) {
showLogin();
toast("Connection problem — please retry.");
return;
}
}
// 2. Not logged in — check setup status
try {
const { needs_setup } = await api("/setup/status");
if (needs_setup) showSetup();
else showLogin();
} catch {
showLogin();
}
}
// ---------- Setup ----------
function showSetup() {
showScreen("setupScreen");
$("setupForm").reset();
$("setupError").classList.add("hidden");
setTimeout(() => $("setupForm").username?.focus(), 50);
}
async function handleSetup(e) {
e.preventDefault();
const form = e.target;
const errEl = $("setupError");
errEl.classList.add("hidden");
const username = form.username.value.trim();
const password = form.password.value;
const password2 = form.password2.value;
if (password !== password2) {
errEl.textContent = "Passwords do not match.";
errEl.classList.remove("hidden");
return;
}
try {
const { user } = await api("/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
state.user = user;
toast("Welcome — admin account created.");
enterApp();
} catch (err) {
errEl.textContent = err.message || "Setup failed.";
errEl.classList.remove("hidden");
}
}
// ---------- Login ----------
function showLogin() {
showScreen("loginScreen");
$("loginForm").reset();
$("mfaField").classList.add("hidden");
$("loginError").classList.add("hidden");
setTimeout(() => $("loginForm").username?.focus(), 50);
}
async function handleLogin(e) {
e.preventDefault();
const form = e.target;
const errEl = $("loginError");
errEl.classList.add("hidden");
const payload = {
username: form.username.value.trim(),
password: form.password.value,
};
const mfaInput = form.totp_code.value.trim();
if (mfaInput) payload.totp_code = mfaInput;
try {
const { user } = await api("/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
state.user = user;
toast(`Welcome, ${user.username}.`);
enterApp();
} catch (err) {
if (err.status === 401 && err.body && err.body.mfa_required) {
$("mfaField").classList.remove("hidden");
const f = $("loginForm").totp_code;
f.value = "";
setTimeout(() => f.focus(), 50);
errEl.textContent = "Enter your authenticator code to continue.";
errEl.classList.remove("hidden");
return;
}
errEl.textContent = err.message || "Login failed.";
errEl.classList.remove("hidden");
}
}
async function handleLogout() {
try { await api("/logout", { method: "POST" }); } catch {}
state.user = null;
state.activePstId = null;
resetReader();
showLogin();
}
// ---------- Enter the app ----------
function enterApp() {
showScreen("mainApp");
$("userChip").textContent = `${state.user.username}${state.user.role === "admin" ? " · admin" : ""}`;
checkHealth();
loadPstList();
setInterval(checkHealth, 30000);
}
// ---------- Health ----------
async function checkHealth() {
const dot = $("statusDot");
const text = $("statusText");
try {
await api("/health");
dot.className = "status-dot ok";
text.textContent = "connected";
} catch {
dot.className = "status-dot err";
text.textContent = "offline";
}
}
// ---------- PST list ----------
async function loadPstList() {
try {
const [folders, items] = await Promise.all([
api("/folders"),
api("/pst-files"),
]);
state.folders = folders;
state.psts = items;
renderPstList();
if (items.some((i) => i.status === "indexing")) {
clearTimeout(state.refreshTimer);
state.refreshTimer = setTimeout(loadPstList, 2500);
}
} catch (e) {
if (e.status === 401) { handleLogout(); return; }
toast(`Failed to load archives: ${e.message}`);
}
}
function pstItemHtml(i) {
const active = i.id === state.activePstId ? "active" : "";
const disabled = i.status !== "ready";
const statusText = i.status === "indexing" ? "indexing…" :
i.status === "ready" ? `${i.message_count.toLocaleString()} msgs` :
i.status === "failed" ? "failed" : i.status;
return `
<li class="pst-item ${active}" data-id="${i.id}" data-name="${escapeHtml(i.original_name)}" data-status="${i.status}" ${disabled ? 'data-disabled="1"' : ""}>
<div class="pst-name">
<span class="pst-name-text" title="${escapeHtml(i.original_name)}">${escapeHtml(i.original_name)}</span>
<button class="pst-menu" data-pst-menu="${i.id}" title="Actions">⋯</button>
</div>
<div class="pst-meta">
<span>${fmtBytes(i.size_bytes)} · ${fmtDate(i.uploaded_at)}</span>
<span class="pst-status ${i.status}">${statusText}</span>
</div>
</li>
`;
}
function renderPstList() {
const ul = $("pstList");
const { folders, psts } = state;
if (!folders.length && !psts.length) {
ul.innerHTML = '<li class="empty-state">No archives yet.</li>';
return;
}
// Group PSTs by folder_id
const byFolder = new Map();
byFolder.set(null, []); // uncategorized bucket
folders.forEach((f) => byFolder.set(f.id, []));
psts.forEach((p) => {
const bucket = byFolder.has(p.folder_id) ? p.folder_id : null;
byFolder.get(bucket).push(p);
});
const parts = [];
// Render user-defined folders first (sorted alphabetically — server does this)
for (const f of folders) {
const items = byFolder.get(f.id) || [];
const collapsed = state.collapsedFolders.has(f.id) ? "collapsed" : "";
parts.push(`
<li class="folder-group ${collapsed}" data-folder="${f.id}">
<div class="folder-header" data-folder-header="${f.id}">
<div class="folder-header-left">
<span class="folder-caret">▾</span>
<span class="folder-name" title="${escapeHtml(f.name)}">${escapeHtml(f.name)}</span>
<span class="folder-count">${items.length}</span>
</div>
<button class="folder-menu-btn" data-folder-menu="${f.id}" title="Folder actions">⋯</button>
</div>
<ul class="folder-items">
${items.length
? items.map(pstItemHtml).join("")
: '<li class="folder-empty">— empty —</li>'}
</ul>
</li>
`);
}
// Uncategorized group — only shown if it has items OR there are no folders
const uncat = byFolder.get(null) || [];
if (uncat.length || !folders.length) {
const collapsed = state.collapsedFolders.has("__uncat__") ? "collapsed" : "";
parts.push(`
<li class="folder-group ${collapsed}" data-folder="__uncat__">
<div class="folder-header" data-folder-header="__uncat__">
<div class="folder-header-left">
<span class="folder-caret">▾</span>
<span class="folder-name">Uncategorized</span>
<span class="folder-count">${uncat.length}</span>
</div>
</div>
<ul class="folder-items">
${uncat.length
? uncat.map(pstItemHtml).join("")
: '<li class="folder-empty">— empty —</li>'}
</ul>
</li>
`);
}
ul.innerHTML = parts.join("");
// PST click
ul.querySelectorAll(".pst-item").forEach((el) => {
el.addEventListener("click", (e) => {
if (e.target.closest("[data-pst-menu]")) return;
if (el.dataset.disabled) {
toast(el.dataset.status === "indexing" ? "Still indexing — one moment…" : "This archive is unavailable.");
return;
}
selectPst(el.dataset.id, el.dataset.name);
});
});
// PST action menu
ul.querySelectorAll("[data-pst-menu]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
openPstMenu(e, btn.dataset.pstMenu);
});
});
// Folder header toggle (collapse/expand)
ul.querySelectorAll("[data-folder-header]").forEach((el) => {
el.addEventListener("click", (e) => {
if (e.target.closest("[data-folder-menu]")) return;
const id = el.dataset.folderHeader;
if (state.collapsedFolders.has(id)) state.collapsedFolders.delete(id);
else state.collapsedFolders.add(id);
const group = el.closest(".folder-group");
group.classList.toggle("collapsed");
});
});
// Folder action menu
ul.querySelectorAll("[data-folder-menu]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
openFolderMenu(e, btn.dataset.folderMenu);
});
});
}
// ---------- Context menus ----------
function closeContextMenu() {
document.querySelectorAll(".context-menu").forEach((el) => el.remove());
}
function openContextMenu(x, y, items) {
closeContextMenu();
const menu = document.createElement("div");
menu.className = "context-menu";
items.forEach((it) => {
if (it.divider) {
menu.appendChild(document.createElement("hr"));
return;
}
const btn = document.createElement("button");
btn.textContent = it.label;
if (it.danger) btn.classList.add("danger");
btn.addEventListener("click", () => {
closeContextMenu();
it.onClick();
});
menu.appendChild(btn);
});
document.body.appendChild(menu);
// Position, keeping menu inside viewport
const rect = menu.getBoundingClientRect();
const maxX = window.innerWidth - rect.width - 4;
const maxY = window.innerHeight - rect.height - 4;
menu.style.left = Math.min(x, maxX) + "px";
menu.style.top = Math.min(y, maxY) + "px";
// Close on outside click or escape
setTimeout(() => {
document.addEventListener("click", closeContextMenu, { once: true });
}, 0);
}
function openPstMenu(event, pstId) {
const p = state.psts.find((x) => x.id === pstId);
if (!p) return;
const r = event.currentTarget.getBoundingClientRect();
openContextMenu(r.left, r.bottom + 4, [
{
label: "Move to folder…",
onClick: () => openMoveModal(p),
},
{ divider: true },
{
label: "Delete archive",
danger: true,
onClick: () => deletePst(p),
},
]);
}
function openFolderMenu(event, folderId) {
const f = state.folders.find((x) => x.id === folderId);
if (!f) return;
const r = event.currentTarget.getBoundingClientRect();
openContextMenu(r.left, r.bottom + 4, [
{
label: "Rename folder…",
onClick: () => renameFolder(f),
},
{ divider: true },
{
label: "Delete folder",
danger: true,
onClick: () => deleteFolder(f),
},
]);
}
// ---------- Folder operations ----------
function openFolderModal() {
$("folderForm").reset();
$("folderError").classList.add("hidden");
$("folderModal").classList.remove("hidden");
setTimeout(() => $("folderForm").name.focus(), 50);
}
async function handleCreateFolder(e) {
e.preventDefault();
const form = e.target;
const errEl = $("folderError");
errEl.classList.add("hidden");
try {
await api("/folders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: form.name.value }),
});
toast("Folder created.");
closeModals();
loadPstList();
} catch (err) {
errEl.textContent = err.message || "Create failed.";
errEl.classList.remove("hidden");
}
}
async function renameFolder(f) {
const newName = prompt(`Rename "${f.name}" to:`, f.name);
if (!newName || newName.trim() === f.name) return;
try {
await api(`/folders/${f.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName }),
});
toast("Folder renamed.");
loadPstList();
} catch (err) {
toast(`Rename failed: ${err.message}`);
}
}
async function deleteFolder(f) {
if (!confirm(`Delete folder "${f.name}"?\n\nAny archives inside will be moved to Uncategorized.`)) return;
try {
await api(`/folders/${f.id}`, { method: "DELETE" });
toast("Folder deleted.");
loadPstList();
} catch (err) {
toast(`Delete failed: ${err.message}`);
}
}
async function deletePst(p) {
if (!confirm(`Remove "${p.original_name}" and all indexed messages?`)) return;
try {
await api(`/pst-files/${p.id}`, { method: "DELETE" });
if (state.activePstId === p.id) { state.activePstId = null; resetReader(); }
toast("Archive removed.");
loadPstList();
} catch (err) {
toast(`Delete failed: ${err.message}`);
}
}
function openMoveModal(p) {
state.movePstId = p.id;
$("moveSubject").textContent = p.original_name;
const sel = $("moveFolderSelect");
sel.innerHTML = '<option value="">Uncategorized</option>' +
state.folders.map((f) =>
`<option value="${f.id}" ${f.id === p.folder_id ? "selected" : ""}>${escapeHtml(f.name)}</option>`
).join("");
if (!p.folder_id) sel.value = "";
$("moveError").classList.add("hidden");
$("moveModal").classList.remove("hidden");
}
async function handleMovePst(e) {
e.preventDefault();
const errEl = $("moveError");
errEl.classList.add("hidden");
const folderId = $("moveFolderSelect").value || null;
try {
await api(`/pst-files/${state.movePstId}/folder`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder_id: folderId }),
});
toast("Moved.");
closeModals();
loadPstList();
} catch (err) {
errEl.textContent = err.message || "Move failed.";
errEl.classList.remove("hidden");
}
}
function closeModals() {
document.querySelectorAll(".modal").forEach((m) => m.classList.add("hidden"));
}
// ---------- Selection + folders ----------
async function selectPst(id, name) {
state.activePstId = id;
state.activePstName = name;
state.folder = "";
state.query = "";
state.offset = 0;
$("searchInput").value = "";
$("searchInput").disabled = false;
$("folderFilter").disabled = false;
document.querySelectorAll(".pst-item").forEach((el) => {
el.classList.toggle("active", el.dataset.id === id);
});
$("readerTitle").textContent = name;
$("readerMeta").textContent = "Loading messages…";
hideMessageView();
await Promise.all([loadFolders(), loadMessages()]);
}
async function loadFolders() {
if (!state.activePstId) return;
try {
const folders = await api(`/pst-files/${state.activePstId}/folders`);
const sel = $("folderFilter");
sel.innerHTML =
'<option value="">All folders</option>' +
folders.map((f) => `<option value="${escapeHtml(f.folder || "")}">${escapeHtml(f.folder || "(root)")} · ${f.count}</option>`).join("");
sel.value = state.folder || "";
} catch (e) { console.warn("folders", e); }
}
// ---------- Messages ----------
async function loadMessages() {
if (!state.activePstId) return;
const params = new URLSearchParams();
if (state.query) params.set("q", state.query);
if (state.folder) params.set("folder", state.folder);
params.set("limit", state.limit);
params.set("offset", state.offset);
try {
const res = await api(`/pst-files/${state.activePstId}/messages?${params}`);
state.total = res.total;
renderMessages(res.items);
renderPaginator();
$("readerMeta").textContent =
`${state.total.toLocaleString()} message${state.total === 1 ? "" : "s"}` +
(state.query ? ` matching "${state.query}"` : "") +
(state.folder ? ` in ${state.folder}` : "");
} catch (e) {
if (e.status === 401) { handleLogout(); return; }
toast(`Search failed: ${e.message}`);
}
}
function renderMessages(items) {
const list = $("messageList");
if (!items.length) {
list.innerHTML = `
<div class="reader-placeholder">
<div class="placeholder-ornament">∅</div>
<p>No messages match.</p>
</div>`;
return;
}
list.innerHTML = items.map((m) => {
const hasAtt = m.attachments && m.attachments.trim().length > 0;
return `
<div class="message-row" data-id="${m.id}">
<div class="mr-sender" title="${escapeHtml(m.sender || "")}">${escapeHtml(m.sender || "(unknown sender)")}</div>
<div class="mr-main">
<div class="mr-subject">${escapeHtml(m.subject || "(no subject)")} ${hasAtt ? '<span class="mr-attached" title="Has attachments">◈</span>' : ""}</div>
<div class="mr-snippet">${renderSnippet(m.snippet || "")}</div>
</div>
<div class="mr-meta">
<div>${fmtDate(m.sent_date)}</div>
<div class="mr-folder" title="${escapeHtml(m.folder || "")}">${escapeHtml((m.folder || "").split("/").filter(Boolean).pop() || "inbox")}</div>
</div>
</div>`;
}).join("");
list.querySelectorAll(".message-row").forEach((el) => {
el.addEventListener("click", () => openMessage(el.dataset.id));
});
}
function renderPaginator() {
const p = $("paginator");
if (state.total <= state.limit) { p.classList.add("hidden"); return; }
p.classList.remove("hidden");
const from = state.offset + 1;
const to = Math.min(state.offset + state.limit, state.total);
$("pageInfo").textContent = `${from}${to} of ${state.total.toLocaleString()}`;
$("prevBtn").disabled = state.offset === 0;
$("nextBtn").disabled = state.offset + state.limit >= state.total;
}
// ---------- Message view ----------
async function openMessage(id) {
try {
const m = await api(`/messages/${id}`);
$("mvSubject").textContent = m.subject || "(no subject)";
$("mvSender").textContent = m.sender || "—";
$("mvRecipients").textContent = m.recipients || "—";
$("mvDate").textContent = fmtDateTime(m.sent_date);
$("mvFolder").textContent = m.folder || "—";
if (m.attachments && m.attachments.trim()) {
$("mvAttLabel").classList.remove("hidden");
$("mvAttachments").classList.remove("hidden");
$("mvAttachments").textContent = m.attachments.split(" | ").join(", ");
} else {
$("mvAttLabel").classList.add("hidden");
$("mvAttachments").classList.add("hidden");
}
$("mvBody").textContent = m.body || "(empty body)";
$("messageView").classList.remove("hidden");
} catch (e) {
if (e.status === 401) { handleLogout(); return; }
toast(`Could not open message: ${e.message}`);
}
}
function hideMessageView() { $("messageView").classList.add("hidden"); }
function resetReader() {
$("readerTitle").textContent = "Select an archive";
$("readerMeta").textContent = "Upload a PST file or select one from the left to begin.";
$("searchInput").disabled = true;
$("folderFilter").disabled = true;
$("messageList").innerHTML = `
<div class="reader-placeholder">
<div class="placeholder-ornament">✦</div>
<p>The archive awaits.</p>
</div>`;
$("paginator").classList.add("hidden");
hideMessageView();
}
// ---------- Upload ----------
function uploadFile(file) {
if (!file) return;
const lower = file.name.toLowerCase();
if (!lower.endsWith(".pst") && !lower.endsWith(".ost")) {
toast("Only .pst and .ost files are accepted.");
return;
}
const progress = $("uploadProgress");
const bar = $("progressBar");
const pct = $("progressPct");
const nm = $("progressName");
progress.classList.remove("hidden");
nm.textContent = file.name;
bar.style.width = "0%";
pct.textContent = "0%";
const form = new FormData();
form.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", API + "/pst-files");
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const p = Math.round((e.loaded / e.total) * 100);
bar.style.width = p + "%";
pct.textContent = p + "%";
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
pct.textContent = "indexing…";
bar.style.width = "100%";
toast("Upload complete. Indexing started in background.");
setTimeout(() => progress.classList.add("hidden"), 2500);
loadPstList();
} else if (xhr.status === 401) {
handleLogout();
} else {
let msg = "Upload failed";
try { msg = JSON.parse(xhr.responseText).detail || msg; } catch {}
toast(msg);
progress.classList.add("hidden");
}
};
xhr.onerror = () => { toast("Upload failed (network)."); progress.classList.add("hidden"); };
xhr.send(form);
}
// ---------- Theme ----------
function initTheme() {
// Preference order: localStorage → prefers-color-scheme → light
let theme = "light";
try {
const saved = localStorage.getItem("pst-theme");
if (saved === "light" || saved === "dark") {
theme = saved;
} else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
theme = "dark";
}
} catch { /* ignore storage errors */ }
document.documentElement.dataset.theme = theme;
}
function toggleTheme() {
const current = document.documentElement.dataset.theme === "dark" ? "dark" : "light";
const next = current === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next;
try { localStorage.setItem("pst-theme", next); } catch {}
}
// Apply theme immediately so there's no flash of wrong theme
initTheme();
// ---------- Wire up ----------
function init() {
$("setupForm").addEventListener("submit", handleSetup);
$("loginForm").addEventListener("submit", handleLogin);
$("logoutBtn").addEventListener("click", handleLogout);
// Theme toggles (both the in-masthead and floating variants)
["themeToggle", "themeToggleFloat"].forEach((id) => {
const el = document.getElementById(id);
if (el) el.addEventListener("click", toggleTheme);
});
$("fileInput").addEventListener("change", (e) => {
const f = e.target.files[0];
if (f) uploadFile(f);
e.target.value = "";
});
const dz = $("dropzone");
["dragenter", "dragover"].forEach((ev) => {
dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.add("drag-over"); });
});
["dragleave", "drop"].forEach((ev) => {
dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.remove("drag-over"); });
});
dz.addEventListener("drop", (e) => {
const f = e.dataTransfer.files?.[0];
if (f) uploadFile(f);
});
$("refreshBtn").addEventListener("click", loadPstList);
$("newFolderBtn").addEventListener("click", openFolderModal);
$("folderForm").addEventListener("submit", handleCreateFolder);
$("moveForm").addEventListener("submit", handleMovePst);
// Modal close buttons + backdrop clicks + Escape
document.querySelectorAll("[data-close]").forEach((el) => {
el.addEventListener("click", closeModals);
});
document.querySelectorAll(".modal").forEach((m) => {
m.addEventListener("click", (e) => {
if (e.target === m) closeModals();
});
});
const searchHandler = debounce(() => {
state.query = $("searchInput").value.trim();
state.offset = 0;
loadMessages();
}, 250);
$("searchInput").addEventListener("input", searchHandler);
$("folderFilter").addEventListener("change", () => {
state.folder = $("folderFilter").value;
state.offset = 0;
loadMessages();
});
$("prevBtn").addEventListener("click", () => {
state.offset = Math.max(0, state.offset - state.limit);
loadMessages();
});
$("nextBtn").addEventListener("click", () => {
state.offset += state.limit;
loadMessages();
});
$("closeMessageBtn").addEventListener("click", hideMessageView);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
hideMessageView();
closeModals();
closeContextMenu();
}
});
bootstrap();
}
document.addEventListener("DOMContentLoaded", init);