This commit is contained in:
jpmvaz
2026-09-13 20:07:03 +01:00
commit 5482731e78
37 changed files with 15639 additions and 0 deletions
@@ -0,0 +1,869 @@
/* ============================================================
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);
@@ -0,0 +1,256 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PST Archive · Mail Indexer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,500;0,9..144,700;1,9..144,400&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css" />
<script>
// Inline theme bootstrap — runs before CSS so no flash of wrong theme
(function() {
try {
var t = localStorage.getItem("pst-theme");
if (t !== "light" && t !== "dark") {
t = (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
}
document.documentElement.dataset.theme = t;
} catch (e) {
document.documentElement.dataset.theme = "light";
}
})();
</script>
</head>
<body>
<div class="grain"></div>
<!-- Floating theme toggle (visible on all screens) -->
<button id="themeToggleFloat" class="theme-toggle theme-toggle-float" title="Toggle theme" aria-label="Toggle theme">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4.5"/><line x1="12" y1="2" x2="12" y2="4.5"/><line x1="12" y1="19.5" x2="12" y2="22"/><line x1="2" y1="12" x2="4.5" y2="12"/><line x1="19.5" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="6.7" y2="6.7"/><line x1="17.3" y1="17.3" x2="19.1" y2="19.1"/><line x1="4.9" y1="19.1" x2="6.7" y2="17.3"/><line x1="17.3" y1="6.7" x2="19.1" y2="4.9"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A9 9 0 019.5 4a7.5 7.5 0 1010.5 10.5z"/></svg>
</button>
<!-- SETUP WIZARD -->
<section id="setupScreen" class="auth-screen hidden">
<div class="auth-card">
<div class="auth-brand">
<svg viewBox="0 0 40 40" width="44" height="44" aria-hidden="true">
<rect x="3" y="8" width="34" height="26" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 8 L20 22 L37 8" fill="none" stroke="currentColor" stroke-width="1.5"/>
<line x1="3" y1="34" x2="14" y2="22" stroke="currentColor" stroke-width="1.5"/>
<line x1="37" y1="34" x2="26" y2="22" stroke="currentColor" stroke-width="1.5"/>
</svg>
<h1>PST Archive</h1>
<p class="auth-tagline">First-run setup</p>
</div>
<form id="setupForm" class="auth-form" novalidate>
<p class="auth-intro">Create the primary administrator account. This user will have full access to the archive and the admin panel.</p>
<label class="auth-label">
<span>Username</span>
<input type="text" name="username" autocomplete="username" required minlength="3" maxlength="64" />
</label>
<label class="auth-label">
<span>Password</span>
<input type="password" name="password" autocomplete="new-password" required minlength="8" />
<small>Minimum 8 characters. Use a passphrase you will remember.</small>
</label>
<label class="auth-label">
<span>Confirm password</span>
<input type="password" name="password2" autocomplete="new-password" required minlength="8" />
</label>
<button type="submit" class="auth-btn">Create administrator</button>
<div id="setupError" class="auth-error hidden"></div>
</form>
</div>
</section>
<!-- LOGIN -->
<section id="loginScreen" class="auth-screen hidden">
<div class="auth-card">
<div class="auth-brand">
<svg viewBox="0 0 40 40" width="44" height="44" aria-hidden="true">
<rect x="3" y="8" width="34" height="26" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 8 L20 22 L37 8" fill="none" stroke="currentColor" stroke-width="1.5"/>
<line x1="3" y1="34" x2="14" y2="22" stroke="currentColor" stroke-width="1.5"/>
<line x1="37" y1="34" x2="26" y2="22" stroke="currentColor" stroke-width="1.5"/>
</svg>
<h1>PST Archive</h1>
<p class="auth-tagline">Sign in to continue</p>
</div>
<form id="loginForm" class="auth-form" novalidate>
<label class="auth-label">
<span>Username</span>
<input type="text" name="username" autocomplete="username" required />
</label>
<label class="auth-label">
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required />
</label>
<label id="mfaField" class="auth-label hidden">
<span>Authentication code</span>
<input type="text" name="totp_code" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]*" maxlength="6" placeholder="6-digit code" />
<small>From your authenticator app</small>
</label>
<button type="submit" class="auth-btn">Sign in</button>
<div id="loginError" class="auth-error hidden"></div>
</form>
</div>
</section>
<!-- MAIN APP -->
<div id="mainApp" class="hidden">
<header class="masthead">
<div class="masthead-left">
<div class="logo-mark">
<svg viewBox="0 0 40 40" width="36" height="36" aria-hidden="true">
<rect x="3" y="8" width="34" height="26" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 8 L20 22 L37 8" fill="none" stroke="currentColor" stroke-width="1.5"/>
<line x1="3" y1="34" x2="14" y2="22" stroke="currentColor" stroke-width="1.5"/>
<line x1="37" y1="34" x2="26" y2="22" stroke="currentColor" stroke-width="1.5"/>
</svg>
</div>
<div class="masthead-title">
<h1>PST Archive</h1>
<p class="tagline">Forensic mail indexer · v1.0</p>
</div>
</div>
<div class="masthead-right">
<span id="statusDot" class="status-dot"></span>
<span id="statusText" class="status-text">connecting…</span>
<span id="userChip" class="user-chip"></span>
<button id="themeToggle" class="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4.5"/><line x1="12" y1="2" x2="12" y2="4.5"/><line x1="12" y1="19.5" x2="12" y2="22"/><line x1="2" y1="12" x2="4.5" y2="12"/><line x1="19.5" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="6.7" y2="6.7"/><line x1="17.3" y1="17.3" x2="19.1" y2="19.1"/><line x1="4.9" y1="19.1" x2="6.7" y2="17.3"/><line x1="17.3" y1="6.7" x2="19.1" y2="4.9"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A9 9 0 019.5 4a7.5 7.5 0 1010.5 10.5z"/></svg>
</button>
<button id="logoutBtn" class="logout-btn" title="Sign out">Sign out</button>
</div>
</header>
<main class="workspace">
<aside class="sidebar">
<section class="upload-block">
<h2 class="section-heading">01 · Ingest</h2>
<label class="dropzone" id="dropzone">
<input type="file" id="fileInput" accept=".pst,.ost" hidden />
<div class="dropzone-inner">
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span class="dropzone-title">Drop <em>.pst</em> / <em>.ost</em></span>
<span class="dropzone-hint">or click to browse</span>
</div>
</label>
<div id="uploadProgress" class="upload-progress hidden">
<div class="progress-label"><span id="progressName"></span> <span id="progressPct">0%</span></div>
<div class="progress-track"><div id="progressBar" class="progress-bar"></div></div>
</div>
</section>
<section class="archive-block">
<div class="archive-head">
<h2 class="section-heading">02 · Archives</h2>
<div class="archive-actions">
<button id="newFolderBtn" class="icon-btn" title="New folder">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" stroke="currentColor" stroke-width="1.5"/><line x1="12" y1="11" x2="12" y2="17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="9" y1="14" x2="15" y2="14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<button id="refreshBtn" class="icon-btn" title="Refresh list">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M4 4v6h6M20 20v-6h-6M20 10a8 8 0 00-14.9-3M4 14a8 8 0 0014.9 3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
</div>
</div>
<ul id="pstList" class="pst-list">
<li class="empty-state">No archives yet.</li>
</ul>
</section>
</aside>
<section class="reader">
<div class="reader-head">
<div class="reader-title">
<h2 id="readerTitle">Select an archive</h2>
<p id="readerMeta" class="reader-meta">Upload a PST file or select one from the left to begin.</p>
</div>
<div class="search-wrap">
<input id="searchInput" type="search" placeholder="Search subject, sender, body…" disabled />
<select id="folderFilter" disabled>
<option value="">All folders</option>
</select>
</div>
</div>
<div class="reader-body">
<div id="messageList" class="message-list">
<div class="reader-placeholder">
<div class="placeholder-ornament"></div>
<p>The archive awaits.</p>
</div>
</div>
<article id="messageView" class="message-view hidden">
<button id="closeMessageBtn" class="close-btn" title="Close">×</button>
<header class="message-header">
<div class="message-subject" id="mvSubject"></div>
<dl class="message-meta">
<dt>From</dt><dd id="mvSender"></dd>
<dt>To</dt><dd id="mvRecipients"></dd>
<dt>Date</dt><dd id="mvDate"></dd>
<dt>Folder</dt><dd id="mvFolder"></dd>
<dt id="mvAttLabel" class="hidden">Attachments</dt>
<dd id="mvAttachments" class="hidden"></dd>
</dl>
</header>
<div class="message-body" id="mvBody"></div>
</article>
</div>
<div id="paginator" class="paginator hidden">
<button id="prevBtn">← Previous</button>
<span id="pageInfo"></span>
<button id="nextBtn">Next →</button>
</div>
</section>
</main>
</div>
<div id="toast" class="toast hidden"></div>
<!-- NEW FOLDER MODAL -->
<div id="folderModal" class="modal hidden">
<div class="modal-card">
<button class="modal-close" data-close>×</button>
<h2>New folder</h2>
<form id="folderForm" class="auth-form" novalidate>
<label class="auth-label">
<span>Folder name</span>
<input type="text" name="name" maxlength="80" required autocomplete="off" />
</label>
<button type="submit" class="auth-btn">Create folder</button>
<div id="folderError" class="auth-error hidden"></div>
</form>
</div>
</div>
<!-- MOVE PST MODAL -->
<div id="moveModal" class="modal hidden">
<div class="modal-card">
<button class="modal-close" data-close>×</button>
<h2>Move archive</h2>
<p class="modal-subtitle" id="moveSubject"></p>
<form id="moveForm" class="auth-form" novalidate>
<label class="auth-label">
<span>Folder</span>
<select name="folder_id" id="moveFolderSelect">
<option value="">Uncategorized</option>
</select>
</label>
<button type="submit" class="auth-btn">Move</button>
<div id="moveError" class="auth-error hidden"></div>
</form>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff