Gym — Class Management
A high-energy gym admin for managing the weekly class schedule as a full CRUD. A live stats strip tracks totals, active classes, weekly capacity and trainers above a sortable table listing each class with its type, trainer, day and time, room, a capacity progress bar and a status badge. An Add class button opens a validated modal form, rows edit in place via the same pre-filled dialog, deletes use an inline confirm, and a search box plus type and trainer filters narrow the list instantly.
MCP
程式碼
:root {
--bg: #0d0f12;
--surface: #15181d;
--surface-2: #1d2127;
--elevated: #23282f;
--ink: #f4f6f8;
--ink-2: #c2c8d0;
--muted: #8b929c;
--neon: #c6ff3a;
--neon-d: #a6e016;
--neon-50: rgba(198, 255, 58, 0.12);
--orange: #ff6a2b;
--orange-soft: rgba(255, 106, 43, 0.14);
--line: rgba(255, 255, 255, 0.08);
--line-2: rgba(255, 255, 255, 0.16);
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-2: 0 10px 30px rgba(0, 0, 0, 0.45);
--shadow-3: 0 24px 60px rgba(0, 0, 0, 0.6);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button,
input,
select {
font-family: inherit;
}
:focus-visible {
outline: 2px solid var(--neon);
outline-offset: 2px;
border-radius: var(--r-sm);
}
.app {
max-width: 1120px;
margin: 0 auto;
padding: 28px 24px 64px;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 24px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-mark {
width: 48px;
height: 48px;
border-radius: var(--r-md);
background: var(--neon);
color: #0c0f06;
display: grid;
place-items: center;
font-weight: 900;
font-size: 18px;
letter-spacing: 0.5px;
box-shadow: 0 6px 18px rgba(198, 255, 58, 0.3);
}
.eyebrow {
display: block;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 11px;
font-weight: 700;
color: var(--muted);
}
.brand-text h1 {
margin: 2px 0 0;
font-size: 26px;
font-weight: 800;
letter-spacing: -0.02em;
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid transparent;
border-radius: var(--r-md);
padding: 11px 18px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: transform 0.08s ease, background 0.15s ease, border-color 0.15s ease,
box-shadow 0.15s ease;
white-space: nowrap;
}
.btn:active {
transform: translateY(1px);
}
.btn-neon {
background: var(--neon);
color: #0c0f06;
box-shadow: 0 8px 22px rgba(198, 255, 58, 0.24);
}
.btn-neon:hover {
background: var(--neon-d);
}
.btn-ghost {
background: transparent;
color: var(--ink-2);
border-color: var(--line-2);
}
.btn-ghost:hover {
background: var(--surface-2);
color: var(--ink);
}
.btn-danger {
background: var(--danger);
color: #2a0d0d;
}
.btn-danger:hover {
filter: brightness(1.05);
}
.plus {
font-size: 18px;
font-weight: 800;
line-height: 1;
}
/* ---------- Stats ---------- */
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 22px;
}
.stat {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px 18px;
box-shadow: var(--shadow-1);
}
.stat-label {
display: block;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 11px;
font-weight: 700;
color: var(--muted);
}
.stat-value {
display: block;
margin-top: 6px;
font-size: 28px;
font-weight: 800;
letter-spacing: -0.02em;
}
/* ---------- Toolbar ---------- */
.toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.search {
position: relative;
flex: 1 1 260px;
max-width: 360px;
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
color: var(--muted);
pointer-events: none;
}
.search input {
width: 100%;
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
color: var(--ink);
padding: 12px 14px 12px 42px;
font-size: 14px;
}
.search input::placeholder {
color: var(--muted);
}
.filters {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 11px;
font-weight: 700;
color: var(--muted);
}
select,
input[type="text"],
input[type="time"],
input[type="number"],
input[type="search"] {
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
color: var(--ink);
padding: 10px 12px;
font-size: 14px;
min-height: 42px;
}
.filters select {
min-width: 150px;
}
select:hover,
input:hover {
border-color: rgba(255, 255, 255, 0.26);
}
/* ---------- Table ---------- */
.table-wrap {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: var(--shadow-2);
}
table.classes {
width: 100%;
border-collapse: collapse;
}
.classes thead th {
text-align: left;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
font-weight: 700;
color: var(--muted);
padding: 14px 18px;
background: var(--surface-2);
border-bottom: 1px solid var(--line);
}
.col-actions {
text-align: right;
}
.classes tbody tr {
border-bottom: 1px solid var(--line);
transition: background 0.12s ease;
}
.classes tbody tr:last-child {
border-bottom: none;
}
.classes tbody tr:hover {
background: var(--surface-2);
}
.classes td {
padding: 14px 18px;
font-size: 14px;
vertical-align: middle;
}
.cell-class {
display: flex;
align-items: center;
gap: 12px;
}
.type-dot {
width: 38px;
height: 38px;
border-radius: 11px;
display: grid;
place-items: center;
font-weight: 800;
font-size: 13px;
flex-shrink: 0;
background: var(--neon-50);
color: var(--neon);
}
.cls-name {
font-weight: 700;
letter-spacing: -0.01em;
}
.cls-type {
font-size: 12px;
color: var(--muted);
font-weight: 600;
}
.schedule {
font-weight: 600;
}
.schedule .sched-time {
display: block;
color: var(--muted);
font-size: 12.5px;
font-weight: 500;
}
.cap {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 92px;
}
.cap-num {
font-weight: 700;
font-size: 13px;
}
.cap-bar {
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.cap-fill {
height: 100%;
border-radius: 999px;
background: var(--neon);
}
.cap-fill.high {
background: var(--orange);
}
.cap-fill.full {
background: var(--danger);
}
/* badges */
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
.badge::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.badge-active {
background: rgba(52, 211, 153, 0.14);
color: var(--ok);
}
.badge-draft {
background: rgba(251, 191, 36, 0.14);
color: var(--warn);
}
.badge-cancelled {
background: rgba(248, 113, 113, 0.14);
color: var(--danger);
}
/* row actions */
.row-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.icon-btn {
background: transparent;
border: 1px solid var(--line-2);
color: var(--ink-2);
border-radius: var(--r-sm);
width: 34px;
height: 34px;
display: grid;
place-items: center;
cursor: pointer;
font-size: 16px;
line-height: 1;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.icon-btn:hover {
background: var(--surface-2);
color: var(--ink);
}
.icon-btn.danger:hover {
background: var(--orange-soft);
color: var(--orange);
border-color: rgba(255, 106, 43, 0.4);
}
.icon-btn svg {
width: 16px;
height: 16px;
}
/* inline confirm */
.confirm {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ink-2);
font-weight: 600;
}
.confirm .mini {
padding: 6px 12px;
font-size: 12.5px;
border-radius: var(--r-sm);
}
.empty {
padding: 48px 24px;
text-align: center;
color: var(--muted);
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.empty p {
margin: 0;
font-weight: 600;
}
/* ---------- Modal ---------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(6, 8, 11, 0.7);
backdrop-filter: blur(4px);
display: grid;
place-items: center;
padding: 20px;
z-index: 50;
animation: fade 0.16s ease;
}
.modal {
width: 100%;
max-width: 620px;
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
box-shadow: var(--shadow-3);
max-height: 92vh;
overflow: auto;
animation: pop 0.18s cubic-bezier(0.2, 0.8, 0.3, 1.2);
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 22px;
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
background: var(--surface);
}
.modal-head h2 {
margin: 0;
font-size: 19px;
font-weight: 800;
letter-spacing: -0.02em;
}
.modal-head .icon-btn {
font-size: 22px;
width: 36px;
height: 36px;
}
form {
padding: 22px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.span-2 {
grid-column: 1 / -1;
}
.err {
font-size: 12px;
font-weight: 600;
color: var(--danger);
min-height: 0;
display: none;
}
.field.invalid input,
.field.invalid select {
border-color: var(--danger);
}
.field.invalid .err {
display: block;
}
.switch-row {
display: flex;
gap: 8px;
}
.seg {
flex: 1;
background: var(--surface-2);
border: 1px solid var(--line-2);
color: var(--ink-2);
border-radius: var(--r-sm);
padding: 10px;
font-weight: 700;
font-size: 13px;
cursor: pointer;
transition: all 0.12s ease;
}
.seg:hover {
color: var(--ink);
}
.seg.on {
background: var(--neon-50);
border-color: var(--neon);
color: var(--neon);
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 24px;
}
/* ---------- Toasts ---------- */
.toast-stack {
position: fixed;
bottom: 22px;
right: 22px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 80;
}
.toast {
background: var(--elevated);
border: 1px solid var(--line-2);
border-left: 4px solid var(--neon);
border-radius: var(--r-md);
padding: 12px 16px;
font-size: 14px;
font-weight: 600;
color: var(--ink);
box-shadow: var(--shadow-2);
animation: slidein 0.22s ease;
max-width: 320px;
}
.toast.danger {
border-left-color: var(--orange);
}
.toast.leaving {
animation: slideout 0.22s ease forwards;
}
@keyframes fade {
from {
opacity: 0;
}
}
@keyframes pop {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
}
@keyframes slidein {
from {
opacity: 0;
transform: translateX(20px);
}
}
@keyframes slideout {
to {
opacity: 0;
transform: translateX(20px);
}
}
@keyframes rowin {
from {
background: var(--neon-50);
}
}
tr.flash {
animation: rowin 0.9s ease;
}
/* ---------- Responsive ---------- */
@media (max-width: 880px) {
.stats {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 760px) {
.col-room,
.col-cap,
td.cell-room,
td.cell-cap {
display: none;
}
}
@media (max-width: 520px) {
.app {
padding: 20px 14px 56px;
}
.brand-text h1 {
font-size: 21px;
}
.topbar {
gap: 12px;
}
.addBtn,
#addBtn {
flex: 1;
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.search {
max-width: none;
}
.filters {
width: 100%;
}
.filters select {
flex: 1;
min-width: 0;
}
.grid {
grid-template-columns: 1fr;
}
.span-2 {
grid-column: 1;
}
.classes thead {
display: none;
}
.classes tbody tr {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px 12px;
padding: 14px 16px;
align-items: center;
}
.classes td {
padding: 0;
}
.cell-class {
grid-column: 1 / -1;
}
.cell-trainer,
.cell-schedule {
font-size: 13px;
color: var(--ink-2);
}
td.cell-status {
grid-column: 1;
}
td.cell-actions {
grid-column: 2;
}
td.cell-room,
td.cell-cap {
display: none;
}
.stat-value {
font-size: 24px;
}
}
/* Visibility guard: honor the [hidden] attribute over base display */
.empty[hidden],
.modal-backdrop[hidden] {
display: none;
}(function () {
"use strict";
/* ---------------- In-memory store ---------------- */
var uid = 0;
function id() {
uid += 1;
return "c" + uid;
}
var classes = [
{
id: id(),
name: "Sunrise HIIT",
type: "HIIT",
trainer: "Mara Velez",
day: "Monday",
time: "06:30",
duration: 45,
room: "Studio A",
capacity: 24,
booked: 22,
status: "active",
},
{
id: id(),
name: "Power Strength",
type: "Strength",
trainer: "Theo Brandt",
day: "Monday",
time: "18:00",
duration: 60,
room: "Strength Floor",
capacity: 18,
booked: 18,
status: "active",
},
{
id: id(),
name: "Rhythm Ride",
type: "Spin",
trainer: "Devon Cole",
day: "Tuesday",
time: "19:15",
duration: 50,
room: "Spin Loft",
capacity: 30,
booked: 19,
status: "active",
},
{
id: id(),
name: "Flow & Restore",
type: "Yoga",
trainer: "Priya Nandakumar",
day: "Wednesday",
time: "08:00",
duration: 60,
room: "Mind & Body",
capacity: 20,
booked: 11,
status: "active",
},
{
id: id(),
name: "Knockout Boxing",
type: "Boxing",
trainer: "Imani Okafor",
day: "Thursday",
time: "20:00",
duration: 45,
room: "Studio B",
capacity: 16,
booked: 6,
status: "draft",
},
{
id: id(),
name: "Core Pilates",
type: "Pilates",
trainer: "Luca Ferreira",
day: "Friday",
time: "12:00",
duration: 40,
room: "Mind & Body",
capacity: 14,
booked: 0,
status: "cancelled",
},
];
/* ---------------- Elements ---------------- */
var $ = function (sel, root) {
return (root || document).querySelector(sel);
};
var tbody = $("#tbody");
var empty = $("#empty");
var searchEl = $("#search");
var filterType = $("#filterType");
var filterTrainer = $("#filterTrainer");
var backdrop = $("#backdrop");
var form = $("#form");
var modalTitle = $("#modalTitle");
var toasts = $("#toasts");
var fieldIds = [
"fName",
"fType",
"fTrainer",
"fDay",
"fTime",
"fDuration",
"fRoom",
"fCapacity",
];
var TYPES = ["HIIT", "Strength", "Spin", "Yoga", "Boxing", "Pilates", "Mobility"];
var DAY_ABBR = {
Monday: "Mon",
Tuesday: "Tue",
Wednesday: "Wed",
Thursday: "Thu",
Friday: "Fri",
Saturday: "Sat",
Sunday: "Sun",
};
var currentStatus = "active";
/* ---------------- Helpers ---------------- */
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
}[c];
});
}
function fmtTime(t) {
if (!t) return "";
var parts = t.split(":");
var h = parseInt(parts[0], 10);
var m = parts[1];
var ap = h >= 12 ? "PM" : "AM";
var h12 = h % 12;
if (h12 === 0) h12 = 12;
return h12 + ":" + m + " " + ap;
}
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast" + (kind ? " " + kind : "");
el.textContent = msg;
toasts.appendChild(el);
setTimeout(function () {
el.classList.add("leaving");
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, 220);
}, 2600);
}
function trainers() {
var set = {};
classes.forEach(function (c) {
set[c.trainer] = true;
});
return Object.keys(set).sort();
}
/* ---------------- Stats ---------------- */
function renderStats() {
$("#statTotal").textContent = classes.length;
$("#statActive").textContent = classes.filter(function (c) {
return c.status === "active";
}).length;
$("#statCapacity").textContent = classes
.filter(function (c) {
return c.status === "active";
})
.reduce(function (sum, c) {
return sum + c.capacity;
}, 0);
$("#statTrainers").textContent = trainers().length;
}
/* ---------------- Filter selects ---------------- */
function syncFilterOptions() {
var prevTrainer = filterTrainer.value;
// types are fixed
if (filterType.options.length <= 1) {
TYPES.forEach(function (t) {
var o = document.createElement("option");
o.value = t;
o.textContent = t;
filterType.appendChild(o);
});
}
filterTrainer.innerHTML = '<option value="">All trainers</option>';
trainers().forEach(function (t) {
var o = document.createElement("option");
o.value = t;
o.textContent = t;
filterTrainer.appendChild(o);
});
if (prevTrainer) filterTrainer.value = prevTrainer;
}
/* ---------------- Render rows ---------------- */
function visible() {
var q = searchEl.value.trim().toLowerCase();
var ft = filterType.value;
var fr = filterTrainer.value;
return classes.filter(function (c) {
if (ft && c.type !== ft) return false;
if (fr && c.trainer !== fr) return false;
if (q) {
var hay = (c.name + " " + c.type + " " + c.trainer + " " + c.room + " " + c.day).toLowerCase();
if (hay.indexOf(q) === -1) return false;
}
return true;
});
}
function capFillClass(pct) {
if (pct >= 100) return "full";
if (pct >= 80) return "high";
return "";
}
function rowHtml(c) {
var pct = c.capacity ? Math.round((c.booked / c.capacity) * 100) : 0;
if (pct > 100) pct = 100;
var initials = c.type.slice(0, 2).toUpperCase();
return (
'<td class="cell-class">' +
'<div class="cell-class">' +
'<span class="type-dot">' +
esc(initials) +
"</span>" +
"<span>" +
'<span class="cls-name">' +
esc(c.name) +
"</span><br>" +
'<span class="cls-type">' +
esc(c.type) +
"</span>" +
"</span>" +
"</div>" +
"</td>" +
'<td class="cell-trainer">' +
esc(c.trainer) +
"</td>" +
'<td class="cell-schedule"><span class="schedule">' +
esc(DAY_ABBR[c.day] || c.day) +
'<span class="sched-time">' +
fmtTime(c.time) +
" · " +
c.duration +
" min</span></span></td>" +
'<td class="cell-room col-room">' +
esc(c.room) +
"</td>" +
'<td class="cell-cap col-cap"><div class="cap">' +
'<span class="cap-num">' +
c.booked +
" / " +
c.capacity +
"</span>" +
'<span class="cap-bar"><span class="cap-fill ' +
capFillClass(pct) +
'" style="width:' +
pct +
'%"></span></span>' +
"</div></td>" +
'<td class="cell-status"><span class="badge badge-' +
c.status +
'">' +
c.status.charAt(0).toUpperCase() +
c.status.slice(1) +
"</span></td>" +
'<td class="cell-actions"><div class="row-actions" data-id="' +
c.id +
'">' +
'<button class="icon-btn" data-act="edit" aria-label="Edit ' +
esc(c.name) +
'" title="Edit">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>' +
"</button>" +
'<button class="icon-btn danger" data-act="del" aria-label="Delete ' +
esc(c.name) +
'" title="Delete">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>' +
"</button>" +
"</div></td>"
);
}
function render() {
var rows = visible();
tbody.innerHTML = "";
rows.forEach(function (c) {
var tr = document.createElement("tr");
tr.dataset.id = c.id;
tr.innerHTML = rowHtml(c);
tbody.appendChild(tr);
});
empty.hidden = rows.length !== 0;
renderStats();
}
/* ---------------- Modal ---------------- */
var lastFocused = null;
function setStatusSeg(status) {
currentStatus = status;
var segs = form.querySelectorAll(".seg");
Array.prototype.forEach.call(segs, function (s) {
s.classList.toggle("on", s.dataset.status === status);
});
}
function clearErrors() {
fieldIds.forEach(function (fid) {
var f = $("#" + fid);
if (f) f.closest(".field").classList.remove("invalid");
});
}
function openModal(cls) {
lastFocused = document.activeElement;
form.reset();
clearErrors();
if (cls) {
modalTitle.textContent = "Edit class";
$("#fId").value = cls.id;
$("#fName").value = cls.name;
$("#fType").value = cls.type;
$("#fTrainer").value = cls.trainer;
$("#fDay").value = cls.day;
$("#fTime").value = cls.time;
$("#fDuration").value = cls.duration;
$("#fRoom").value = cls.room;
$("#fCapacity").value = cls.capacity;
setStatusSeg(cls.status);
} else {
modalTitle.textContent = "Add class";
$("#fId").value = "";
setStatusSeg("active");
}
backdrop.hidden = false;
document.body.style.overflow = "hidden";
setTimeout(function () {
$("#fName").focus();
}, 30);
}
function closeModal() {
backdrop.hidden = true;
document.body.style.overflow = "";
if (lastFocused && lastFocused.focus) lastFocused.focus();
}
function setError(fid, msg) {
var f = $("#" + fid);
var field = f.closest(".field");
field.classList.add("invalid");
var err = field.querySelector(".err");
if (err) err.textContent = msg;
}
function validate() {
clearErrors();
var ok = true;
var v = {
name: $("#fName").value.trim(),
type: $("#fType").value,
trainer: $("#fTrainer").value,
day: $("#fDay").value,
time: $("#fTime").value,
duration: parseInt($("#fDuration").value, 10),
room: $("#fRoom").value,
capacity: parseInt($("#fCapacity").value, 10),
};
if (!v.name) {
setError("fName", "Class name is required.");
ok = false;
}
if (!v.type) {
setError("fType", "Pick a type.");
ok = false;
}
if (!v.trainer) {
setError("fTrainer", "Pick a trainer.");
ok = false;
}
if (!v.day) {
setError("fDay", "Pick a day.");
ok = false;
}
if (!v.time) {
setError("fTime", "Set a start time.");
ok = false;
}
if (!v.duration || v.duration < 15) {
setError("fDuration", "Min 15 minutes.");
ok = false;
}
if (!v.room) {
setError("fRoom", "Pick a room.");
ok = false;
}
if (!v.capacity || v.capacity < 1) {
setError("fCapacity", "Capacity must be at least 1.");
ok = false;
}
return ok ? v : null;
}
/* ---------------- Events ---------------- */
$("#addBtn").addEventListener("click", function () {
openModal(null);
});
$("#closeModal").addEventListener("click", closeModal);
$("#cancelModal").addEventListener("click", closeModal);
backdrop.addEventListener("click", function (e) {
if (e.target === backdrop) closeModal();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !backdrop.hidden) closeModal();
});
form.addEventListener("click", function (e) {
var seg = e.target.closest(".seg");
if (seg) {
e.preventDefault();
setStatusSeg(seg.dataset.status);
}
});
form.addEventListener("submit", function (e) {
e.preventDefault();
var v = validate();
if (!v) {
toast("Please fix the highlighted fields.", "danger");
return;
}
var editId = $("#fId").value;
if (editId) {
var c = classes.find(function (x) {
return x.id === editId;
});
if (c) {
if (v.capacity < c.booked) c.booked = v.capacity;
c.name = v.name;
c.type = v.type;
c.trainer = v.trainer;
c.day = v.day;
c.time = v.time;
c.duration = v.duration;
c.room = v.room;
c.capacity = v.capacity;
c.status = currentStatus;
}
closeModal();
render();
flash(editId);
toast("Class updated.");
} else {
var nc = {
id: id(),
name: v.name,
type: v.type,
trainer: v.trainer,
day: v.day,
time: v.time,
duration: v.duration,
room: v.room,
capacity: v.capacity,
booked: 0,
status: currentStatus,
};
classes.unshift(nc);
// reset filters that would hide the new row
if (filterType.value && filterType.value !== nc.type) filterType.value = "";
if (filterTrainer.value && filterTrainer.value !== nc.trainer)
filterTrainer.value = "";
searchEl.value = "";
syncFilterOptions();
closeModal();
render();
flash(nc.id);
toast("Class added.");
}
});
function flash(rid) {
var tr = tbody.querySelector('tr[data-id="' + rid + '"]');
if (tr) tr.classList.add("flash");
}
/* row actions: edit / delete (inline confirm) */
tbody.addEventListener("click", function (e) {
var btn = e.target.closest("button[data-act]");
if (!btn) return;
var wrap = btn.closest(".row-actions");
var rid = wrap.dataset.id;
var cls = classes.find(function (x) {
return x.id === rid;
});
if (!cls) return;
if (btn.dataset.act === "edit") {
openModal(cls);
return;
}
if (btn.dataset.act === "del") {
showConfirm(wrap, rid, cls.name);
}
});
function showConfirm(wrap, rid, name) {
var original = wrap.innerHTML;
wrap.innerHTML =
'<span class="confirm">Delete?' +
'<button class="btn btn-danger mini" data-c="yes">Yes</button>' +
'<button class="btn btn-ghost mini" data-c="no">No</button>' +
"</span>";
wrap.querySelector('[data-c="no"]').addEventListener("click", function () {
wrap.innerHTML = original;
});
wrap.querySelector('[data-c="yes"]').addEventListener("click", function () {
classes = classes.filter(function (x) {
return x.id !== rid;
});
syncFilterOptions();
render();
toast('"' + name + '" deleted.', "danger");
});
}
/* filters */
searchEl.addEventListener("input", render);
filterType.addEventListener("change", render);
filterTrainer.addEventListener("change", render);
function clearFilters() {
searchEl.value = "";
filterType.value = "";
filterTrainer.value = "";
render();
}
$("#clearFilters").addEventListener("click", clearFilters);
$("#emptyClear").addEventListener("click", clearFilters);
/* ---------------- Init ---------------- */
syncFilterOptions();
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gym — Class 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=Inter:wght@400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<div class="brand-mark" aria-hidden="true">PF</div>
<div class="brand-text">
<span class="eyebrow">Pulse Fitness · Admin</span>
<h1>Class Management</h1>
</div>
</div>
<button id="addBtn" class="btn btn-neon" type="button">
<span class="plus" aria-hidden="true">+</span> Add class
</button>
</header>
<section class="stats" aria-label="Class statistics">
<div class="stat">
<span class="stat-label">Total classes</span>
<strong class="stat-value" id="statTotal">0</strong>
</div>
<div class="stat">
<span class="stat-label">Active</span>
<strong class="stat-value" id="statActive">0</strong>
</div>
<div class="stat">
<span class="stat-label">Weekly capacity</span>
<strong class="stat-value" id="statCapacity">0</strong>
</div>
<div class="stat">
<span class="stat-label">Trainers</span>
<strong class="stat-value" id="statTrainers">0</strong>
</div>
</section>
<section class="toolbar" aria-label="Filters">
<div class="search">
<svg viewBox="0 0 24 24" aria-hidden="true" class="search-icon">
<path
d="M21 21l-4.3-4.3M11 19a8 8 0 110-16 8 8 0 010 16z"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<input
id="search"
type="search"
placeholder="Search classes, trainers, rooms…"
aria-label="Search classes"
autocomplete="off"
/>
</div>
<div class="filters">
<label class="field">
<span class="field-label">Type</span>
<select id="filterType" aria-label="Filter by type">
<option value="">All types</option>
</select>
</label>
<label class="field">
<span class="field-label">Trainer</span>
<select id="filterTrainer" aria-label="Filter by trainer">
<option value="">All trainers</option>
</select>
</label>
<button id="clearFilters" class="btn btn-ghost" type="button">Clear</button>
</div>
</section>
<section class="table-wrap" aria-label="Classes">
<table class="classes">
<thead>
<tr>
<th scope="col">Class</th>
<th scope="col">Trainer</th>
<th scope="col">Schedule</th>
<th scope="col">Room</th>
<th scope="col">Capacity</th>
<th scope="col">Status</th>
<th scope="col" class="col-actions">Actions</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div id="empty" class="empty" hidden>
<p>No classes match your filters.</p>
<button class="btn btn-ghost" type="button" id="emptyClear">Clear filters</button>
</div>
</section>
</div>
<!-- Modal -->
<div class="modal-backdrop" id="backdrop" hidden>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modalTitle"
id="modal"
>
<div class="modal-head">
<h2 id="modalTitle">Add class</h2>
<button class="icon-btn" type="button" id="closeModal" aria-label="Close">×</button>
</div>
<form id="form" novalidate>
<input type="hidden" id="fId" />
<div class="grid">
<label class="field span-2">
<span class="field-label">Class name</span>
<input id="fName" type="text" placeholder="e.g. Sunrise HIIT" />
<span class="err" data-for="fName"></span>
</label>
<label class="field">
<span class="field-label">Type</span>
<select id="fType">
<option value="">Select type</option>
<option>HIIT</option>
<option>Strength</option>
<option>Spin</option>
<option>Yoga</option>
<option>Boxing</option>
<option>Pilates</option>
<option>Mobility</option>
</select>
<span class="err" data-for="fType"></span>
</label>
<label class="field">
<span class="field-label">Trainer</span>
<select id="fTrainer">
<option value="">Select trainer</option>
<option>Mara Velez</option>
<option>Devon Cole</option>
<option>Priya Nandakumar</option>
<option>Theo Brandt</option>
<option>Imani Okafor</option>
<option>Luca Ferreira</option>
</select>
<span class="err" data-for="fTrainer"></span>
</label>
<label class="field">
<span class="field-label">Day</span>
<select id="fDay">
<option value="">Select day</option>
<option>Monday</option>
<option>Tuesday</option>
<option>Wednesday</option>
<option>Thursday</option>
<option>Friday</option>
<option>Saturday</option>
<option>Sunday</option>
</select>
<span class="err" data-for="fDay"></span>
</label>
<label class="field">
<span class="field-label">Start time</span>
<input id="fTime" type="time" />
<span class="err" data-for="fTime"></span>
</label>
<label class="field">
<span class="field-label">Duration (min)</span>
<input id="fDuration" type="number" min="15" max="180" step="5" placeholder="45" />
<span class="err" data-for="fDuration"></span>
</label>
<label class="field">
<span class="field-label">Room</span>
<select id="fRoom">
<option value="">Select room</option>
<option>Studio A</option>
<option>Studio B</option>
<option>Spin Loft</option>
<option>Strength Floor</option>
<option>Mind & Body</option>
</select>
<span class="err" data-for="fRoom"></span>
</label>
<label class="field">
<span class="field-label">Capacity</span>
<input id="fCapacity" type="number" min="1" max="120" placeholder="24" />
<span class="err" data-for="fCapacity"></span>
</label>
<label class="field span-2 switch-field">
<span class="field-label">Status</span>
<div class="switch-row">
<button type="button" class="seg" data-status="active">Active</button>
<button type="button" class="seg" data-status="draft">Draft</button>
<button type="button" class="seg" data-status="cancelled">Cancelled</button>
</div>
</label>
</div>
<div class="modal-foot">
<button type="button" class="btn btn-ghost" id="cancelModal">Cancel</button>
<button type="submit" class="btn btn-neon" id="saveBtn">Save class</button>
</div>
</form>
</div>
</div>
<div class="toast-stack" id="toasts" aria-live="polite" aria-atomic="false"></div>
<script src="script.js"></script>
</body>
</html>Class Management
An athletic, dark-themed admin screen for running a gym’s weekly class schedule. A four-tile stats strip surfaces total classes, active classes, summed weekly capacity and the number of distinct trainers, recomputing every time the roster changes. Below it, a table lists each class with a type-coded avatar, name and type, the assigned trainer, an abbreviated day plus start time and duration, the room, a colour-shifting capacity bar (neon → orange → red as it fills) and a pill status badge for active, draft or cancelled.
Adding a class opens a modal form with name, type, trainer, day, start time, duration, room and capacity fields plus a status segmented control. Inline validation flags any empty or out-of-range field before saving, and a successful save prepends the new row with a brief highlight flash. Editing reuses the same dialog, pre-filled, and writes changes back in place. Deleting swaps the row’s action buttons for an inline Yes / No confirm so nothing is removed by accident.
The search box matches against name, type, trainer, room and day, while the type and trainer selects filter the list live; a Clear control resets everything and an empty state appears when no class matches. Everything runs on a small in-memory store with vanilla JS, an Escape-to-close modal, focus management and toast feedback — no frameworks or build step.
Illustrative UI only — sample classes, trainers and members are fictional.