Add optional nursery roster composition
This commit is contained in:
+86
-29
@@ -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
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user