593 lines
20 KiB
HTML
593 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Cary Swim Club Management</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--ink: #10231a;
|
|
--ink-soft: #4b5f56;
|
|
--surface: #ffffff;
|
|
--surface-soft: #f4fbef;
|
|
--line: #d8e8cf;
|
|
--accent: #4cbb17;
|
|
--accent-strong: #3b9612;
|
|
--danger: #b63838;
|
|
--bg-a: #f6fbf2;
|
|
--bg-b: #deefd1;
|
|
--hero-a: #1f3e22;
|
|
--hero-b: #2f642d;
|
|
--focus-ring: rgba(76, 187, 23, 0.28);
|
|
--shadow: 0 14px 34px rgba(17, 43, 20, 0.14);
|
|
}
|
|
|
|
body[data-theme="dark"] {
|
|
--ink: #e7f6df;
|
|
--ink-soft: #a5c1af;
|
|
--surface: #132118;
|
|
--surface-soft: #1a2d21;
|
|
--line: #264032;
|
|
--accent: #65d82c;
|
|
--accent-strong: #4cbb17;
|
|
--danger: #dc5959;
|
|
--bg-a: #0a140d;
|
|
--bg-b: #12211a;
|
|
--hero-a: #15341c;
|
|
--hero-b: #1f4c28;
|
|
--focus-ring: rgba(101, 216, 44, 0.25);
|
|
--shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
|
color: var(--ink);
|
|
background:
|
|
radial-gradient(circle at 78% 10%, rgba(76, 187, 23, 0.22), transparent 32%),
|
|
radial-gradient(circle at 10% 85%, rgba(61, 120, 67, 0.16), transparent 36%),
|
|
linear-gradient(160deg, var(--bg-a), var(--bg-b));
|
|
background-attachment: fixed;
|
|
padding: 20px;
|
|
transition: background 260ms ease, color 260ms ease;
|
|
}
|
|
|
|
.page {
|
|
max-width: 1100px;
|
|
margin: 0 auto;
|
|
display: grid;
|
|
gap: 16px;
|
|
}
|
|
|
|
.hero {
|
|
border-radius: 18px;
|
|
padding: 20px;
|
|
background:
|
|
linear-gradient(120deg, rgba(255, 255, 255, 0.05), transparent 42%),
|
|
linear-gradient(140deg, var(--hero-a), var(--hero-b));
|
|
color: #f2ffe9;
|
|
box-shadow: var(--shadow);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.hero::after {
|
|
content: "";
|
|
position: absolute;
|
|
width: 220px;
|
|
height: 220px;
|
|
right: -70px;
|
|
top: -105px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle, rgba(151, 255, 109, 0.24), transparent 70%);
|
|
}
|
|
|
|
.hero h1 {
|
|
margin: 0;
|
|
font-family: "Archivo Black", "Impact", sans-serif;
|
|
font-size: clamp(1.3rem, 2vw + 1rem, 2rem);
|
|
}
|
|
|
|
.hero p {
|
|
margin: 8px 0 0;
|
|
color: #d8f6c8;
|
|
}
|
|
|
|
.club-tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(196, 250, 166, 0.4);
|
|
background: rgba(13, 31, 14, 0.25);
|
|
padding: 5px 11px;
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.btn {
|
|
border: 0;
|
|
border-radius: 11px;
|
|
padding: 9px 13px;
|
|
font: inherit;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
display: inline-flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.btn-main {
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-ghost {
|
|
background: var(--surface-soft);
|
|
color: var(--ink);
|
|
border: 1px solid var(--line);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: linear-gradient(135deg, #c44d4d, var(--danger));
|
|
color: #fff;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
gap: 14px;
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
|
|
.panel {
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: 16px;
|
|
padding: 14px;
|
|
box-shadow: var(--shadow);
|
|
transition: background 220ms ease, border-color 220ms ease;
|
|
}
|
|
|
|
.panel h2 {
|
|
margin: 0;
|
|
font-size: 1.08rem;
|
|
}
|
|
|
|
.sub {
|
|
margin: 6px 0 10px;
|
|
color: var(--ink-soft);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.form {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 8px;
|
|
}
|
|
|
|
label {
|
|
display: grid;
|
|
gap: 4px;
|
|
font-size: 0.84rem;
|
|
color: var(--ink-soft);
|
|
}
|
|
|
|
input,
|
|
select {
|
|
border: 1px solid var(--line);
|
|
border-radius: 10px;
|
|
padding: 9px 10px;
|
|
font: inherit;
|
|
color: var(--ink);
|
|
background: var(--surface-soft);
|
|
}
|
|
|
|
input:focus,
|
|
select:focus {
|
|
outline: none;
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 4px var(--focus-ring);
|
|
}
|
|
|
|
.theme-panel {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 10px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
background: var(--surface-soft);
|
|
padding: 10px 12px;
|
|
}
|
|
|
|
.theme-label {
|
|
display: grid;
|
|
gap: 2px;
|
|
}
|
|
|
|
.theme-label strong {
|
|
font-size: 0.92rem;
|
|
}
|
|
|
|
.theme-label span {
|
|
color: var(--ink-soft);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.theme-toggle {
|
|
border: 1px solid var(--line);
|
|
border-radius: 12px;
|
|
background: color-mix(in srgb, var(--surface-soft) 85%, transparent);
|
|
padding: 3px;
|
|
display: inline-grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 3px;
|
|
min-width: 170px;
|
|
}
|
|
|
|
.theme-toggle button {
|
|
border: 0;
|
|
border-radius: 9px;
|
|
padding: 8px 10px;
|
|
font: inherit;
|
|
font-size: 0.82rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
color: var(--ink);
|
|
background: transparent;
|
|
}
|
|
|
|
.theme-toggle button.active {
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
|
color: #fff;
|
|
box-shadow: 0 8px 20px rgba(9, 35, 15, 0.26);
|
|
}
|
|
|
|
.action-list {
|
|
display: grid;
|
|
gap: 8px;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.status {
|
|
border: 1px dashed var(--line);
|
|
border-radius: 11px;
|
|
padding: 10px 11px;
|
|
color: var(--ink-soft);
|
|
font-size: 0.88rem;
|
|
background: var(--surface-soft);
|
|
}
|
|
|
|
.danger-zone {
|
|
border-color: color-mix(in srgb, var(--danger) 35%, var(--line));
|
|
}
|
|
|
|
.danger-zone .sub {
|
|
color: color-mix(in srgb, var(--danger) 78%, var(--ink-soft));
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
* {
|
|
animation: none !important;
|
|
transition: none !important;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.grid { grid-template-columns: 1fr; }
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
body { padding: 12px; }
|
|
.row { grid-template-columns: 1fr; }
|
|
.action-list { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="page">
|
|
<section class="hero">
|
|
<div class="club-tag">Cary Swim Club</div>
|
|
<h1>Management</h1>
|
|
<p>Add families, add members, and run core administrative tasks.</p>
|
|
</section>
|
|
|
|
<section class="toolbar">
|
|
<a class="btn btn-ghost" href="/">Back To Family Search</a>
|
|
<a class="btn btn-ghost" id="openFirstFamily" href="#">Open First Family</a>
|
|
</section>
|
|
|
|
<section class="theme-panel" aria-label="Theme controls">
|
|
<div class="theme-label">
|
|
<strong>Display Theme</strong>
|
|
<span>Switch between daylight deck mode and evening mode.</span>
|
|
</div>
|
|
<div class="theme-toggle" role="group" aria-label="Color theme selection">
|
|
<button id="themeLightBtn" type="button">Light</button>
|
|
<button id="themeDarkBtn" type="button">Dark</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="grid">
|
|
<article class="panel">
|
|
<h2>Add Family</h2>
|
|
<p class="sub">Create a new household record.</p>
|
|
<form id="familyForm" class="form">
|
|
<label>
|
|
Family Name
|
|
<input id="familyName" type="text" required placeholder="Garcia">
|
|
</label>
|
|
<label>
|
|
Primary Phone
|
|
<input id="familyPhone" type="tel" placeholder="555-201-0141">
|
|
</label>
|
|
<button class="btn btn-main" type="submit">Add Family</button>
|
|
</form>
|
|
</article>
|
|
|
|
<article class="panel">
|
|
<h2>Add Member To Family</h2>
|
|
<p class="sub">Create an individual member attached to a family.</p>
|
|
<form id="memberForm" class="form">
|
|
<div class="row">
|
|
<label>
|
|
First Name
|
|
<input id="memberFirst" type="text" required placeholder="Sofia">
|
|
</label>
|
|
<label>
|
|
Last Name
|
|
<input id="memberLast" type="text" required placeholder="Garcia">
|
|
</label>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<label>
|
|
Age
|
|
<input id="memberAge" type="number" min="0" required>
|
|
</label>
|
|
<label>
|
|
Family
|
|
<select id="memberFamily" required></select>
|
|
</label>
|
|
</div>
|
|
|
|
<button class="btn btn-main" type="submit">Add Member</button>
|
|
</form>
|
|
</article>
|
|
|
|
<article class="panel danger-zone">
|
|
<h2>Family Danger Zone</h2>
|
|
<p class="sub">Delete a family and all members inside it.</p>
|
|
<div class="form">
|
|
<label>
|
|
Family To Delete
|
|
<select id="deleteFamilySelect"></select>
|
|
</label>
|
|
<button id="deleteFamilyBtn" class="btn btn-danger" type="button">Delete Family + Members</button>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="panel" style="grid-column: 1 / -1;">
|
|
<h2>Administrative Actions</h2>
|
|
<p class="sub">Database maintenance and operational actions.</p>
|
|
<div class="action-list">
|
|
<button class="btn btn-ghost" data-action="/api/admin/backup">Create Backup</button>
|
|
<button class="btn btn-ghost" data-action="/api/admin/export">Export CSV</button>
|
|
<button class="btn btn-ghost" data-action="/api/admin/reindex">Rebuild Indexes</button>
|
|
<button class="btn btn-ghost" data-action="/api/admin/vacuum">Run VACUUM</button>
|
|
<button class="btn btn-danger" data-action="/api/admin/cleanup-orphan-members" data-confirm="Delete all members with null or missing family ids? This cannot be undone.">Cleanup Orphan Members</button>
|
|
<button class="btn btn-danger" data-action="/api/admin/reset-checkins" data-confirm="Check out all members right now?">Check Out Everyone</button>
|
|
<button class="btn btn-danger" data-action="/api/admin/reset-swim-tests" data-confirm="Reset all swim tests to not passed?">Reset Swim Tests</button>
|
|
</div>
|
|
<div id="status" class="status" style="margin-top: 10px;">Ready.</div>
|
|
</article>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
const THEME_KEY = "csc-theme";
|
|
|
|
function getPreferredTheme() {
|
|
const saved = window.localStorage.getItem(THEME_KEY);
|
|
if (saved === "dark" || saved === "light") {
|
|
return saved;
|
|
}
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
}
|
|
|
|
function applyTheme(theme) {
|
|
document.body.setAttribute("data-theme", theme);
|
|
const lightBtn = document.getElementById("themeLightBtn");
|
|
const darkBtn = document.getElementById("themeDarkBtn");
|
|
if (lightBtn && darkBtn) {
|
|
lightBtn.classList.toggle("active", theme === "light");
|
|
darkBtn.classList.toggle("active", theme === "dark");
|
|
lightBtn.setAttribute("aria-pressed", String(theme === "light"));
|
|
darkBtn.setAttribute("aria-pressed", String(theme === "dark"));
|
|
}
|
|
}
|
|
|
|
function setTheme(theme) {
|
|
window.localStorage.setItem(THEME_KEY, theme);
|
|
applyTheme(theme);
|
|
}
|
|
|
|
applyTheme(getPreferredTheme());
|
|
|
|
const familyForm = document.getElementById("familyForm");
|
|
const memberForm = document.getElementById("memberForm");
|
|
const memberFamily = document.getElementById("memberFamily");
|
|
const deleteFamilySelect = document.getElementById("deleteFamilySelect");
|
|
const deleteFamilyBtn = document.getElementById("deleteFamilyBtn");
|
|
const statusBox = document.getElementById("status");
|
|
const openFirstFamily = document.getElementById("openFirstFamily");
|
|
const themeLightBtn = document.getElementById("themeLightBtn");
|
|
const themeDarkBtn = document.getElementById("themeDarkBtn");
|
|
|
|
function setStatus(message) {
|
|
const stamp = new Date().toLocaleTimeString();
|
|
statusBox.textContent = `${stamp}: ${message}`;
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const response = await fetch(path, {
|
|
headers: { "Content-Type": "application/json" },
|
|
...options,
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(payload.error || payload.message || "Request failed");
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
async function loadFamiliesForSelect() {
|
|
const payload = await api("/api/families");
|
|
const families = payload.items || [];
|
|
|
|
memberFamily.innerHTML = "";
|
|
deleteFamilySelect.innerHTML = "";
|
|
families.forEach((family) => {
|
|
const option = document.createElement("option");
|
|
option.value = String(family.id);
|
|
option.textContent = `${family.familyName} Family`;
|
|
memberFamily.append(option);
|
|
|
|
const deleteOption = document.createElement("option");
|
|
deleteOption.value = String(family.id);
|
|
deleteOption.textContent = `${family.familyName} Family`;
|
|
deleteFamilySelect.append(deleteOption);
|
|
});
|
|
|
|
if (families.length > 0) {
|
|
openFirstFamily.href = `/family/${families[0].id}`;
|
|
memberFamily.disabled = false;
|
|
deleteFamilySelect.disabled = false;
|
|
deleteFamilyBtn.disabled = false;
|
|
} else {
|
|
openFirstFamily.href = "/";
|
|
memberFamily.disabled = true;
|
|
deleteFamilySelect.disabled = true;
|
|
deleteFamilyBtn.disabled = true;
|
|
}
|
|
}
|
|
|
|
familyForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const familyName = document.getElementById("familyName").value.trim();
|
|
const primaryPhone = document.getElementById("familyPhone").value.trim();
|
|
|
|
try {
|
|
await api("/api/families", {
|
|
method: "POST",
|
|
body: JSON.stringify({ familyName, primaryPhone }),
|
|
});
|
|
familyForm.reset();
|
|
await loadFamiliesForSelect();
|
|
setStatus("Family added.");
|
|
} catch (error) {
|
|
setStatus(`Error: ${error.message}`);
|
|
}
|
|
});
|
|
|
|
memberForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
if (!memberFamily.value) {
|
|
setStatus("Error: Add a family before adding members.");
|
|
return;
|
|
}
|
|
const firstName = document.getElementById("memberFirst").value.trim();
|
|
const lastName = document.getElementById("memberLast").value.trim();
|
|
const age = Number.parseInt(document.getElementById("memberAge").value, 10);
|
|
const familyId = Number.parseInt(memberFamily.value, 10);
|
|
|
|
try {
|
|
await api("/api/members", {
|
|
method: "POST",
|
|
body: JSON.stringify({ firstName, lastName, age, familyId }),
|
|
});
|
|
memberForm.reset();
|
|
setStatus("Member added to family.");
|
|
} catch (error) {
|
|
setStatus(`Error: ${error.message}`);
|
|
}
|
|
});
|
|
|
|
deleteFamilyBtn.addEventListener("click", async () => {
|
|
const selectedId = Number.parseInt(deleteFamilySelect.value, 10);
|
|
if (!Number.isInteger(selectedId)) {
|
|
setStatus("Error: Select a family to delete.");
|
|
return;
|
|
}
|
|
|
|
const selectedLabel = deleteFamilySelect.options[deleteFamilySelect.selectedIndex]?.textContent || "this family";
|
|
const confirmed = window.confirm(
|
|
`Delete ${selectedLabel} and all members in it? This cannot be undone.`
|
|
);
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
deleteFamilyBtn.disabled = true;
|
|
try {
|
|
const payload = await api(`/api/families/${selectedId}`, { method: "DELETE" });
|
|
await loadFamiliesForSelect();
|
|
setStatus(payload.message || "Family deleted.");
|
|
} catch (error) {
|
|
setStatus(`Error: ${error.message}`);
|
|
} finally {
|
|
deleteFamilyBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll("[data-action]").forEach((button) => {
|
|
button.addEventListener("click", async () => {
|
|
const path = button.getAttribute("data-action");
|
|
const confirmText = button.getAttribute("data-confirm");
|
|
if (confirmText && !window.confirm(confirmText)) {
|
|
return;
|
|
}
|
|
button.disabled = true;
|
|
try {
|
|
const payload = await api(path, { method: "POST" });
|
|
setStatus(payload.message || "Action completed.");
|
|
if (path === "/api/admin/cleanup-orphan-members") {
|
|
await loadFamiliesForSelect();
|
|
}
|
|
} catch (error) {
|
|
setStatus(`Error: ${error.message}`);
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
themeLightBtn.addEventListener("click", () => setTheme("light"));
|
|
themeDarkBtn.addEventListener("click", () => setTheme("dark"));
|
|
|
|
loadFamiliesForSelect();
|
|
</script>
|
|
</body>
|
|
</html>
|