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" });
+49 -7
View File
@@ -33,8 +33,8 @@
<p class="eyebrow">Roster formatter</p>
<h1 id="page-title">From current roster to ready-to-share PNG.</h1>
<p class="lede">
Upload the one-page PDF, confirm its month, and download the formatted image.
The current layout is preserved unless you intentionally change an advanced value.
Upload your main roster and, when needed, its nursery-only roster. Confirm the month,
then download one formatted image—the PDFs are identified automatically.
</p>
<div class="format-facts" aria-label="Output details">
<span>PNG</span>
@@ -55,10 +55,12 @@
<form id="roster-form" action="/format" method="post" enctype="multipart/form-data">
<label class="drop-zone" id="drop-zone" for="roster-file">
<input id="roster-file" name="roster" type="file" accept="application/pdf,.pdf">
<input id="roster-file" name="roster" type="file" accept="application/pdf,.pdf" multiple>
<span class="upload-mark" aria-hidden="true">↑</span>
<span class="drop-title" id="drop-title">Choose your roster PDF</span>
<span class="drop-copy" id="drop-copy">or drag it here · one-page landscape Letter · up to __MAX_UPLOAD_MB__ MB</span>
<span class="drop-title" id="drop-title">Choose your roster PDFs</span>
<span class="drop-copy" id="drop-copy">or drag them here · one main roster, optional nursery roster</span>
<span class="drop-requirements">One-page landscape Letter PDFs · up to __MAX_UPLOAD_MB__ MB each</span>
<span class="selected-files" id="selected-files" aria-live="polite" hidden></span>
</label>
<div class="field-block">
@@ -80,7 +82,7 @@
</summary>
<div class="advanced-body">
<div class="advanced-heading">
<p>Values use PDF points. Saved defaults prefill every new roster.</p>
<p>Positions use PDF points; scales are multipliers. Saved defaults prefill every new roster.</p>
<div class="layout-actions">
<button class="text-button" id="reset-layout" type="button">Reset changes</button>
<button class="text-button" id="factory-layout" type="button">Use original values</button>
@@ -88,7 +90,7 @@
</div>
<fieldset>
<legend>Roster content</legend>
<legend>Single-PDF roster</legend>
<div class="setting-grid">
<label>
Vertical shift
@@ -101,6 +103,46 @@
</div>
</fieldset>
<p class="combined-settings-note">
The section controls below apply when two PDFs are combined. Each roster can be moved or scaled independently.
</p>
<fieldset>
<legend>Main roster</legend>
<div class="setting-grid setting-grid--three">
<label>
X position
<input name="main_x" type="number" value="__MAIN_X__" data-default="__MAIN_X__" data-factory="__FACTORY_MAIN_X__" data-layout-input step="1" min="-1000" max="2000" required>
</label>
<label>
Y position
<input name="main_y" type="number" value="__MAIN_Y__" data-default="__MAIN_Y__" data-factory="__FACTORY_MAIN_Y__" data-layout-input step="1" min="-1000" max="2000" required>
</label>
<label>
Scale
<input name="main_scale" type="number" value="__MAIN_SCALE__" data-default="__MAIN_SCALE__" data-factory="__FACTORY_MAIN_SCALE__" data-layout-input step="0.01" min="0.1" max="2" required>
</label>
</div>
</fieldset>
<fieldset>
<legend>Nursery-only roster</legend>
<div class="setting-grid setting-grid--three">
<label>
X position
<input name="nursery_x" type="number" value="__NURSERY_X__" data-default="__NURSERY_X__" data-factory="__FACTORY_NURSERY_X__" data-layout-input step="1" min="-1000" max="2000" required>
</label>
<label>
Y position
<input name="nursery_y" type="number" value="__NURSERY_Y__" data-default="__NURSERY_Y__" data-factory="__FACTORY_NURSERY_Y__" data-layout-input step="1" min="-1000" max="2000" required>
</label>
<label>
Scale
<input name="nursery_scale" type="number" value="__NURSERY_SCALE__" data-default="__NURSERY_SCALE__" data-factory="__FACTORY_NURSERY_SCALE__" data-layout-input step="0.01" min="0.1" max="2" required>
</label>
</div>
</fieldset>
<fieldset>
<legend>Logo</legend>
<div class="setting-grid setting-grid--three">
+58
View File
@@ -460,6 +460,53 @@ h1 {
line-height: 1.5;
}
.drop-requirements {
position: relative;
z-index: 1;
margin-top: 3px;
color: var(--ink-soft);
font-size: 0.72rem;
line-height: 1.5;
}
.selected-files {
position: relative;
z-index: 1;
display: grid;
width: min(100%, 440px);
gap: 7px;
margin-top: 17px;
}
.selected-file {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
min-width: 0;
padding: 9px 11px;
color: var(--ink);
border: 1px solid var(--line);
border-radius: 9px;
background: var(--surface-solid);
text-align: left;
}
.selected-file-name {
min-width: 0;
overflow: hidden;
font-size: 0.76rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.selected-file-size {
color: var(--ink-soft);
font-size: 0.69rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.field-block {
margin-top: 30px;
}
@@ -634,6 +681,17 @@ legend {
font-size: 0.72rem;
}
.combined-settings-note {
margin: 17px 0 2px;
padding: 10px 12px;
color: var(--ink-soft);
border: 1px solid var(--line);
border-radius: 9px;
background: var(--surface-solid);
font-size: 0.72rem;
line-height: 1.5;
}
.canvas-lock {
margin: 18px 0 0;
padding: 11px 13px;