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();