430 lines
14 KiB
JavaScript
430 lines
14 KiB
JavaScript
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();
|