const state = { documents: [], reviewers: [], notifications: [], selectedId: null, selectedDocument: null, filter: "all", view: "documents", reviewerAction: "comment", }; const els = { fileInput: document.querySelector("#fileInput"), uploadButton: document.querySelector("#uploadButton"), dropZone: document.querySelector("#dropZone"), ownerName: document.querySelector("#ownerName"), ownerEmail: document.querySelector("#ownerEmail"), ownerSlack: document.querySelector("#ownerSlack"), ownerTeams: document.querySelector("#ownerTeams"), sideTabs: document.querySelectorAll(".side-tab"), documentsView: document.querySelector("#documentsView"), reviewersView: document.querySelector("#reviewersView"), notificationsView: document.querySelector("#notificationsView"), notificationBadge: document.querySelector("#notificationBadge"), filters: document.querySelectorAll(".filter"), documentList: document.querySelector("#documentList"), reviewerForm: document.querySelector("#reviewerForm"), reviewerId: document.querySelector("#reviewerId"), reviewerName: document.querySelector("#reviewerName"), reviewerEmail: document.querySelector("#reviewerEmail"), reviewerSlack: document.querySelector("#reviewerSlack"), reviewerTeams: document.querySelector("#reviewerTeams"), reviewerSaveButton: document.querySelector("#reviewerSaveButton"), reviewerCancelButton: document.querySelector("#reviewerCancelButton"), reviewerList: document.querySelector("#reviewerList"), notificationList: document.querySelector("#notificationList"), emptyState: document.querySelector("#emptyState"), documentPanel: document.querySelector("#documentPanel"), documentMeta: document.querySelector("#documentMeta"), documentTitle: document.querySelector("#documentTitle"), ownerLine: document.querySelector("#ownerLine"), statusPill: document.querySelector("#statusPill"), downloadButton: document.querySelector("#downloadButton"), reviewButton: document.querySelector("#reviewButton"), assignmentSummary: document.querySelector("#assignmentSummary"), assignmentForm: document.querySelector("#assignmentForm"), assignmentChoices: document.querySelector("#assignmentChoices"), assignmentList: document.querySelector("#assignmentList"), reviewerActionForm: document.querySelector("#reviewerActionForm"), reviewerActionSelect: document.querySelector("#reviewerActionSelect"), reviewerSignature: document.querySelector("#reviewerSignature"), reviewerNote: document.querySelector("#reviewerNote"), findings: document.querySelector("#findings"), findingSummary: document.querySelector("#findingSummary"), commentForm: document.querySelector("#commentForm"), commentBody: document.querySelector("#commentBody"), comments: document.querySelector("#comments"), commentCount: document.querySelector("#commentCount"), activityTimeline: document.querySelector("#activityTimeline"), activityCount: document.querySelector("#activityCount"), textPreview: document.querySelector("#textPreview"), wordCount: document.querySelector("#wordCount"), toast: document.querySelector("#toast"), }; async function api(path, options = {}) { return new Promise((resolve, reject) => { const request = new XMLHttpRequest(); request.open(options.method || "GET", path, true); for (const [key, value] of Object.entries(options.headers || {})) { request.setRequestHeader(key, value); } request.onload = () => { const contentType = request.getResponseHeader("Content-Type") || ""; const payload = contentType.includes("application/json") && request.responseText ? JSON.parse(request.responseText) : null; if (request.status >= 200 && request.status < 300) { resolve(payload); } else { reject(new Error(payload?.error || `Request failed with ${request.status}`)); } }; request.onerror = () => reject(new Error("Request failed.")); request.send(options.body || null); }); } function showToast(message, isError = false) { els.toast.textContent = message; els.toast.classList.toggle("error", isError); els.toast.classList.remove("hidden"); clearTimeout(showToast.timer); showToast.timer = setTimeout(() => els.toast.classList.add("hidden"), 3600); } function formatDate(value) { if (!value) return "Not recorded"; return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }).format(new Date(value)); } function countsFor(document) { const counts = { high: 0, medium: 0, low: 0 }; for (const finding of document.findings || []) { counts[finding.severity] += 1; } return counts; } function contactLine(contact = {}) { return [contact.email, contact.slack, contact.teams].filter(Boolean).join(" | "); } async function loadAll() { const [documents, reviewers, notifications] = await Promise.all([ api("/api/documents"), api("/api/reviewers"), api("/api/notifications"), ]); state.documents = documents.documents; state.reviewers = reviewers.reviewers; state.notifications = notifications.notifications; renderNavigation(notifications.unread_count || 0); renderList(); renderReviewerList(); renderNotifications(); if (state.selectedId && state.documents.some((doc) => doc.id === state.selectedId)) { await selectDocument(state.selectedId); } else if (state.documents.length > 0 && !state.selectedId) { await selectDocument(state.documents[0].id); } else if (state.documents.length === 0) { renderEmpty(); } } async function refreshSelected() { const [documents, notifications] = await Promise.all([api("/api/documents"), api("/api/notifications")]); state.documents = documents.documents; state.notifications = notifications.notifications; renderNavigation(notifications.unread_count || 0); renderList(); renderNotifications(); if (state.selectedId) { state.selectedDocument = await api(`/api/documents/${state.selectedId}`); renderDocument(); } } function renderNavigation(unreadCount = 0) { els.sideTabs.forEach((button) => button.classList.toggle("active", button.dataset.view === state.view)); els.documentsView.classList.toggle("hidden", state.view !== "documents"); els.reviewersView.classList.toggle("hidden", state.view !== "reviewers"); els.notificationsView.classList.toggle("hidden", state.view !== "notifications"); els.notificationBadge.textContent = `${unreadCount}`; els.notificationBadge.classList.toggle("hidden", unreadCount === 0); } function renderList() { const filtered = state.documents.filter((document) => { if (state.filter === "all") return true; if (state.filter === "in_review") return document.status === "in_review" || document.status === "uploaded"; return document.status === state.filter; }); if (!filtered.length) { els.documentList.innerHTML = `
No documents in this view.
`; return; } els.documentList.innerHTML = filtered .map((document) => { const counts = countsFor(document); const assignments = document.assignment_counts || {}; return ` `; }) .join(""); els.documentList.querySelectorAll("[data-id]").forEach((button) => { button.addEventListener("click", () => selectDocument(button.dataset.id)); }); } function assignmentTotal(counts) { return (counts.pending || 0) + (counts.commented || 0) + (counts.changes_requested || 0) + (counts.approved || 0); } function renderEmpty() { state.selectedDocument = null; els.emptyState.classList.remove("hidden"); els.documentPanel.classList.add("hidden"); } async function selectDocument(id) { state.selectedId = id; state.selectedDocument = await api(`/api/documents/${id}`); renderList(); renderDocument(); } function renderDocument() { const document = state.selectedDocument; if (!document) { renderEmpty(); return; } els.emptyState.classList.add("hidden"); els.documentPanel.classList.remove("hidden"); els.documentTitle.textContent = document.filename; els.documentMeta.textContent = `${document.extension.toUpperCase().replace(".", "")} | Uploaded ${formatDate(document.created_at)}`; els.ownerLine.textContent = `Owner: ${document.owner?.name || "Owner"}${contactLine(document.owner) ? ` | ${contactLine(document.owner)}` : ""}`; els.statusPill.textContent = document.status_label; els.statusPill.className = `status-pill ${document.status}`; els.wordCount.textContent = `${document.word_count || 0} words`; els.textPreview.textContent = document.text || ""; els.downloadButton.onclick = () => { window.location.href = `/api/documents/${document.id}/download`; }; renderAssignments(document); renderFindings(document); renderComments(document); renderActivity(document); } function renderAssignments(document) { const selectedReviewerIds = new Set((document.assignments || []).map((assignment) => assignment.reviewer_id)); if (!state.reviewers.length) { els.assignmentChoices.innerHTML = `Add reviewers in the sidebar first.
`; } else { els.assignmentChoices.innerHTML = state.reviewers .map( (reviewer) => ` `, ) .join(""); } const counts = document.assignment_counts || {}; const total = assignmentTotal(counts); els.assignmentSummary.textContent = total ? `${counts.approved || 0}/${total} approved` : "No reviewers"; if (!(document.assignments || []).length) { els.assignmentList.innerHTML = `No reviewers assigned.
`; els.reviewerActionSelect.innerHTML = ``; els.reviewerActionForm.classList.add("muted-panel"); return; } els.reviewerActionForm.classList.remove("muted-panel"); els.assignmentList.innerHTML = document.assignments .map( (assignment) => `Run a review to check required sections, placeholders, and vague language.
`; return; } const counts = countsFor(document); els.findingSummary.textContent = `${counts.high} high, ${counts.medium} medium, ${counts.low} low`; if (!findings.length) { els.findings.innerHTML = `No findings from the local rubric.
`; return; } const groups = ["high", "medium", "low"] .map((severity) => findings.filter((finding) => finding.severity === severity)) .filter((group) => group.length); els.findings.innerHTML = groups .map((group) => { const severity = group[0].severity; return `${escapeHtml(finding.detail)}
${finding.snippet ? `No comments yet.
`; return; } els.comments.innerHTML = comments .slice() .reverse() .map( (comment) => `${escapeHtml(comment.body)}
No activity yet.
`; return; } els.activityTimeline.innerHTML = activity .map( (item) => `${escapeHtml(item.message)}
No reviewers yet.
`; return; } els.reviewerList.innerHTML = state.reviewers .map( (reviewer) => `No notifications yet.
`; return; } els.notificationList.innerHTML = state.notifications .map( (notification) => `${escapeHtml(notification.message)}