878 lines
26 KiB
JavaScript
878 lines
26 KiB
JavaScript
/* ============================================================
|
||
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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||
.replace(/"/g, """).replace(/'/g, "'");
|
||
}
|
||
|
||
function renderSnippet(snippet) {
|
||
if (!snippet) return "";
|
||
return escapeHtml(snippet)
|
||
.replace(/<mark>/g, "<mark>")
|
||
.replace(/<\/mark>/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" : ""}`;
|
||
// Show the ADMINISTRATION button only for admins.
|
||
const adminBtn = $("adminNavBtn");
|
||
if (adminBtn) adminBtn.classList.toggle("hidden", state.user.role !== "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);
|
||
|
||
const adminNavBtn = $("adminNavBtn");
|
||
if (adminNavBtn) {
|
||
adminNavBtn.addEventListener("click", () => { window.location.href = "/admin/"; });
|
||
}
|
||
|
||
// 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);
|