Eric Putnam
Claude Opus 5 (1M context)
feat: deploy simpledocreview as a browser-only static space
3d968d9
Raw History Blame Contribute Delete
25.4 kB
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 = `<p class="quiet">No documents in this view.</p>`;
return;
}
els.documentList.innerHTML = filtered
.map((document) => {
const counts = countsFor(document);
const assignments = document.assignment_counts || {};
return `
<button class="queue-item ${document.id === state.selectedId ? "active" : ""}" type="button" data-id="${document.id}">
<span class="queue-title">${escapeHtml(document.filename)}</span>
<span class="queue-meta">
<span>${escapeHtml(document.status_label)}</span>
<span>${document.word_count || 0} words</span>
</span>
<span class="queue-meta">
<span>${assignments.approved || 0}/${assignmentTotal(assignments)} approved</span>
<span class="mini-counts" title="High, medium, and low findings">
<span>${counts.high}</span><span>${counts.medium}</span><span>${counts.low}</span>
</span>
</span>
</button>
`;
})
.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 = `<p class="quiet">Add reviewers in the sidebar first.</p>`;
} else {
els.assignmentChoices.innerHTML = state.reviewers
.map(
(reviewer) => `
<label class="check-card">
<input type="checkbox" value="${escapeHtml(reviewer.id)}" ${selectedReviewerIds.has(reviewer.id) ? "checked" : ""}>
<span>
<strong>${escapeHtml(reviewer.name)}</strong>
<small>${escapeHtml(contactLine(reviewer) || "No contact set")}</small>
</span>
</label>
`,
)
.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 = `<p class="quiet">No reviewers assigned.</p>`;
els.reviewerActionSelect.innerHTML = `<option value="">Assign a reviewer first</option>`;
els.reviewerActionForm.classList.add("muted-panel");
return;
}
els.reviewerActionForm.classList.remove("muted-panel");
els.assignmentList.innerHTML = document.assignments
.map(
(assignment) => `
<article class="assignment-card ${assignment.status}">
<div>
<strong>${escapeHtml(assignment.reviewer_name)}</strong>
<small>${escapeHtml(contactLine(assignment.reviewer_contact) || "No contact set")}</small>
</div>
<span class="status-chip ${assignment.status}">${escapeHtml(assignment.status_label)}</span>
${assignment.approved_at ? `<small>Approved ${formatDate(assignment.approved_at)} as ${escapeHtml(assignment.approval_name)}</small>` : ""}
</article>
`,
)
.join("");
els.reviewerActionSelect.innerHTML = document.assignments
.map((assignment) => `<option value="${escapeHtml(assignment.reviewer_id)}">${escapeHtml(assignment.reviewer_name)}</option>`)
.join("");
}
function renderFindings(document) {
const findings = document.findings || [];
if (!document.reviewed_at) {
els.findingSummary.textContent = "Not reviewed";
els.findings.innerHTML = `<p class="quiet">Run a review to check required sections, placeholders, and vague language.</p>`;
return;
}
const counts = countsFor(document);
els.findingSummary.textContent = `${counts.high} high, ${counts.medium} medium, ${counts.low} low`;
if (!findings.length) {
els.findings.innerHTML = `<p class="quiet">No findings from the local rubric.</p>`;
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 `
<div class="finding-group">
<div class="severity-heading">
<span class="severity-dot ${severity}"></span>
${severity} severity
</div>
${group.map(renderFinding).join("")}
</div>
`;
})
.join("");
}
function renderFinding(finding) {
return `
<article class="finding ${finding.severity}">
<h4>${escapeHtml(finding.title)}</h4>
<p>${escapeHtml(finding.detail)}</p>
${finding.snippet ? `<div class="snippet">${escapeHtml(finding.snippet)}</div>` : ""}
</article>
`;
}
function renderComments(document) {
const comments = document.comments || [];
els.commentCount.textContent = `${comments.length}`;
if (!comments.length) {
els.comments.innerHTML = `<p class="quiet">No comments yet.</p>`;
return;
}
els.comments.innerHTML = comments
.slice()
.reverse()
.map(
(comment) => `
<article class="comment">
<strong>${escapeHtml(comment.author)}</strong>
<time>${formatDate(comment.created_at)}</time>
<p>${escapeHtml(comment.body)}</p>
</article>
`,
)
.join("");
}
function renderActivity(document) {
const activity = document.activity || [];
els.activityCount.textContent = `${activity.length}`;
if (!activity.length) {
els.activityTimeline.innerHTML = `<p class="quiet">No activity yet.</p>`;
return;
}
els.activityTimeline.innerHTML = activity
.map(
(item) => `
<article class="timeline-item">
<span></span>
<div>
<strong>${escapeHtml(item.actor)}</strong>
<p>${escapeHtml(item.message)}</p>
<time>${formatDate(item.created_at)}</time>
</div>
</article>
`,
)
.join("");
}
function renderReviewerList() {
if (!state.reviewers.length) {
els.reviewerList.innerHTML = `<p class="quiet">No reviewers yet.</p>`;
return;
}
els.reviewerList.innerHTML = state.reviewers
.map(
(reviewer) => `
<article class="person-card">
<div>
<strong>${escapeHtml(reviewer.name)}</strong>
<small>${escapeHtml(contactLine(reviewer) || "No contact set")}</small>
</div>
<div class="icon-row">
<button class="secondary tiny" type="button" data-edit-reviewer="${escapeHtml(reviewer.id)}">Edit</button>
<button class="secondary tiny danger-text" type="button" data-delete-reviewer="${escapeHtml(reviewer.id)}">Remove</button>
</div>
</article>
`,
)
.join("");
els.reviewerList.querySelectorAll("[data-edit-reviewer]").forEach((button) => {
button.addEventListener("click", () => editReviewer(button.dataset.editReviewer));
});
els.reviewerList.querySelectorAll("[data-delete-reviewer]").forEach((button) => {
button.addEventListener("click", () => deleteReviewer(button.dataset.deleteReviewer));
});
}
function renderNotifications() {
if (!state.notifications.length) {
els.notificationList.innerHTML = `<p class="quiet">No notifications yet.</p>`;
return;
}
els.notificationList.innerHTML = state.notifications
.map(
(notification) => `
<article class="notification-card ${notification.read_at ? "" : "unread"}">
<div>
<strong>${escapeHtml(notification.recipient_name)}</strong>
<small>${escapeHtml(notification.recipient_type)} | ${formatDate(notification.created_at)}</small>
</div>
<p>${escapeHtml(notification.message)}</p>
<div class="icon-row">
<button class="secondary tiny" type="button" data-copy-notification="${escapeHtml(notification.id)}">Copy</button>
${
notification.read_at
? `<span class="quiet tiny-label">Read</span>`
: `<button class="secondary tiny" type="button" data-read-notification="${escapeHtml(notification.id)}">Mark read</button>`
}
</div>
</article>
`,
)
.join("");
els.notificationList.querySelectorAll("[data-copy-notification]").forEach((button) => {
button.addEventListener("click", () => copyNotification(button.dataset.copyNotification));
});
els.notificationList.querySelectorAll("[data-read-notification]").forEach((button) => {
button.addEventListener("click", () => markNotificationRead(button.dataset.readNotification));
});
}
async function uploadFile(file) {
if (!file) return;
const form = new FormData();
form.append("file", file);
form.append("owner_name", els.ownerName.value.trim());
form.append("owner_email", els.ownerEmail.value.trim());
form.append("owner_slack", els.ownerSlack.value.trim());
form.append("owner_teams", els.ownerTeams.value.trim());
els.uploadButton.disabled = true;
els.uploadButton.textContent = "Uploading...";
try {
const document = await api("/api/upload", { method: "POST", body: form });
showToast("Document uploaded.");
state.selectedId = document.id;
await loadAll();
} catch (error) {
showToast(error.message, true);
} finally {
els.uploadButton.disabled = false;
els.uploadButton.innerHTML = `<span class="button-icon">+</span>Upload document`;
els.fileInput.value = "";
}
}
async function refreshSelectedWith(action, message) {
if (!state.selectedId) return;
try {
state.selectedDocument = await api(`/api/documents/${state.selectedId}/${action}`, { method: "POST" });
showToast(message);
await refreshSelected();
} catch (error) {
showToast(error.message, true);
}
}
async function saveReviewer(event) {
event.preventDefault();
const payload = {
name: els.reviewerName.value.trim(),
email: els.reviewerEmail.value.trim(),
slack: els.reviewerSlack.value.trim(),
teams: els.reviewerTeams.value.trim(),
};
const id = els.reviewerId.value;
try {
if (id) {
await api(`/api/reviewers/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
showToast("Reviewer updated.");
} else {
await api("/api/reviewers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
showToast("Reviewer added.");
}
resetReviewerForm();
await loadAll();
} catch (error) {
showToast(error.message, true);
}
}
function editReviewer(id) {
const reviewer = state.reviewers.find((item) => item.id === id);
if (!reviewer) return;
els.reviewerId.value = reviewer.id;
els.reviewerName.value = reviewer.name;
els.reviewerEmail.value = reviewer.email || "";
els.reviewerSlack.value = reviewer.slack || "";
els.reviewerTeams.value = reviewer.teams || "";
els.reviewerSaveButton.textContent = "Update reviewer";
els.reviewerCancelButton.classList.remove("hidden");
}
function resetReviewerForm() {
els.reviewerForm.reset();
els.reviewerId.value = "";
els.reviewerSaveButton.textContent = "Save reviewer";
els.reviewerCancelButton.classList.add("hidden");
}
async function deleteReviewer(id) {
const reviewer = state.reviewers.find((item) => item.id === id);
if (!reviewer) return;
if (!window.confirm(`Remove ${reviewer.name} from the reviewer directory? Historical assignments stay on documents.`)) {
return;
}
try {
await api(`/api/reviewers/${id}`, { method: "DELETE" });
showToast("Reviewer removed.");
await loadAll();
} catch (error) {
showToast(error.message, true);
}
}
async function assignReviewers(event) {
event.preventDefault();
if (!state.selectedId) return;
const reviewerIds = Array.from(els.assignmentChoices.querySelectorAll("input[type='checkbox']:checked")).map((input) => input.value);
try {
await api(`/api/documents/${state.selectedId}/assign-reviewers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reviewer_ids: reviewerIds }),
});
showToast("Reviewer assignments updated.");
await refreshSelected();
} catch (error) {
showToast(error.message, true);
}
}
async function submitReviewerAction(event) {
event.preventDefault();
const submitter = event.submitter;
const action = submitter?.dataset.reviewerAction || state.reviewerAction;
const reviewerId = els.reviewerActionSelect.value;
const signature = els.reviewerSignature.value.trim();
const body = els.reviewerNote.value.trim();
if (!reviewerId) {
showToast("Choose an assigned reviewer.", true);
return;
}
if (action === "approve" && !signature) {
showToast("Enter a name or initials to approve.", true);
return;
}
if ((action === "comment" || action === "request") && !body) {
showToast("Add a note first.", true);
return;
}
if (action === "approve" && (state.selectedDocument?.findings || []).length) {
if (!window.confirm("This document still has review findings. Approve anyway?")) {
return;
}
}
const endpoint = {
comment: "reviewer-comment",
request: "reviewer-request-changes",
approve: "reviewer-approve",
}[action];
try {
await api(`/api/documents/${state.selectedId}/${endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reviewer_id: reviewerId, signature, body }),
});
els.reviewerNote.value = "";
els.reviewerSignature.value = "";
showToast(action === "approve" ? "Reviewer approval recorded." : "Reviewer response recorded.");
await refreshSelected();
} catch (error) {
showToast(error.message, true);
}
}
async function copyNotification(id) {
const notification = state.notifications.find((item) => item.id === id);
if (!notification) return;
try {
await navigator.clipboard.writeText(notification.message);
showToast("Notification copied.");
} catch {
showToast(notification.message);
}
}
async function markNotificationRead(id) {
try {
await api(`/api/notifications/${id}/read`, { method: "POST" });
showToast("Notification marked read.");
const payload = await api("/api/notifications");
state.notifications = payload.notifications;
renderNavigation(payload.unread_count || 0);
renderNotifications();
} catch (error) {
showToast(error.message, true);
}
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
els.sideTabs.forEach((button) => {
button.addEventListener("click", () => {
state.view = button.dataset.view;
renderNavigation(state.notifications.filter((item) => !item.read_at).length);
});
});
els.uploadButton.addEventListener("click", () => els.fileInput.click());
els.fileInput.addEventListener("change", () => uploadFile(els.fileInput.files[0]));
["dragenter", "dragover"].forEach((eventName) => {
els.dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
els.dropZone.classList.add("dragging");
});
});
["dragleave", "drop"].forEach((eventName) => {
els.dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
els.dropZone.classList.remove("dragging");
});
});
els.dropZone.addEventListener("drop", (event) => uploadFile(event.dataTransfer.files[0]));
els.filters.forEach((button) => {
button.addEventListener("click", () => {
state.filter = button.dataset.filter;
els.filters.forEach((item) => item.classList.toggle("active", item === button));
renderList();
});
});
els.reviewerForm.addEventListener("submit", saveReviewer);
els.reviewerCancelButton.addEventListener("click", resetReviewerForm);
els.assignmentForm.addEventListener("submit", assignReviewers);
els.reviewerActionForm.addEventListener("submit", submitReviewerAction);
els.reviewButton.addEventListener("click", () => refreshSelectedWith("review", "Review complete."));
els.commentForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedId) return;
const body = els.commentBody.value.trim();
if (!body) return;
try {
state.selectedDocument = await api(`/api/documents/${state.selectedId}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body, author: state.selectedDocument?.owner?.name || "Owner" }),
});
els.commentBody.value = "";
showToast("Comment added.");
await refreshSelected();
} catch (error) {
showToast(error.message, true);
}
});
loadAll().catch((error) => showToast(error.message, true));