Spaces:
Running
Running
Eric Putnam
Claude Opus 5 (1M context)
feat: deploy simpledocreview as a browser-only static space
3d968d9 Download static/app.js from Hoctar77/SimpleDocReview: direct link, hf CLI and curl.
- Browser
- Download file 25.4 kB
-
https://huggingface.co/spaces/Hoctar77/SimpleDocReview/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/Hoctar77/SimpleDocReview/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Hoctar77/SimpleDocReview/resolve/main/static/app.js
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("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| } | |
| 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)); | |