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 selectedFilesList = document.querySelector("#selected-files"); 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 selectedFiles = []; 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 setFiles(files) { selectedFiles = [...files]; selectedFilesList.replaceChildren(); if (!selectedFiles.length) { fileInput.value = ""; dropZone.classList.remove("has-file"); dropTitle.textContent = "Choose your roster PDFs"; dropCopy.textContent = "or drag them here · one main roster, optional nursery roster"; selectedFilesList.hidden = true; return; } dropZone.classList.add("has-file"); dropTitle.textContent = selectedFiles.length === 1 ? "1 roster PDF selected" : "2 roster PDFs selected"; dropCopy.textContent = selectedFiles.length === 1 ? "Ready to format as the main roster" : "Ready to format · roster types are identified automatically"; selectedFiles.forEach((file) => { const item = document.createElement("span"); item.className = "selected-file"; const name = document.createElement("strong"); name.className = "selected-file-name"; name.textContent = file.name; name.title = file.name; const size = document.createElement("span"); size.className = "selected-file-size"; size.textContent = formatFileSize(file.size); item.append(name, size); selectedFilesList.append(item); }); selectedFilesList.hidden = false; } function formatFileSize(bytes) { if (bytes < 1024 * 1024) { return `${Math.max(1, Math.round(bytes / 1024))} KB`; } return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function fileSelectionError(files) { if (!files.length) { return "Choose at least one roster PDF."; } if (files.length > 2) { return "Choose no more than two roster PDFs: one main roster and one optional nursery roster."; } for (const file of files) { const mimeType = (file.type || "").toLowerCase(); const supportedMimeType = !mimeType || mimeType === "application/pdf" || mimeType === "application/x-pdf" || mimeType === "application/octet-stream"; if (!file.name.toLowerCase().endsWith(".pdf") || !supportedMimeType) { return `“${file.name}” must be a PDF file whose name ends in .pdf.`; } if (file.size === 0) { return `“${file.name}” is empty. Choose a valid roster PDF.`; } if (file.size > maximumUploadBytes) { return `“${file.name}” must be ${maximumUploadMb} MB or smaller.`; } } return null; } function chooseFiles(files) { const candidates = [...files]; const error = fileSelectionError(candidates); if (error) { setFiles([]); showError(error); return false; } setFiles(candidates); clearError(); return true; } 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", () => { if (!fileInput.files.length) { setFiles([]); clearError(); return; } chooseFiles(fileInput.files); }); ["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 files = [...event.dataTransfer.files]; if (!files.length) { showError("Drop one or two roster PDFs here."); return; } chooseFiles(files); }); 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(); const selectionError = fileSelectionError(selectedFiles); if (selectionError) { showError(selectionError); fileInput.focus(); return; } if (!form.reportValidity()) { return; } const formData = new FormData(form); formData.delete("roster"); selectedFiles.forEach((file) => formData.append("roster", file, file.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", () => { setFiles([]); 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();