V2: Added Webserver for faster formatting
This commit is contained in:
+372
@@ -0,0 +1,372 @@
|
||||
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 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 selectedFile = null;
|
||||
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 setFile(file) {
|
||||
selectedFile = file || null;
|
||||
if (!selectedFile) {
|
||||
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`;
|
||||
return;
|
||||
}
|
||||
|
||||
dropZone.classList.add("has-file");
|
||||
dropTitle.textContent = selectedFile.name;
|
||||
dropCopy.textContent = `${formatFileSize(selectedFile.size)} · ready to format`;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
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", () => {
|
||||
setFile(fileInput.files[0] || null);
|
||||
clearError();
|
||||
});
|
||||
|
||||
["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 file = [...event.dataTransfer.files].find(
|
||||
(candidate) => candidate.type === "application/pdf" || candidate.name.toLowerCase().endsWith(".pdf"),
|
||||
);
|
||||
if (!file) {
|
||||
showError("Drop a PDF file here.");
|
||||
return;
|
||||
}
|
||||
setFile(file);
|
||||
clearError();
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
if (!selectedFile) {
|
||||
showError("Choose a roster PDF first.");
|
||||
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);
|
||||
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", () => {
|
||||
setFile(null);
|
||||
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();
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="system">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#102d3d">
|
||||
<title>Roster Formatter</title>
|
||||
<script src="/static/theme.js"></script>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body
|
||||
data-default-month="__DEFAULT_MONTH__"
|
||||
data-retention-months="__RETENTION_MONTHS__"
|
||||
data-max-upload-mb="__MAX_UPLOAD_MB__"
|
||||
>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/" aria-label="Roster Formatter home">
|
||||
<img src="/assets/logo.png" alt="River of Glory Church">
|
||||
</a>
|
||||
<div class="header-actions">
|
||||
<div class="theme-switch" role="group" aria-label="Color theme">
|
||||
<button class="theme-option" type="button" data-theme-choice="system" aria-pressed="true">System</button>
|
||||
<button class="theme-option" type="button" data-theme-choice="light" aria-pressed="false">Light</button>
|
||||
<button class="theme-option" type="button" data-theme-choice="dark" aria-pressed="false">Dark</button>
|
||||
</div>
|
||||
<span class="local-badge"><span aria-hidden="true"></span> Local & private</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="intro" aria-labelledby="page-title">
|
||||
<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.
|
||||
</p>
|
||||
<div class="format-facts" aria-label="Output details">
|
||||
<span>PNG</span>
|
||||
<span>1650 × 1275 px</span>
|
||||
<span>150 DPI</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="workspace">
|
||||
<section class="card form-card" aria-labelledby="format-heading">
|
||||
<div class="card-heading">
|
||||
<div>
|
||||
<p class="step-label">New export</p>
|
||||
<h2 id="format-heading">Format a roster</h2>
|
||||
</div>
|
||||
<span class="defaults-status" id="defaults-status">Saved defaults</span>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</label>
|
||||
|
||||
<div class="field-block">
|
||||
<label for="month">Roster month</label>
|
||||
<div class="month-row">
|
||||
<input id="month" name="month" type="month" value="__DEFAULT_MONTH__" required>
|
||||
<button class="text-button" id="suggested-month" type="button">Use suggested</button>
|
||||
</div>
|
||||
<p class="field-help">This controls the label and downloaded filename.</p>
|
||||
</div>
|
||||
|
||||
<details class="advanced" id="advanced-settings">
|
||||
<summary>
|
||||
<span>
|
||||
<strong>Advanced adjustments</strong>
|
||||
<small>Use only when this month needs a nudge.</small>
|
||||
</span>
|
||||
<span class="chevron" aria-hidden="true">⌄</span>
|
||||
</summary>
|
||||
<div class="advanced-body">
|
||||
<div class="advanced-heading">
|
||||
<p>Values use PDF points. 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>Roster content</legend>
|
||||
<div class="setting-grid">
|
||||
<label>
|
||||
Vertical shift
|
||||
<input name="content_shift" type="number" value="__CONTENT_SHIFT__" data-default="__CONTENT_SHIFT__" data-factory="__FACTORY_CONTENT_SHIFT__" data-layout-input step="1" min="-300" max="500" required>
|
||||
</label>
|
||||
<label>
|
||||
Bottom trim
|
||||
<input name="bottom_margin" type="number" value="__BOTTOM_MARGIN__" data-default="__BOTTOM_MARGIN__" data-factory="__FACTORY_BOTTOM_MARGIN__" data-layout-input step="1" min="0" max="500" required>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Logo</legend>
|
||||
<div class="setting-grid setting-grid--three">
|
||||
<label>
|
||||
X position
|
||||
<input name="logo_x" type="number" value="__LOGO_X__" data-default="__LOGO_X__" data-factory="__FACTORY_LOGO_X__" data-layout-input step="1" min="-1000" max="2000" required>
|
||||
</label>
|
||||
<label>
|
||||
Y position
|
||||
<input name="logo_y" type="number" value="__LOGO_Y__" data-default="__LOGO_Y__" data-factory="__FACTORY_LOGO_Y__" data-layout-input step="1" min="-1000" max="2000" required>
|
||||
</label>
|
||||
<label>
|
||||
Scale
|
||||
<input name="logo_scale" type="number" value="__LOGO_SCALE__" data-default="__LOGO_SCALE__" data-factory="__FACTORY_LOGO_SCALE__" data-layout-input step="0.01" min="0.01" max="2" required>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Date label</legend>
|
||||
<div class="setting-grid setting-grid--three">
|
||||
<label>
|
||||
X position
|
||||
<input name="date_x" type="number" value="__DATE_X__" data-default="__DATE_X__" data-factory="__FACTORY_DATE_X__" data-layout-input step="1" min="-1000" max="2000" required>
|
||||
</label>
|
||||
<label>
|
||||
Y position
|
||||
<input name="date_y" type="number" value="__DATE_Y__" data-default="__DATE_Y__" data-factory="__FACTORY_DATE_Y__" data-layout-input step="1" min="-1000" max="2000" required>
|
||||
</label>
|
||||
<label>
|
||||
Font size
|
||||
<input name="date_font_size" type="number" value="__DATE_FONT_SIZE__" data-default="__DATE_FONT_SIZE__" data-factory="__FACTORY_DATE_FONT_SIZE__" data-layout-input step="1" min="8" max="200" required>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="save-defaults-row">
|
||||
<p id="layout-message" role="status" aria-live="polite" hidden></p>
|
||||
<button class="secondary-button save-defaults-button" id="save-layout-defaults" type="button">Save as defaults</button>
|
||||
</div>
|
||||
|
||||
<p class="canvas-lock">
|
||||
The page canvas and 150-DPI export stay fixed, even when these values change.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="form-message" id="form-message" role="alert" hidden></div>
|
||||
<button class="primary-button" id="format-button" type="submit">
|
||||
<span class="button-label">Format roster</span>
|
||||
<span class="button-arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="side-column">
|
||||
<section class="card result-card" id="result-card" aria-labelledby="result-heading" aria-live="polite" tabindex="-1" hidden>
|
||||
<div class="result-heading">
|
||||
<div>
|
||||
<p class="step-label">Ready</p>
|
||||
<h2 id="result-heading">Your formatted roster</h2>
|
||||
</div>
|
||||
<span class="success-mark" aria-hidden="true">✓</span>
|
||||
</div>
|
||||
<div class="preview-shell">
|
||||
<img id="result-preview" alt="Preview of the formatted roster">
|
||||
</div>
|
||||
<p class="result-name" id="result-name"></p>
|
||||
<div class="result-actions">
|
||||
<a class="primary-button" id="download-result" href="#" download>Download PNG</a>
|
||||
<button class="secondary-button" id="format-another" type="button">Format another</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card history-card" aria-labelledby="history-heading">
|
||||
<div class="card-heading compact-heading">
|
||||
<div>
|
||||
<p class="step-label">Saved locally</p>
|
||||
<h2 id="history-heading">Recent rosters</h2>
|
||||
</div>
|
||||
<button class="icon-button" id="refresh-history" type="button" aria-label="Refresh recent rosters">↻</button>
|
||||
</div>
|
||||
<div id="history-list" class="history-list" aria-live="polite">
|
||||
<p class="empty-state">Loading saved rosters…</p>
|
||||
</div>
|
||||
<p class="retention-note" id="retention-note">
|
||||
Saved roster PNGs outside the __RETENTION_MONTHS__-month window are removed automatically.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="privacy-note" aria-label="Privacy information">
|
||||
<span aria-hidden="true">⌂</span>
|
||||
<p>
|
||||
Processing happens on this machine. Uploads and working PDFs are deleted as soon as the PNG is made.
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>River of Glory · Roster tools</span>
|
||||
<span>Fixed 11 × 8.5 in canvas</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
+1074
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
(() => {
|
||||
const allowedThemes = new Set(["system", "light", "dark"]);
|
||||
let theme = "system";
|
||||
|
||||
try {
|
||||
const savedTheme = window.localStorage.getItem("roster-theme");
|
||||
if (allowedThemes.has(savedTheme)) {
|
||||
theme = savedTheme;
|
||||
}
|
||||
} catch {
|
||||
// Storage can be unavailable in hardened or private browsing contexts.
|
||||
}
|
||||
|
||||
document.documentElement.dataset.theme = theme;
|
||||
})();
|
||||
Reference in New Issue
Block a user