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);
|
||||
@@ -0,0 +1,259 @@
|
||||
<!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="adminNavBtn" class="admin-nav-btn hidden" title="Open the administration section">Administration</button>
|
||||
<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>
|
||||
|
||||
<footer class="site-footer">© 2026 Martinhal IT - Joao Vaz - Version 2.2</footer>
|
||||
</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>
|
||||
+1182
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user