v_2.2
This commit is contained in:
+877
@@ -0,0 +1,877 @@
|
||||
/* ============================================================
|
||||
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);
|
||||
Reference in New Issue
Block a user