Files
Roster/web/app.js
T

373 lines
12 KiB
JavaScript

const body = document.body;
const form = document.querySelector("#roster-form");
const fileInput = document.querySelector("#roster-file");
const dropZone = document.querySelector("#drop-zone");
const dropTitle = document.querySelector("#drop-title");
const dropCopy = document.querySelector("#drop-copy");
const monthInput = document.querySelector("#month");
const suggestedMonthButton = document.querySelector("#suggested-month");
const resetLayoutButton = document.querySelector("#reset-layout");
const factoryLayoutButton = document.querySelector("#factory-layout");
const saveLayoutDefaultsButton = document.querySelector("#save-layout-defaults");
const layoutMessage = document.querySelector("#layout-message");
const layoutInputs = [...document.querySelectorAll("[data-layout-input]")];
const defaultsStatus = document.querySelector("#defaults-status");
const themeChoiceButtons = [...document.querySelectorAll("[data-theme-choice]")];
const formMessage = document.querySelector("#form-message");
const formatButton = document.querySelector("#format-button");
const buttonLabel = formatButton.querySelector(".button-label");
const resultCard = document.querySelector("#result-card");
const resultPreview = document.querySelector("#result-preview");
const resultName = document.querySelector("#result-name");
const downloadResult = document.querySelector("#download-result");
const formatAnother = document.querySelector("#format-another");
const historyList = document.querySelector("#history-list");
const refreshHistoryButton = document.querySelector("#refresh-history");
const retentionNote = document.querySelector("#retention-note");
const defaultMonth = body.dataset.defaultMonth;
const retentionMonths = Number(body.dataset.retentionMonths);
const maximumUploadMb = Number(body.dataset.maxUploadMb);
const maximumUploadBytes = maximumUploadMb * 1024 * 1024;
let selectedFile = null;
let resultUrl = null;
const themeChoices = new Set(["system", "light", "dark"]);
function setThemeChoice(choice, persist = true) {
const selected = themeChoices.has(choice) ? choice : "system";
document.documentElement.dataset.theme = selected;
themeChoiceButtons.forEach((button) => {
button.setAttribute("aria-pressed", String(button.dataset.themeChoice === selected));
});
if (persist) {
try {
localStorage.setItem("roster-theme", selected);
} catch {
// The theme still works when browser storage is unavailable.
}
}
}
function setFile(file) {
selectedFile = file || null;
if (!selectedFile) {
fileInput.value = "";
dropZone.classList.remove("has-file");
dropTitle.textContent = "Choose your roster PDF";
dropCopy.textContent = `or drag it here · one-page landscape Letter · up to ${maximumUploadMb} MB`;
return;
}
dropZone.classList.add("has-file");
dropTitle.textContent = selectedFile.name;
dropCopy.textContent = `${formatFileSize(selectedFile.size)} · ready to format`;
}
function formatFileSize(bytes) {
if (bytes < 1024 * 1024) {
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function showError(message) {
formMessage.textContent = message;
formMessage.hidden = false;
}
function clearError() {
formMessage.textContent = "";
formMessage.hidden = true;
}
function showLayoutMessage(message, isError = false) {
layoutMessage.textContent = message;
layoutMessage.classList.toggle("is-error", isError);
layoutMessage.hidden = false;
}
function clearLayoutMessage() {
layoutMessage.textContent = "";
layoutMessage.classList.remove("is-error");
layoutMessage.hidden = true;
}
function setBusy(busy) {
body.classList.toggle("is-busy", busy);
form.setAttribute("aria-busy", String(busy));
formatButton.disabled = busy;
buttonLabel.textContent = busy ? "Formatting…" : "Format roster";
}
function updateDefaultsStatus() {
const custom = layoutInputs.some((input) => {
if (input.value.trim() === "") {
return true;
}
return Number(input.value) !== Number(input.dataset.default);
});
defaultsStatus.textContent = custom ? "Unsaved layout" : "Saved defaults";
defaultsStatus.classList.toggle("is-custom", custom);
}
async function saveLayoutDefaults() {
const invalidInput = layoutInputs.find((input) => !input.checkValidity());
if (invalidInput) {
invalidInput.reportValidity();
invalidInput.focus();
return;
}
const values = Object.fromEntries(layoutInputs.map((input) => [input.name, input.value]));
const originalLabel = saveLayoutDefaultsButton.textContent;
saveLayoutDefaultsButton.disabled = true;
saveLayoutDefaultsButton.textContent = "Saving…";
clearLayoutMessage();
try {
const response = await fetch("/api/layout-defaults", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
let payload = {};
try {
payload = await response.json();
} catch {
// Use the safe fallback below if the server returns an unexpected response.
}
if (!response.ok) {
throw new Error(payload.error || "The layout defaults could not be saved.");
}
if (!payload.defaults || typeof payload.defaults !== "object") {
throw new Error("The server returned an invalid defaults response.");
}
const canonicalValues = layoutInputs.map((input) => {
const savedValue = payload.defaults[input.name];
if (savedValue === undefined) {
throw new Error("The server returned an incomplete defaults response.");
}
return String(savedValue);
});
layoutInputs.forEach((input, index) => {
input.value = canonicalValues[index];
input.dataset.default = canonicalValues[index];
});
updateDefaultsStatus();
showLayoutMessage("Saved. These values will prefill future rosters.");
} catch (error) {
showLayoutMessage(error.message || "The layout defaults could not be saved.", true);
} finally {
saveLayoutDefaultsButton.disabled = false;
saveLayoutDefaultsButton.textContent = originalLabel;
}
}
function responseFilename(response) {
const disposition = response.headers.get("Content-Disposition") || "";
const match = disposition.match(/filename="([^"]+)"/i);
return match ? match[1] : "Formatted Roster.png";
}
function createHistoryItem(item) {
const link = document.createElement("a");
link.className = "history-item";
link.href = item.url;
link.download = item.filename;
const file = document.createElement("span");
file.className = "history-file";
const name = document.createElement("strong");
name.textContent = item.filename;
const details = document.createElement("small");
details.textContent = `${formatFileSize(item.size)} · PNG`;
file.append(name, details);
const icon = document.createElement("span");
icon.className = "history-download";
icon.setAttribute("aria-hidden", "true");
icon.textContent = "↓";
link.append(file, icon);
return link;
}
async function loadHistory() {
refreshHistoryButton.disabled = true;
try {
const response = await fetch("/api/history", { cache: "no-store" });
if (!response.ok) {
throw new Error("History could not be loaded.");
}
const payload = await response.json();
historyList.replaceChildren();
if (!payload.items.length) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = "Your formatted rosters will appear here.";
historyList.append(empty);
return;
}
payload.items.forEach((item) => historyList.append(createHistoryItem(item)));
} catch (error) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = error.message;
historyList.replaceChildren(empty);
} finally {
refreshHistoryButton.disabled = false;
}
}
fileInput.addEventListener("change", () => {
setFile(fileInput.files[0] || null);
clearError();
});
["dragenter", "dragover"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.add("is-dragging");
});
});
["dragleave", "drop"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.remove("is-dragging");
});
});
dropZone.addEventListener("drop", (event) => {
const file = [...event.dataTransfer.files].find(
(candidate) => candidate.type === "application/pdf" || candidate.name.toLowerCase().endsWith(".pdf"),
);
if (!file) {
showError("Drop a PDF file here.");
return;
}
setFile(file);
clearError();
});
window.addEventListener("dragover", (event) => event.preventDefault());
window.addEventListener("drop", (event) => event.preventDefault());
suggestedMonthButton.addEventListener("click", () => {
monthInput.value = defaultMonth;
});
themeChoiceButtons.forEach((button) => {
button.addEventListener("click", () => setThemeChoice(button.dataset.themeChoice));
});
layoutInputs.forEach((input) => {
input.addEventListener("input", () => {
updateDefaultsStatus();
clearLayoutMessage();
});
});
resetLayoutButton.addEventListener("click", () => {
layoutInputs.forEach((input) => {
input.value = input.dataset.default;
});
updateDefaultsStatus();
showLayoutMessage("Restored the saved defaults.");
});
factoryLayoutButton.addEventListener("click", () => {
layoutInputs.forEach((input) => {
input.value = input.dataset.factory;
});
updateDefaultsStatus();
showLayoutMessage("Original production values loaded. Save them to make them the defaults.");
});
saveLayoutDefaultsButton.addEventListener("click", saveLayoutDefaults);
form.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
if (!selectedFile) {
showError("Choose a roster PDF first.");
fileInput.focus();
return;
}
if (!selectedFile.name.toLowerCase().endsWith(".pdf")) {
showError("The roster filename must end in .pdf.");
return;
}
if (selectedFile.size > maximumUploadBytes) {
showError(`The roster PDF must be ${maximumUploadMb} MB or smaller.`);
return;
}
if (!form.reportValidity()) {
return;
}
const formData = new FormData(form);
formData.delete("roster");
formData.append("roster", selectedFile, selectedFile.name);
setBusy(true);
try {
const response = await fetch("/format", {
method: "POST",
body: formData,
});
if (!response.ok) {
let message = "The roster could not be formatted.";
try {
const payload = await response.json();
message = payload.error || message;
} catch {
// Keep the safe fallback for an unexpected non-JSON response.
}
throw new Error(message);
}
const blob = await response.blob();
const filename = responseFilename(response);
const saved = response.headers.get("X-Roster-Saved") === "true";
if (resultUrl) {
URL.revokeObjectURL(resultUrl);
}
resultUrl = URL.createObjectURL(blob);
resultPreview.src = resultUrl;
downloadResult.href = resultUrl;
downloadResult.download = filename;
resultName.textContent = saved
? `${filename} · saved in output/`
: `${filename} · available for download (outside retention window)`;
resultCard.hidden = false;
await loadHistory();
resultCard.focus({ preventScroll: true });
resultCard.scrollIntoView({ behavior: "smooth", block: "start" });
} catch (error) {
showError(error.message || "The roster could not be formatted.");
formMessage.scrollIntoView({ behavior: "smooth", block: "center" });
} finally {
setBusy(false);
}
});
formatAnother.addEventListener("click", () => {
setFile(null);
resultCard.hidden = true;
clearError();
dropZone.scrollIntoView({ behavior: "smooth", block: "center" });
});
refreshHistoryButton.addEventListener("click", loadHistory);
if (retentionMonths === 0) {
retentionNote.textContent = "Automatic roster cleanup is disabled.";
}
setThemeChoice(document.documentElement.dataset.theme, false);
updateDefaultsStatus();
loadHistory();