Add optional nursery roster composition

This commit is contained in:
Media
2026-08-30 20:04:00 -05:00
parent 3eab672c6c
commit 7651a3ed9d
12 changed files with 1578 additions and 116 deletions
+86 -29
View File
@@ -4,6 +4,7 @@ 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");
@@ -29,7 +30,7 @@ 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 selectedFiles = [];
let resultUrl = null;
const themeChoices = new Set(["system", "light", "dark"]);
@@ -49,19 +50,41 @@ function setThemeChoice(choice, persist = true) {
}
}
function setFile(file) {
selectedFile = file || null;
if (!selectedFile) {
function setFiles(files) {
selectedFiles = [...files];
selectedFilesList.replaceChildren();
if (!selectedFiles.length) {
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`;
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 = selectedFile.name;
dropCopy.textContent = `${formatFileSize(selectedFile.size)} · ready to format`;
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) {
@@ -71,6 +94,46 @@ function formatFileSize(bytes) {
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;
@@ -221,8 +284,12 @@ async function loadHistory() {
}
fileInput.addEventListener("change", () => {
setFile(fileInput.files[0] || null);
clearError();
if (!fileInput.files.length) {
setFiles([]);
clearError();
return;
}
chooseFiles(fileInput.files);
});
["dragenter", "dragover"].forEach((eventName) => {
@@ -240,15 +307,12 @@ fileInput.addEventListener("change", () => {
});
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.");
const files = [...event.dataTransfer.files];
if (!files.length) {
showError("Drop one or two roster PDFs here.");
return;
}
setFile(file);
clearError();
chooseFiles(files);
});
window.addEventListener("dragover", (event) => event.preventDefault());
@@ -291,26 +355,19 @@ form.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
if (!selectedFile) {
showError("Choose a roster PDF first.");
const selectionError = fileSelectionError(selectedFiles);
if (selectionError) {
showError(selectionError);
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);
selectedFiles.forEach((file) => formData.append("roster", file, file.name));
setBusy(true);
try {
@@ -355,7 +412,7 @@ form.addEventListener("submit", async (event) => {
});
formatAnother.addEventListener("click", () => {
setFile(null);
setFiles([]);
resultCard.hidden = true;
clearError();
dropZone.scrollIntoView({ behavior: "smooth", block: "center" });