-
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# Neon Health Tracker
|
||||
|
||||
A dependency-free PHP, HTML5, JavaScript, and SQLite tracker for workouts, nutrition, supplements, and body metrics.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
/Users/tyemeclifford/frankenphp php-server --root public --listen 127.0.0.1:8080
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
## Public Routes
|
||||
|
||||
- `/` - public homepage for the service.
|
||||
- `/workouts.php` - workout planning feature page.
|
||||
- `/nutrition.php` - nutrition tracking feature page.
|
||||
- `/recovery.php` - recovery, supplements, and health monitoring feature page.
|
||||
- `/signup.php` - public profile signup.
|
||||
- `/tracker.php` - the workout, nutrition, supplement, and health dashboard.
|
||||
- `/admin.php` - admin user-management dashboard.
|
||||
|
||||
## Storage
|
||||
|
||||
SQLite is created automatically at:
|
||||
|
||||
```text
|
||||
data/health_tracker.sqlite
|
||||
```
|
||||
|
||||
The app seeds two sample profiles, built-in exercise movements, common gym supplements, starter routines, planner entries, nutrition logs, supplement logs, and body metrics.
|
||||
|
||||
## Included
|
||||
|
||||
- Multi-user profiles with separate routines, logs, goals, and theme preferences.
|
||||
- Admin dashboard for creating, editing, activating/deactivating, and deleting user profiles.
|
||||
- First user created in a database is automatically promoted to `admin`; later users default to `member`.
|
||||
- Daily, weekly, and monthly workout planner views.
|
||||
- Routine planning for weight lifting, cycling, running, swimming, conditioning, mobility, core, outdoor, and hybrid programs.
|
||||
- Configurable exercise and supplement libraries.
|
||||
- Nutrition goals scoped by day, week, or month, compared against actual logs.
|
||||
- Supplement tracking for pre-workout, intra-workout, post-workout, daily, and evening use.
|
||||
- Health metrics for weight, body fat, sleep, resting heart rate, mood, and notes.
|
||||
- Neon dark theme by default, light mode option, and green, blue, red, pink, and orange accents.
|
||||
|
||||
## Photo Credits
|
||||
|
||||
- `Jogging Woman in Grass` by Mike Baird, CC BY 2.0.
|
||||
- `Cycling in Amsterdam (893)` by FaceMePLS, CC BY-SA 2.0.
|
||||
- `A large mixed salad` by Jmabel, CC BY-SA 4.0.
|
||||
+1362
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../app/bootstrap.php';
|
||||
app_db();
|
||||
|
||||
$pageTitle = 'Admin Dashboard | Neon Health Tracker';
|
||||
$activePage = 'admin';
|
||||
require __DIR__ . '/partials/site-header.php';
|
||||
?>
|
||||
<main>
|
||||
<section class="admin-public-shell">
|
||||
<div class="site-section-heading">
|
||||
<p class="site-eyebrow">Admin dashboard</p>
|
||||
<h1>Manage users and their profiles.</h1>
|
||||
<p>Use an active admin profile to create users, update profile details, assign roles, activate or deactivate accounts, and remove users.</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-access-row">
|
||||
<label class="site-field">
|
||||
<span>Acting profile</span>
|
||||
<select id="adminProfileSelect"></select>
|
||||
</label>
|
||||
<a class="site-secondary" href="/tracker.php">Open tracker</a>
|
||||
</div>
|
||||
|
||||
<div class="admin-denied" id="adminDenied" hidden>
|
||||
<h2>Admin access required</h2>
|
||||
<p>Select an active admin profile to manage users. The first user in the database is automatically promoted to admin.</p>
|
||||
</div>
|
||||
|
||||
<section class="admin-panel site-admin-panel" id="adminPanel" hidden>
|
||||
<div class="admin-stats" id="adminStats"></div>
|
||||
<div class="admin-layout">
|
||||
<div class="admin-user-list" id="adminUserList"></div>
|
||||
<form class="admin-editor" id="adminUserForm">
|
||||
<input type="hidden" name="target_user_id">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="site-eyebrow">Profile editor</p>
|
||||
<h2 id="adminUserFormTitle">Create Profile</h2>
|
||||
</div>
|
||||
<button class="text-button" type="button" id="clearAdminUserFormButton">Clear</button>
|
||||
</div>
|
||||
<div class="form-grid two">
|
||||
<label class="field"><span>Name</span><input name="name" required></label>
|
||||
<label class="field"><span>Email</span><input name="email" type="email"></label>
|
||||
<label class="field"><span>Role</span><select name="role"><option value="member">member</option><option value="admin">admin</option></select></label>
|
||||
<label class="field"><span>Status</span><select name="status"><option value="active">active</option><option value="inactive">inactive</option></select></label>
|
||||
<label class="field"><span>Phone</span><input name="phone" type="tel"></label>
|
||||
<label class="field"><span>Birthday</span><input name="birthday" type="date"></label>
|
||||
<label class="field"><span>Height in</span><input name="height_in" type="number" min="0" step="0.5"></label>
|
||||
<label class="field"><span>Training level</span><select name="training_level"><option></option><option>Beginner</option><option>Intermediate</option><option>Advanced</option><option>Competitive</option></select></label>
|
||||
<label class="field"><span>Theme</span><select name="theme_mode"><option>dark</option><option>light</option></select></label>
|
||||
<label class="field"><span>Accent</span><select name="theme_accent"><option>green</option><option>blue</option><option>red</option><option>pink</option><option>orange</option></select></label>
|
||||
<label class="field wide"><span>Fitness focus</span><input name="fitness_focus" placeholder="Strength, endurance, recovery"></label>
|
||||
<label class="field wide"><span>Emergency contact</span><input name="emergency_contact" placeholder="Name and phone"></label>
|
||||
</div>
|
||||
<button class="site-primary full" type="submit">Save profile</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<div class="site-toast" id="siteToast" role="status" aria-live="polite"></div>
|
||||
<script src="/assets/admin.js" defer></script>
|
||||
<?php require __DIR__ . '/partials/site-footer.php'; ?>
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../app/bootstrap.php';
|
||||
|
||||
try {
|
||||
$action = $_GET['action'] ?? 'state';
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
|
||||
if ($method === 'GET' && $action === 'state') {
|
||||
json_response(state_payload($_GET));
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
json_response(['error' => 'Unsupported method.'], 405);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_input();
|
||||
$routes = [
|
||||
'create_user' => 'create_user',
|
||||
'public_signup' => 'public_signup',
|
||||
'update_user' => 'update_user',
|
||||
'delete_user' => 'delete_user',
|
||||
'update_theme' => 'update_theme',
|
||||
'create_exercise' => 'create_exercise',
|
||||
'create_supplement' => 'create_supplement',
|
||||
'create_nutrition_goal' => 'create_nutrition_goal',
|
||||
'create_routine' => 'create_routine',
|
||||
'create_workout' => 'create_workout',
|
||||
'update_workout' => 'update_workout',
|
||||
'create_nutrition_log' => 'create_nutrition_log',
|
||||
'create_supplement_log' => 'create_supplement_log',
|
||||
'create_body_metric' => 'create_body_metric',
|
||||
];
|
||||
|
||||
if (!isset($routes[$action])) {
|
||||
json_response(['error' => 'Unknown API action.'], 404);
|
||||
exit;
|
||||
}
|
||||
|
||||
json_response($routes[$action]($data));
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
json_response(['error' => $exception->getMessage()], 400);
|
||||
} catch (Throwable $exception) {
|
||||
json_response(['error' => 'Server error: ' . $exception->getMessage()], 500);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
const adminState = {
|
||||
data: null,
|
||||
userId: localStorage.getItem("nht:adminUserId") || localStorage.getItem("nht:userId") || "",
|
||||
};
|
||||
|
||||
const admin$ = (selector, root = document) => root.querySelector(selector);
|
||||
|
||||
function adminEscape(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function adminNumber(value, digits = 0) {
|
||||
return Number(value || 0).toLocaleString(undefined, {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
function adminToast(message) {
|
||||
const toast = admin$("#siteToast");
|
||||
if (!toast) return;
|
||||
toast.textContent = message;
|
||||
toast.classList.add("show");
|
||||
window.clearTimeout(adminToast.timer);
|
||||
adminToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2400);
|
||||
}
|
||||
|
||||
async function adminApi(action, payload = null, method = "POST") {
|
||||
const options = { method };
|
||||
if (payload) {
|
||||
options.headers = { "Content-Type": "application/json" };
|
||||
options.body = JSON.stringify(payload);
|
||||
}
|
||||
const response = await fetch(`/api.php?action=${encodeURIComponent(action)}`, options);
|
||||
const json = await response.json();
|
||||
if (!response.ok || json.error) {
|
||||
throw new Error(json.error || "Request failed.");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
async function loadAdminState() {
|
||||
const params = new URLSearchParams({
|
||||
action: "state",
|
||||
view: "weekly",
|
||||
});
|
||||
if (adminState.userId) {
|
||||
params.set("user_id", adminState.userId);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api.php?${params.toString()}`);
|
||||
const json = await response.json();
|
||||
if (!response.ok || json.error) {
|
||||
throw new Error(json.error || "Unable to load admin dashboard.");
|
||||
}
|
||||
|
||||
adminState.data = json;
|
||||
adminState.userId = String(json.currentUser.id);
|
||||
localStorage.setItem("nht:adminUserId", adminState.userId);
|
||||
renderAdminPage();
|
||||
}
|
||||
|
||||
function renderAdminPage() {
|
||||
const data = adminState.data;
|
||||
const profileSelect = admin$("#adminProfileSelect");
|
||||
profileSelect.innerHTML = data.users.map((user) => `
|
||||
<option value="${user.id}" ${String(user.id) === String(data.currentUser.id) ? "selected" : ""}>
|
||||
${adminEscape(user.name)} (${adminEscape(user.role)})
|
||||
</option>
|
||||
`).join("");
|
||||
|
||||
admin$("#adminPanel").hidden = !data.currentUserIsAdmin;
|
||||
admin$("#adminDenied").hidden = data.currentUserIsAdmin;
|
||||
if (!data.currentUserIsAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderAdminStats();
|
||||
renderAdminUsers();
|
||||
}
|
||||
|
||||
function renderAdminStats() {
|
||||
const summary = adminState.data.adminSummary || {};
|
||||
const stats = [
|
||||
["Total", summary.total_users || 0],
|
||||
["Active", summary.active_users || 0],
|
||||
["Inactive", summary.inactive_users || 0],
|
||||
["Admins", summary.admins || 0],
|
||||
["Members", summary.members || 0],
|
||||
];
|
||||
|
||||
admin$("#adminStats").innerHTML = stats.map(([label, value]) => `
|
||||
<article class="admin-stat">
|
||||
<span>${adminEscape(label)}</span>
|
||||
<strong>${adminNumber(value)}</strong>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderAdminUsers() {
|
||||
const currentId = Number(adminState.data.currentUser.id);
|
||||
const rows = adminState.data.adminUsers || [];
|
||||
admin$("#adminUserList").innerHTML = rows.length ? rows.map((user) => {
|
||||
const isCurrent = Number(user.id) === currentId;
|
||||
const nextStatus = user.status === "active" ? "inactive" : "active";
|
||||
const height = Number(user.height_in || 0) > 0 ? ` / ${adminNumber(user.height_in, 1)} in` : "";
|
||||
const training = user.training_level ? ` / ${user.training_level}` : "";
|
||||
return `
|
||||
<article class="admin-user-card">
|
||||
<div class="admin-user-main">
|
||||
<div>
|
||||
<h3>${adminEscape(user.name)}${isCurrent ? " (current)" : ""}</h3>
|
||||
<p class="details-line">${adminEscape(user.email || "No email")}</p>
|
||||
<p class="details-line">${adminEscape(user.fitness_focus || "No focus set")}</p>
|
||||
<p class="timeline-meta">${adminEscape(user.phone || "No phone")}${adminEscape(training)}${adminEscape(height)}</p>
|
||||
</div>
|
||||
<div class="admin-badges">
|
||||
<span class="badge ${adminEscape(user.role)}">${adminEscape(user.role)}</span>
|
||||
<span class="badge ${adminEscape(user.status)}">${adminEscape(user.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-user-metrics">
|
||||
<span>${adminNumber(user.routine_count)} routines</span>
|
||||
<span>${adminNumber(user.workout_count)} workouts</span>
|
||||
<span>${adminNumber(user.nutrition_log_count)} nutrition logs</span>
|
||||
<span>${adminNumber(user.supplement_log_count)} supplement logs</span>
|
||||
<span>${adminNumber(user.metric_count)} metrics</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<button class="status-button" type="button" data-admin-action="edit" data-admin-user-id="${user.id}">Edit</button>
|
||||
<button class="status-button" type="button" data-admin-action="status" data-admin-user-id="${user.id}" data-next-status="${nextStatus}">
|
||||
${nextStatus === "active" ? "Activate" : "Deactivate"}
|
||||
</button>
|
||||
<button class="status-button danger-action" type="button" data-admin-action="delete" data-admin-user-id="${user.id}" ${isCurrent ? "disabled" : ""}>Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join("") : `<div class="admin-denied">No users found.</div>`;
|
||||
}
|
||||
|
||||
function resetAdminForm() {
|
||||
const form = admin$("#adminUserForm");
|
||||
form.reset();
|
||||
form.elements.target_user_id.value = "";
|
||||
form.elements.role.value = "member";
|
||||
form.elements.status.value = "active";
|
||||
form.elements.theme_mode.value = "dark";
|
||||
form.elements.theme_accent.value = "green";
|
||||
admin$("#adminUserFormTitle").textContent = "Create Profile";
|
||||
}
|
||||
|
||||
function fillAdminForm(user) {
|
||||
const form = admin$("#adminUserForm");
|
||||
form.elements.target_user_id.value = user.id || "";
|
||||
form.elements.name.value = user.name || "";
|
||||
form.elements.email.value = user.email || "";
|
||||
form.elements.role.value = user.role || "member";
|
||||
form.elements.status.value = user.status || "active";
|
||||
form.elements.phone.value = user.phone || "";
|
||||
form.elements.birthday.value = user.birthday || "";
|
||||
form.elements.height_in.value = Number(user.height_in || 0) > 0 ? user.height_in : "";
|
||||
form.elements.training_level.value = user.training_level || "";
|
||||
form.elements.theme_mode.value = user.theme_mode || "dark";
|
||||
form.elements.theme_accent.value = user.theme_accent || "green";
|
||||
form.elements.fitness_focus.value = user.fitness_focus || "";
|
||||
form.elements.emergency_contact.value = user.emergency_contact || "";
|
||||
admin$("#adminUserFormTitle").textContent = `Edit ${user.name}`;
|
||||
}
|
||||
|
||||
function adminFormPayload(form) {
|
||||
const payload = Object.fromEntries(new FormData(form).entries());
|
||||
payload.acting_user_id = adminState.data.currentUser.id;
|
||||
return payload;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
admin$("#adminProfileSelect").addEventListener("change", async (event) => {
|
||||
adminState.userId = event.target.value;
|
||||
localStorage.setItem("nht:adminUserId", adminState.userId);
|
||||
await loadAdminState();
|
||||
});
|
||||
|
||||
admin$("#clearAdminUserFormButton").addEventListener("click", resetAdminForm);
|
||||
|
||||
admin$("#adminUserList").addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-admin-action]");
|
||||
if (!button) return;
|
||||
const user = (adminState.data.adminUsers || []).find((item) => String(item.id) === button.dataset.adminUserId);
|
||||
if (!user) return;
|
||||
|
||||
if (button.dataset.adminAction === "edit") {
|
||||
fillAdminForm(user);
|
||||
admin$("#adminUserForm").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (button.dataset.adminAction === "status") {
|
||||
await adminApi("update_user", {
|
||||
acting_user_id: adminState.data.currentUser.id,
|
||||
target_user_id: user.id,
|
||||
status: button.dataset.nextStatus,
|
||||
});
|
||||
adminToast("Profile status updated");
|
||||
}
|
||||
|
||||
if (button.dataset.adminAction === "delete") {
|
||||
const ok = window.confirm(`Delete ${user.name} and all tracker data attached to this profile?`);
|
||||
if (!ok) return;
|
||||
await adminApi("delete_user", {
|
||||
acting_user_id: adminState.data.currentUser.id,
|
||||
target_user_id: user.id,
|
||||
});
|
||||
adminToast("Profile deleted");
|
||||
}
|
||||
|
||||
resetAdminForm();
|
||||
await loadAdminState();
|
||||
} catch (error) {
|
||||
adminToast(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
admin$("#adminUserForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const payload = adminFormPayload(event.currentTarget);
|
||||
const action = payload.target_user_id ? "update_user" : "create_user";
|
||||
try {
|
||||
await adminApi(action, payload);
|
||||
resetAdminForm();
|
||||
await loadAdminState();
|
||||
adminToast(action === "create_user" ? "Profile created" : "Profile updated");
|
||||
} catch (error) {
|
||||
adminToast(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await loadAdminState();
|
||||
} catch (error) {
|
||||
adminToast(error.message);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,678 @@
|
||||
const appState = {
|
||||
data: null,
|
||||
userId: localStorage.getItem("nht:userId") || "",
|
||||
view: localStorage.getItem("nht:view") || "weekly",
|
||||
date: localStorage.getItem("nht:date") || new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
|
||||
const accentChoices = ["green", "blue", "red", "pink", "orange"];
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function number(value, digits = 0) {
|
||||
const numeric = Number(value || 0);
|
||||
return numeric.toLocaleString(undefined, {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
function shortDate(value) {
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function longDate(value) {
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function addDays(dateString, days) {
|
||||
const date = new Date(`${dateString}T00:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dateRange(start, end) {
|
||||
const days = [];
|
||||
let cursor = start;
|
||||
while (cursor <= end) {
|
||||
days.push(cursor);
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = $("#toast");
|
||||
toast.textContent = message;
|
||||
toast.classList.add("show");
|
||||
window.clearTimeout(showToast.timer);
|
||||
showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2400);
|
||||
}
|
||||
|
||||
async function api(action, payload = null, method = "POST") {
|
||||
const options = { method };
|
||||
if (payload) {
|
||||
options.headers = { "Content-Type": "application/json" };
|
||||
options.body = JSON.stringify(payload);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api.php?action=${encodeURIComponent(action)}`, options);
|
||||
const json = await response.json();
|
||||
if (!response.ok || json.error) {
|
||||
throw new Error(json.error || "Request failed.");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
const params = new URLSearchParams({
|
||||
action: "state",
|
||||
view: appState.view,
|
||||
date: appState.date,
|
||||
});
|
||||
|
||||
if (appState.userId) {
|
||||
params.set("user_id", appState.userId);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api.php?${params.toString()}`);
|
||||
const json = await response.json();
|
||||
if (!response.ok || json.error) {
|
||||
throw new Error(json.error || "Unable to load tracker state.");
|
||||
}
|
||||
|
||||
appState.data = json;
|
||||
appState.userId = String(json.currentUser.id);
|
||||
appState.view = json.view;
|
||||
appState.date = json.range.anchor;
|
||||
localStorage.setItem("nht:userId", appState.userId);
|
||||
localStorage.setItem("nht:view", appState.view);
|
||||
localStorage.setItem("nht:date", appState.date);
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!appState.data) return;
|
||||
|
||||
applyTheme();
|
||||
renderControls();
|
||||
renderSummaries();
|
||||
renderTimeline();
|
||||
renderNutrition();
|
||||
renderRoutines();
|
||||
renderSupplements();
|
||||
renderAdmin();
|
||||
renderLibraries();
|
||||
populateForms();
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
const user = appState.data.currentUser;
|
||||
const mode = user.theme_mode || "dark";
|
||||
const accent = accentChoices.includes(user.theme_accent) ? user.theme_accent : "green";
|
||||
|
||||
document.body.dataset.theme = mode;
|
||||
document.body.dataset.accent = accent;
|
||||
|
||||
$("#darkModeButton").classList.toggle("active", mode === "dark");
|
||||
$("#lightModeButton").classList.toggle("active", mode === "light");
|
||||
$$("[data-accent]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.accent === accent);
|
||||
});
|
||||
}
|
||||
|
||||
function renderControls() {
|
||||
const data = appState.data;
|
||||
const user = data.currentUser;
|
||||
$("#profileFocus").textContent = user.fitness_focus || "Multi-profile training dashboard";
|
||||
if (data.currentUserIsAdmin) {
|
||||
$("#profileFocus").textContent += " / admin";
|
||||
}
|
||||
$("#rangeCopy").textContent = `${data.view[0].toUpperCase()}${data.view.slice(1)} view: ${longDate(data.range.start)} to ${longDate(data.range.end)}`;
|
||||
$("#datePicker").value = data.range.anchor;
|
||||
|
||||
$("#profileSelect").innerHTML = data.users
|
||||
.map((profile) => `<option value="${profile.id}" ${profile.id === user.id ? "selected" : ""}>${escapeHtml(profile.name)} (${escapeHtml(profile.role)})</option>`)
|
||||
.join("");
|
||||
|
||||
$$("[data-view]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.view === data.view);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummaries() {
|
||||
const { workoutSummary, nutritionSummary, latestMetric, supplementLogs } = appState.data;
|
||||
const actual = nutritionSummary.actual;
|
||||
const target = nutritionSummary.target;
|
||||
const supplementTaken = supplementLogs.filter((item) => item.status === "taken").length;
|
||||
|
||||
const cards = [
|
||||
["Completion", `${number(workoutSummary.completion_rate, 1)}%`, `${workoutSummary.completed}/${workoutSummary.planned} workouts`],
|
||||
["Volume", number(workoutSummary.weight_volume_lbs), "lb lifted"],
|
||||
["Cardio", `${number(workoutSummary.minutes, 0)} min`, `${number(workoutSummary.distance_mi, 2)} mi`],
|
||||
["Calories", number(actual.calories), `${number(target.calories)} target`],
|
||||
["Protein", `${number(actual.protein_g)} g`, `${number(target.protein_g)} g target`],
|
||||
["Weight", latestMetric ? `${number(latestMetric.weight_lbs, 1)} lb` : "No log", latestMetric ? `${number(latestMetric.sleep_hours, 1)} hr sleep` : `${supplementTaken} doses taken`],
|
||||
];
|
||||
|
||||
$("#summaryGrid").innerHTML = cards
|
||||
.map(([label, value, detail]) => `
|
||||
<article class="summary-card">
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<strong>${escapeHtml(value)}</strong>
|
||||
<small>${escapeHtml(detail)}</small>
|
||||
</article>
|
||||
`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderTimeline() {
|
||||
const { range, workouts } = appState.data;
|
||||
const byDate = new Map();
|
||||
workouts.forEach((workout) => {
|
||||
if (!byDate.has(workout.scheduled_on)) byDate.set(workout.scheduled_on, []);
|
||||
byDate.get(workout.scheduled_on).push(workout);
|
||||
});
|
||||
|
||||
const html = dateRange(range.start, range.end).map((date) => {
|
||||
const entries = byDate.get(date) || [];
|
||||
const volume = entries.reduce((sum, workout) => sum + Number(workout.sets || 0) * Number(workout.reps || 0) * Number(workout.weight_lbs || 0), 0);
|
||||
const minutes = entries.reduce((sum, workout) => sum + Number(workout.duration_min || 0), 0);
|
||||
return `
|
||||
<section class="timeline-day">
|
||||
<div class="timeline-date">
|
||||
<strong>${longDate(date)}</strong>
|
||||
<span class="timeline-meta">${entries.length} workouts / ${number(minutes)} min / ${number(volume)} lb</span>
|
||||
</div>
|
||||
<div class="workout-list">
|
||||
${entries.length ? entries.map(renderWorkoutItem).join("") : `<div class="empty-state">No workouts scheduled</div>`}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
$("#workoutTimeline").innerHTML = html || `<div class="empty-state">No planner days found</div>`;
|
||||
}
|
||||
|
||||
function renderWorkoutItem(workout) {
|
||||
const liftLine = Number(workout.weight_lbs) > 0
|
||||
? `${number(workout.sets)} x ${number(workout.reps)} @ ${number(workout.weight_lbs, 1)} lb`
|
||||
: "";
|
||||
const cardioLine = Number(workout.duration_min) || Number(workout.distance_mi)
|
||||
? `${number(workout.duration_min)} min${Number(workout.distance_mi) ? ` / ${number(workout.distance_mi, 2)} mi` : ""}`
|
||||
: "";
|
||||
const details = [workout.workout_type, workout.exercise_name, liftLine, cardioLine, workout.intensity]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
|
||||
return `
|
||||
<article class="workout-item">
|
||||
<div class="workout-top">
|
||||
<div>
|
||||
<h3>${escapeHtml(workout.title)}</h3>
|
||||
<p class="details-line">${escapeHtml(details || "Open workout")}</p>
|
||||
${workout.routine_name ? `<p class="timeline-meta">${escapeHtml(workout.routine_name)}</p>` : ""}
|
||||
</div>
|
||||
<span class="badge ${escapeHtml(workout.status)}">${escapeHtml(workout.status)}</span>
|
||||
</div>
|
||||
${workout.notes ? `<p class="muted">${escapeHtml(workout.notes)}</p>` : ""}
|
||||
<div class="status-row">
|
||||
${["planned", "complete", "skipped"].map((status) => `
|
||||
<button class="status-button" type="button" data-workout-id="${workout.id}" data-status="${status}">
|
||||
${status}
|
||||
</button>
|
||||
`).join("")}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderNutrition() {
|
||||
const { nutritionSummary, nutritionLogs } = appState.data;
|
||||
const target = nutritionSummary.target;
|
||||
const actual = nutritionSummary.actual;
|
||||
const macros = [
|
||||
["Calories", "calories", "kcal"],
|
||||
["Protein", "protein_g", "g"],
|
||||
["Carbs", "carbs_g", "g"],
|
||||
["Fat", "fat_g", "g"],
|
||||
["Water", "water_ml", "ml"],
|
||||
];
|
||||
|
||||
const macroHtml = macros.map(([label, key, unit]) => {
|
||||
const targetValue = Number(target[key] || 0);
|
||||
const actualValue = Number(actual[key] || 0);
|
||||
const percent = targetValue > 0 ? Math.min((actualValue / targetValue) * 100, 140) : 0;
|
||||
return `
|
||||
<div class="macro-row">
|
||||
<div class="macro-copy">
|
||||
<span>${label}</span>
|
||||
<span>${number(actualValue)} / ${number(targetValue)} ${unit}</span>
|
||||
</div>
|
||||
<div class="bar" style="--value: ${percent}%"><span></span></div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
const logHtml = nutritionLogs.length
|
||||
? nutritionLogs.map((log) => `
|
||||
<article class="log-item">
|
||||
<div class="log-top">
|
||||
<strong>${shortDate(log.logged_on)}</strong>
|
||||
<span class="badge">${number(log.calories)} kcal</span>
|
||||
</div>
|
||||
<p>${number(log.protein_g)}g protein / ${number(log.carbs_g)}g carbs / ${number(log.fat_g)}g fat / ${number(log.water_ml)}ml water</p>
|
||||
${log.notes ? `<p class="muted">${escapeHtml(log.notes)}</p>` : ""}
|
||||
</article>
|
||||
`).join("")
|
||||
: `<div class="empty-state">No nutrition logs in this range</div>`;
|
||||
|
||||
$("#nutritionCompare").innerHTML = `
|
||||
<div class="macro-list">${macroHtml}</div>
|
||||
<div class="panel-heading" style="margin-top: 16px;">
|
||||
<div>
|
||||
<p class="eyebrow">Selected Target</p>
|
||||
<h3>${escapeHtml(target.label)} (${escapeHtml(target.scope)})</h3>
|
||||
</div>
|
||||
<span class="badge">${escapeHtml(target.scaled_from)} basis</span>
|
||||
</div>
|
||||
<div class="nutrition-list">${logHtml}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRoutines() {
|
||||
const routines = appState.data.routines;
|
||||
$("#routineList").innerHTML = routines.length
|
||||
? routines.map((routine) => `
|
||||
<article class="routine-item">
|
||||
<div class="routine-top">
|
||||
<div>
|
||||
<h3>${escapeHtml(routine.name)}</h3>
|
||||
<p class="routine-meta">${escapeHtml(routine.workout_type)} / ${escapeHtml(routine.planner_scope)} / ${escapeHtml(routine.intensity || "open intensity")}</p>
|
||||
</div>
|
||||
<span class="badge">${routine.active ? "active" : "paused"}</span>
|
||||
</div>
|
||||
<p class="details-line">Nutrition: ${escapeHtml(routine.nutrition_goal_label || "separate target")}</p>
|
||||
${routine.notes ? `<p class="muted">${escapeHtml(routine.notes)}</p>` : ""}
|
||||
</article>
|
||||
`).join("")
|
||||
: `<div class="empty-state">No routines yet</div>`;
|
||||
}
|
||||
|
||||
function renderSupplements() {
|
||||
const logs = appState.data.supplementLogs;
|
||||
$("#supplementLogList").innerHTML = logs.length
|
||||
? logs.map((log) => `
|
||||
<article class="supplement-item">
|
||||
<div class="supplement-top">
|
||||
<div>
|
||||
<h3>${escapeHtml(log.supplement_name || "Custom supplement")}</h3>
|
||||
<p class="details-line">${shortDate(log.taken_on)} / ${escapeHtml(log.timing)} / ${escapeHtml(log.dose || "open dose")}</p>
|
||||
</div>
|
||||
<span class="badge ${escapeHtml(log.status)}">${escapeHtml(log.status)}</span>
|
||||
</div>
|
||||
${log.supplement_purpose ? `<p>${escapeHtml(log.supplement_purpose)}</p>` : ""}
|
||||
${log.notes ? `<p class="muted">${escapeHtml(log.notes)}</p>` : ""}
|
||||
</article>
|
||||
`).join("")
|
||||
: `<div class="empty-state">No supplement logs in this range</div>`;
|
||||
}
|
||||
|
||||
function renderAdmin() {
|
||||
const panel = $("#adminPanel");
|
||||
if (!panel) return;
|
||||
|
||||
const isAdminUser = Boolean(appState.data.currentUserIsAdmin);
|
||||
panel.hidden = !isAdminUser;
|
||||
if (!isAdminUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = appState.data.adminSummary || {};
|
||||
const statCards = [
|
||||
["Total", summary.total_users || 0],
|
||||
["Active", summary.active_users || 0],
|
||||
["Inactive", summary.inactive_users || 0],
|
||||
["Admins", summary.admins || 0],
|
||||
["Members", summary.members || 0],
|
||||
];
|
||||
$("#adminStats").innerHTML = statCards.map(([label, value]) => `
|
||||
<article class="admin-stat">
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<strong>${number(value)}</strong>
|
||||
</article>
|
||||
`).join("");
|
||||
|
||||
const currentId = Number(appState.data.currentUser.id);
|
||||
const rows = appState.data.adminUsers || [];
|
||||
$("#adminUserList").innerHTML = rows.length
|
||||
? rows.map((user) => {
|
||||
const isCurrent = Number(user.id) === currentId;
|
||||
const toggleStatus = user.status === "active" ? "inactive" : "active";
|
||||
const training = user.training_level ? ` / ${user.training_level}` : "";
|
||||
const height = Number(user.height_in) > 0 ? ` / ${number(user.height_in, 1)} in` : "";
|
||||
return `
|
||||
<article class="admin-user-card" data-admin-user-card="${user.id}">
|
||||
<div class="admin-user-main">
|
||||
<div>
|
||||
<h3>${escapeHtml(user.name)}${isCurrent ? " (current)" : ""}</h3>
|
||||
<p class="details-line">${escapeHtml(user.email || "No email")}</p>
|
||||
<p class="details-line">${escapeHtml(user.fitness_focus || "No focus set")}</p>
|
||||
<p class="timeline-meta">${escapeHtml(user.phone || "No phone")}${escapeHtml(training)}${escapeHtml(height)}</p>
|
||||
</div>
|
||||
<div class="admin-badges">
|
||||
<span class="badge ${escapeHtml(user.role)}">${escapeHtml(user.role)}</span>
|
||||
<span class="badge ${escapeHtml(user.status)}">${escapeHtml(user.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-user-metrics">
|
||||
<span>${number(user.routine_count)} routines</span>
|
||||
<span>${number(user.workout_count)} workouts</span>
|
||||
<span>${number(user.nutrition_log_count)} nutrition logs</span>
|
||||
<span>${number(user.supplement_log_count)} supplement logs</span>
|
||||
<span>${number(user.metric_count)} metrics</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<button class="status-button" type="button" data-admin-action="edit" data-admin-user-id="${user.id}">Edit</button>
|
||||
<button class="status-button" type="button" data-admin-action="status" data-admin-user-id="${user.id}" data-next-status="${toggleStatus}">
|
||||
${toggleStatus === "active" ? "Activate" : "Deactivate"}
|
||||
</button>
|
||||
<button class="status-button danger-action" type="button" data-admin-action="delete" data-admin-user-id="${user.id}" ${isCurrent ? "disabled" : ""}>Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join("")
|
||||
: `<div class="empty-state">No profiles found</div>`;
|
||||
}
|
||||
|
||||
function resetAdminUserForm() {
|
||||
const form = $("#adminUserForm");
|
||||
if (!form) return;
|
||||
form.reset();
|
||||
form.elements.target_user_id.value = "";
|
||||
form.elements.role.value = "member";
|
||||
form.elements.status.value = "active";
|
||||
form.elements.theme_mode.value = "dark";
|
||||
form.elements.theme_accent.value = "green";
|
||||
$("#adminUserFormTitle").textContent = "Create Profile";
|
||||
}
|
||||
|
||||
function fillAdminUserForm(user) {
|
||||
const form = $("#adminUserForm");
|
||||
if (!form || !user) return;
|
||||
|
||||
form.elements.target_user_id.value = user.id || "";
|
||||
form.elements.name.value = user.name || "";
|
||||
form.elements.email.value = user.email || "";
|
||||
form.elements.role.value = user.role || "member";
|
||||
form.elements.status.value = user.status || "active";
|
||||
form.elements.phone.value = user.phone || "";
|
||||
form.elements.birthday.value = user.birthday || "";
|
||||
form.elements.height_in.value = Number(user.height_in || 0) > 0 ? user.height_in : "";
|
||||
form.elements.training_level.value = user.training_level || "";
|
||||
form.elements.theme_mode.value = user.theme_mode || "dark";
|
||||
form.elements.theme_accent.value = user.theme_accent || "green";
|
||||
form.elements.fitness_focus.value = user.fitness_focus || "";
|
||||
form.elements.emergency_contact.value = user.emergency_contact || "";
|
||||
$("#adminUserFormTitle").textContent = `Edit ${user.name}`;
|
||||
}
|
||||
|
||||
function renderLibraries() {
|
||||
$("#exerciseLibrary").innerHTML = appState.data.exercises.map((exercise) => `
|
||||
<span class="chip">
|
||||
${escapeHtml(exercise.name)}
|
||||
<small>${escapeHtml(exercise.category)} / ${escapeHtml(exercise.default_unit || "custom")}</small>
|
||||
</span>
|
||||
`).join("");
|
||||
|
||||
$("#supplementLibrary").innerHTML = appState.data.supplements.map((supplement) => `
|
||||
<span class="chip">
|
||||
${escapeHtml(supplement.name)}
|
||||
<small>${escapeHtml(supplement.timing)} / ${escapeHtml(supplement.default_dose || "custom")}</small>
|
||||
</span>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function option(value, label, selected = false) {
|
||||
return `<option value="${escapeHtml(value)}" ${selected ? "selected" : ""}>${escapeHtml(label)}</option>`;
|
||||
}
|
||||
|
||||
function populateForms() {
|
||||
const { routines, exercises, supplements, nutritionGoals, range } = appState.data;
|
||||
const routineOptions = option("", "No routine") + routines.map((routine) => option(routine.id, `${routine.name} (${routine.planner_scope})`)).join("");
|
||||
const goalOptions = option("", "Separate target") + nutritionGoals.map((goal) => option(goal.id, `${goal.label} (${goal.scope})`)).join("");
|
||||
const exerciseOptions = option("", "Choose exercise") + exercises.map((exercise) => option(exercise.id, `${exercise.name} (${exercise.category})`)).join("");
|
||||
const supplementOptions = option("", "Choose supplement") + supplements.map((supplement) => option(supplement.id, `${supplement.name} (${supplement.timing})`)).join("");
|
||||
|
||||
$$("#workoutForm [name='routine_id']").forEach((select) => select.innerHTML = routineOptions);
|
||||
$$("#routineForm [name='nutrition_goal_id']").forEach((select) => select.innerHTML = goalOptions);
|
||||
$$("#workoutForm [name='exercise_id']").forEach((select) => select.innerHTML = exerciseOptions);
|
||||
$$("#supplementLogForm [name='supplement_id']").forEach((select) => select.innerHTML = supplementOptions);
|
||||
|
||||
const dateFields = [
|
||||
"#workoutForm [name='scheduled_on']",
|
||||
"#routineForm [name='start_date']",
|
||||
"#nutritionGoalForm [name='start_date']",
|
||||
"#nutritionLogForm [name='logged_on']",
|
||||
"#supplementLogForm [name='taken_on']",
|
||||
"#metricForm [name='measured_on']",
|
||||
];
|
||||
dateFields.forEach((selector) => {
|
||||
const input = $(selector);
|
||||
if (input && !input.value) input.value = range.anchor;
|
||||
});
|
||||
}
|
||||
|
||||
function formPayload(form) {
|
||||
const payload = Object.fromEntries(new FormData(form).entries());
|
||||
payload.user_id = appState.data.currentUser.id;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function adminUserPayload(form) {
|
||||
const payload = Object.fromEntries(new FormData(form).entries());
|
||||
payload.acting_user_id = appState.data.currentUser.id;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function wireForm(selector, action, message, afterSave = null) {
|
||||
const form = $(selector);
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const result = await api(action, formPayload(form));
|
||||
if (afterSave) afterSave(result);
|
||||
form.reset();
|
||||
await loadState();
|
||||
showToast(message);
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireControls() {
|
||||
$("#profileSelect").addEventListener("change", async (event) => {
|
||||
appState.userId = event.target.value;
|
||||
localStorage.setItem("nht:userId", appState.userId);
|
||||
await loadState();
|
||||
});
|
||||
|
||||
$("#datePicker").addEventListener("change", async (event) => {
|
||||
appState.date = event.target.value;
|
||||
localStorage.setItem("nht:date", appState.date);
|
||||
await loadState();
|
||||
});
|
||||
|
||||
$$("[data-view]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
appState.view = button.dataset.view;
|
||||
localStorage.setItem("nht:view", appState.view);
|
||||
await loadState();
|
||||
});
|
||||
});
|
||||
|
||||
$("#darkModeButton").addEventListener("click", () => saveTheme("dark"));
|
||||
$("#lightModeButton").addEventListener("click", () => saveTheme("light"));
|
||||
|
||||
$$("[data-accent]").forEach((button) => {
|
||||
button.addEventListener("click", () => saveTheme(null, button.dataset.accent));
|
||||
});
|
||||
|
||||
$("#workoutTimeline").addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-workout-id]");
|
||||
if (!button) return;
|
||||
try {
|
||||
await api("update_workout", {
|
||||
id: button.dataset.workoutId,
|
||||
status: button.dataset.status,
|
||||
});
|
||||
await loadState();
|
||||
showToast("Workout updated");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const jump = event.target.closest("[data-jump]");
|
||||
if (!jump) return;
|
||||
const target = $(jump.dataset.jump);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
});
|
||||
|
||||
$("#workoutForm [name='exercise_id']").addEventListener("change", (event) => {
|
||||
const exercise = appState.data.exercises.find((item) => String(item.id) === event.target.value);
|
||||
const title = $("#workoutForm [name='title']");
|
||||
const type = $("#workoutForm [name='workout_type']");
|
||||
if (exercise && !title.value) title.value = exercise.name;
|
||||
if (exercise) type.value = exercise.category;
|
||||
});
|
||||
|
||||
$("#supplementLogForm [name='supplement_id']").addEventListener("change", (event) => {
|
||||
const supplement = appState.data.supplements.find((item) => String(item.id) === event.target.value);
|
||||
if (!supplement) return;
|
||||
$("#supplementLogForm [name='timing']").value = supplement.timing;
|
||||
$("#supplementLogForm [name='dose']").value = supplement.default_dose;
|
||||
});
|
||||
|
||||
$("#newAdminUserButton").addEventListener("click", () => {
|
||||
resetAdminUserForm();
|
||||
$("#adminUserForm").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
|
||||
$("#clearAdminUserFormButton").addEventListener("click", resetAdminUserForm);
|
||||
|
||||
$("#adminUserList").addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-admin-action]");
|
||||
if (!button) return;
|
||||
|
||||
const user = (appState.data.adminUsers || []).find((item) => String(item.id) === button.dataset.adminUserId);
|
||||
if (!user) return;
|
||||
|
||||
if (button.dataset.adminAction === "edit") {
|
||||
fillAdminUserForm(user);
|
||||
$("#adminUserForm").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.dataset.adminAction === "status") {
|
||||
try {
|
||||
await api("update_user", {
|
||||
acting_user_id: appState.data.currentUser.id,
|
||||
target_user_id: user.id,
|
||||
status: button.dataset.nextStatus,
|
||||
});
|
||||
await loadState();
|
||||
showToast("Profile status updated");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.dataset.adminAction === "delete") {
|
||||
const ok = window.confirm(`Delete ${user.name} and all of this profile's tracker data?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api("delete_user", {
|
||||
acting_user_id: appState.data.currentUser.id,
|
||||
target_user_id: user.id,
|
||||
});
|
||||
await loadState();
|
||||
showToast("Profile deleted");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function saveTheme(mode = null, accent = null) {
|
||||
const current = appState.data.currentUser;
|
||||
try {
|
||||
await api("update_theme", {
|
||||
user_id: current.id,
|
||||
theme_mode: mode || current.theme_mode || "dark",
|
||||
theme_accent: accent || current.theme_accent || "green",
|
||||
});
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function wireForms() {
|
||||
wireForm("#workoutForm", "create_workout", "Workout saved");
|
||||
wireForm("#routineForm", "create_routine", "Routine saved");
|
||||
wireForm("#nutritionGoalForm", "create_nutrition_goal", "Nutrition target saved");
|
||||
wireForm("#nutritionLogForm", "create_nutrition_log", "Nutrition logged");
|
||||
wireForm("#supplementLogForm", "create_supplement_log", "Supplement logged");
|
||||
wireForm("#metricForm", "create_body_metric", "Metrics logged");
|
||||
wireForm("#exerciseForm", "create_exercise", "Exercise added");
|
||||
wireForm("#supplementForm", "create_supplement", "Supplement added");
|
||||
|
||||
$("#adminUserForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const payload = adminUserPayload(form);
|
||||
const action = payload.target_user_id ? "update_user" : "create_user";
|
||||
try {
|
||||
await api(action, payload);
|
||||
resetAdminUserForm();
|
||||
await loadState();
|
||||
showToast(action === "create_user" ? "Profile created" : "Profile updated");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
wireControls();
|
||||
wireForms();
|
||||
try {
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,736 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #08090d;
|
||||
--bg-2: #11131a;
|
||||
--panel: rgba(18, 21, 29, 0.92);
|
||||
--panel-2: rgba(24, 29, 38, 0.94);
|
||||
--line: rgba(255, 255, 255, 0.13);
|
||||
--line-strong: rgba(255, 255, 255, 0.23);
|
||||
--text: #f5f7fb;
|
||||
--muted: #98a1b3;
|
||||
--muted-2: #c0c7d5;
|
||||
--accent: #2cf296;
|
||||
--accent-2: #2cb5ff;
|
||||
--danger: #ff4d68;
|
||||
--warning: #ffb547;
|
||||
--shadow: 0 20px 60px rgba(0, 0, 0, 0.34);
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px) 0 0 / 44px 44px,
|
||||
linear-gradient(0deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px) 0 0 / 44px 44px,
|
||||
linear-gradient(135deg, var(--bg), var(--bg-2));
|
||||
font-family: var(--font);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.2rem, 7vw, 5.8rem);
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.35rem, 3vw, 2.5rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
z-index: 20;
|
||||
width: min(1440px, calc(100% - 32px));
|
||||
margin: 14px auto 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--panel), transparent 4%);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.site-logo,
|
||||
.site-nav,
|
||||
.site-actions,
|
||||
.admin-access-row,
|
||||
.admin-user-main,
|
||||
.admin-badges,
|
||||
.status-row,
|
||||
.footer-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-logo {
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.site-logo-bars {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 3px;
|
||||
padding: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent), transparent 40%);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--accent), transparent 62%);
|
||||
}
|
||||
|
||||
.site-logo-bars i {
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent);
|
||||
}
|
||||
|
||||
.site-logo-bars i:nth-child(1) {
|
||||
height: 50%;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.site-logo-bars i:nth-child(2) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.site-logo-bars i:nth-child(3) {
|
||||
height: 70%;
|
||||
align-self: end;
|
||||
background: var(--accent-2);
|
||||
box-shadow: 0 0 12px var(--accent-2);
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.site-nav a,
|
||||
.site-secondary,
|
||||
.text-button,
|
||||
.status-button {
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
padding: 8px 11px;
|
||||
text-decoration: none;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.site-nav a.active,
|
||||
.site-nav a:hover,
|
||||
.site-secondary:hover,
|
||||
.text-button:hover,
|
||||
.status-button:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.site-primary {
|
||||
min-height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #06100b;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 22px color-mix(in srgb, var(--accent), transparent 55%);
|
||||
font-weight: 800;
|
||||
padding: 10px 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-primary.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.site-hero {
|
||||
width: min(1440px, calc(100% - 32px));
|
||||
min-height: min(760px, calc(100vh - 44px));
|
||||
margin: 12px auto 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.site-hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, rgba(8, 9, 13, 0.92), rgba(8, 9, 13, 0.35) 55%, rgba(8, 9, 13, 0.2));
|
||||
}
|
||||
|
||||
.site-hero img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.site-hero-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(780px, 100%);
|
||||
padding: clamp(24px, 6vw, 72px);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.site-lede,
|
||||
.site-hero-copy p:not(.site-eyebrow),
|
||||
.detail-hero p,
|
||||
.signup-copy p,
|
||||
.site-split p,
|
||||
.site-section-heading p {
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
.site-eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.site-actions {
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.photo-credit {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 10px;
|
||||
z-index: 1;
|
||||
max-width: min(460px, calc(100% - 28px));
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.photo-credit a,
|
||||
figcaption a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.site-band,
|
||||
.site-split,
|
||||
.cta-strip,
|
||||
.detail-hero,
|
||||
.detail-grid,
|
||||
.signup-layout,
|
||||
.admin-public-shell,
|
||||
.site-footer {
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 14px auto 0;
|
||||
}
|
||||
|
||||
.site-band,
|
||||
.site-split,
|
||||
.cta-strip,
|
||||
.signup-layout,
|
||||
.admin-public-shell,
|
||||
.site-footer {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
padding: clamp(18px, 4vw, 36px);
|
||||
}
|
||||
|
||||
.site-section-heading {
|
||||
max-width: 780px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.feature-grid,
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feature-grid article,
|
||||
.detail-grid article,
|
||||
.site-metric-stack article,
|
||||
.signup-steps article,
|
||||
.admin-denied,
|
||||
.admin-stat,
|
||||
.admin-user-card,
|
||||
.admin-editor {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--panel-2), transparent 4%);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.feature-grid article {
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
.feature-grid span {
|
||||
display: inline-flex;
|
||||
color: var(--accent);
|
||||
margin-bottom: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.feature-grid p,
|
||||
.detail-grid p,
|
||||
.site-metric-stack span,
|
||||
.signup-steps span,
|
||||
.details-line,
|
||||
.timeline-meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.site-split,
|
||||
.detail-hero,
|
||||
.signup-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.8fr);
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-split > div:first-child,
|
||||
.detail-hero > div,
|
||||
.signup-copy {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.site-metric-stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.site-metric-stack article {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cta-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-hero figure {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.detail-hero img {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.35;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.signup-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--panel-2), transparent 2%);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.signup-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.signup-steps {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.signup-steps article {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.signup-steps strong {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: #06100b;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.signup-result {
|
||||
min-height: 24px;
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
.site-field,
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.site-field span,
|
||||
.field span {
|
||||
color: var(--muted-2);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--panel-2), transparent 8%);
|
||||
padding: 9px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent), transparent 78%);
|
||||
}
|
||||
|
||||
.admin-access-row {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.admin-access-row .site-field {
|
||||
width: min(360px, 100%);
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-stat span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-stat strong {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-user-list {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
max-height: 680px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.admin-user-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-user-main {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-badges,
|
||||
.status-row {
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-badges {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-user-metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.admin-user-metrics span,
|
||||
.badge {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--muted-2);
|
||||
padding: 4px 8px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.badge.active,
|
||||
.badge.admin {
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent), transparent 50%);
|
||||
}
|
||||
|
||||
.badge.inactive {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger), transparent 50%);
|
||||
}
|
||||
|
||||
.badge.member {
|
||||
color: var(--warning);
|
||||
border-color: color-mix(in srgb, var(--warning), transparent 50%);
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-grid.two {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.field.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.danger-action {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger), transparent 55%);
|
||||
}
|
||||
|
||||
.status-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.site-toast {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 30;
|
||||
max-width: min(360px, calc(100vw - 36px));
|
||||
padding: 12px 14px;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateY(16px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.site-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.site-header,
|
||||
.site-footer,
|
||||
.cta-strip {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.feature-grid,
|
||||
.detail-grid,
|
||||
.admin-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.site-split,
|
||||
.detail-hero,
|
||||
.signup-layout,
|
||||
.admin-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-hero.reverse figure {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.site-header,
|
||||
.site-hero,
|
||||
.site-band,
|
||||
.site-split,
|
||||
.cta-strip,
|
||||
.detail-hero,
|
||||
.detail-grid,
|
||||
.signup-layout,
|
||||
.admin-public-shell,
|
||||
.site-footer {
|
||||
width: min(100% - 20px, 620px);
|
||||
}
|
||||
|
||||
.site-hero {
|
||||
min-height: 720px;
|
||||
}
|
||||
|
||||
.site-hero-copy {
|
||||
padding: 22px;
|
||||
padding-bottom: 74px;
|
||||
}
|
||||
|
||||
.feature-grid,
|
||||
.detail-grid,
|
||||
.admin-stats,
|
||||
.signup-row,
|
||||
.form-grid.two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-access-row,
|
||||
.admin-user-main,
|
||||
.panel-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-badges {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.detail-hero img {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const siteForm = document.querySelector("#publicSignupForm");
|
||||
const siteResult = document.querySelector("#signupResult");
|
||||
|
||||
function showSignupMessage(message) {
|
||||
if (siteResult) {
|
||||
siteResult.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
async function siteApi(action, payload) {
|
||||
const response = await fetch(`/api.php?action=${encodeURIComponent(action)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await response.json();
|
||||
if (!response.ok || json.error) {
|
||||
throw new Error(json.error || "Request failed.");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
if (siteForm) {
|
||||
siteForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const payload = Object.fromEntries(new FormData(siteForm).entries());
|
||||
showSignupMessage("Creating profile...");
|
||||
try {
|
||||
const result = await siteApi("public_signup", payload);
|
||||
if (result.user?.id) {
|
||||
localStorage.setItem("nht:userId", String(result.user.id));
|
||||
}
|
||||
siteForm.reset();
|
||||
const adminCopy = result.first_admin ? " You are the first user, so this profile is an admin." : "";
|
||||
showSignupMessage(`Profile created.${adminCopy} Opening tracker...`);
|
||||
window.setTimeout(() => {
|
||||
window.location.href = "/tracker.php";
|
||||
}, 900);
|
||||
} catch (error) {
|
||||
showSignupMessage(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #08090d;
|
||||
--bg-2: #11131a;
|
||||
--panel: rgba(18, 21, 29, 0.92);
|
||||
--panel-2: rgba(24, 29, 38, 0.94);
|
||||
--line: rgba(255, 255, 255, 0.11);
|
||||
--line-strong: rgba(255, 255, 255, 0.2);
|
||||
--text: #f5f7fb;
|
||||
--muted: #98a1b3;
|
||||
--muted-2: #c0c7d5;
|
||||
--danger: #ff4d68;
|
||||
--warning: #ffb547;
|
||||
--ok: #2cf296;
|
||||
--accent: #2cf296;
|
||||
--accent-2: #2cb5ff;
|
||||
--accent-3: #ff4d68;
|
||||
--shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
body[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f6f8fb;
|
||||
--bg-2: #e8edf4;
|
||||
--panel: rgba(255, 255, 255, 0.94);
|
||||
--panel-2: rgba(246, 249, 253, 0.96);
|
||||
--line: rgba(31, 40, 55, 0.12);
|
||||
--line-strong: rgba(31, 40, 55, 0.2);
|
||||
--text: #111827;
|
||||
--muted: #5e6677;
|
||||
--muted-2: #394152;
|
||||
--shadow: 0 18px 50px rgba(36, 47, 66, 0.12);
|
||||
}
|
||||
|
||||
body[data-accent="green"] {
|
||||
--accent: #2cf296;
|
||||
--accent-2: #29d8ff;
|
||||
--accent-3: #ff4d68;
|
||||
}
|
||||
|
||||
body[data-accent="blue"] {
|
||||
--accent: #2cb5ff;
|
||||
--accent-2: #48f2d6;
|
||||
--accent-3: #ff9a3d;
|
||||
}
|
||||
|
||||
body[data-accent="red"] {
|
||||
--accent: #ff4d68;
|
||||
--accent-2: #ffd166;
|
||||
--accent-3: #2cb5ff;
|
||||
}
|
||||
|
||||
body[data-accent="pink"] {
|
||||
--accent: #ff4fd8;
|
||||
--accent-2: #2cf296;
|
||||
--accent-3: #ffb547;
|
||||
}
|
||||
|
||||
body[data-accent="orange"] {
|
||||
--accent: #ff9a3d;
|
||||
--accent-2: #2cb5ff;
|
||||
--accent-3: #2cf296;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px) 0 0 / 44px 44px,
|
||||
linear-gradient(0deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px) 0 0 / 44px 44px,
|
||||
linear-gradient(135deg, var(--bg), var(--bg-2));
|
||||
font-family: var(--font);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
body[data-theme="light"] {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(17, 24, 39, 0.045) 1px, transparent 1px) 0 0 / 44px 44px,
|
||||
linear-gradient(0deg, rgba(17, 24, 39, 0.045) 1px, transparent 1px) 0 0 / 44px 44px,
|
||||
linear-gradient(135deg, var(--bg), var(--bg-2));
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1560px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 20px 0 40px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.brand-block,
|
||||
.topbar-controls,
|
||||
.control-strip,
|
||||
.theme-controls,
|
||||
.summary-card,
|
||||
.panel-heading,
|
||||
.timeline-date,
|
||||
.admin-user-main,
|
||||
.admin-badges,
|
||||
.status-row,
|
||||
.toggle-group,
|
||||
.accent-swatches {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
gap: 12px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
padding: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent), transparent 40%);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--accent), transparent 65%);
|
||||
}
|
||||
|
||||
.brand-mark span {
|
||||
min-width: 0;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 16px var(--accent);
|
||||
}
|
||||
|
||||
.brand-mark span:nth-child(1) {
|
||||
height: 55%;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.brand-mark span:nth-child(2) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.brand-mark span:nth-child(3) {
|
||||
height: 72%;
|
||||
align-self: end;
|
||||
background: var(--accent-2);
|
||||
box-shadow: 0 0 16px var(--accent-2);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.2rem, 2vw, 1.75rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.brand-block p,
|
||||
.range-copy,
|
||||
.muted,
|
||||
.routine-meta,
|
||||
.timeline-meta,
|
||||
.chip small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.topbar-controls {
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
color: var(--muted-2);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.compact-field {
|
||||
width: min(220px, 40vw);
|
||||
}
|
||||
|
||||
.date-field {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--panel-2), transparent 10%);
|
||||
padding: 9px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent), transparent 78%);
|
||||
}
|
||||
|
||||
.segmented,
|
||||
.toggle-group {
|
||||
min-height: 40px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.segmented button,
|
||||
.toggle-group button,
|
||||
.text-button,
|
||||
.primary-button,
|
||||
.status-button {
|
||||
min-height: 34px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
padding: 7px 11px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.segmented button.active,
|
||||
.toggle-group button.active {
|
||||
color: #06100b;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 18px color-mix(in srgb, var(--accent), transparent 50%);
|
||||
}
|
||||
|
||||
.control-strip {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 2px 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.range-copy {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.theme-controls {
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.accent-swatches {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.accent-swatches button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 50%;
|
||||
background: var(--swatch);
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--swatch), transparent 55%);
|
||||
}
|
||||
|
||||
.accent-swatches button[data-accent="green"] {
|
||||
--swatch: #2cf296;
|
||||
}
|
||||
|
||||
.accent-swatches button[data-accent="blue"] {
|
||||
--swatch: #2cb5ff;
|
||||
}
|
||||
|
||||
.accent-swatches button[data-accent="red"] {
|
||||
--swatch: #ff4d68;
|
||||
}
|
||||
|
||||
.accent-swatches button[data-accent="pink"] {
|
||||
--swatch: #ff4fd8;
|
||||
}
|
||||
|
||||
.accent-swatches button[data-accent="orange"] {
|
||||
--swatch: #ff9a3d;
|
||||
}
|
||||
|
||||
.accent-swatches button.active {
|
||||
outline: 2px solid var(--text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.summary-card,
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
min-height: 104px;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.summary-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto 12px 10px 12px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--accent), transparent, var(--accent-2));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.summary-card span {
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
font-size: clamp(1.45rem, 2vw, 2.1rem);
|
||||
line-height: 1;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.summary-card small {
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-stat {
|
||||
min-height: 76px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.admin-stat span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-stat strong {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-user-list {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
max-height: 660px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.admin-user-card,
|
||||
.admin-editor {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--panel-2), transparent 4%);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.admin-user-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-user-main {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-badges {
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-user-metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.admin-user-metrics span {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--muted-2);
|
||||
padding: 4px 8px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.planner-panel {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
border: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-button:hover,
|
||||
.status-button:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
color: #06100b;
|
||||
background: var(--accent);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--accent), transparent 55%);
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.timeline-day {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--panel-2), transparent 4%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.timeline-date strong {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.timeline-meta {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.workout-list,
|
||||
.routine-list,
|
||||
.supplement-list,
|
||||
.nutrition-list,
|
||||
.metric-list {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.workout-item,
|
||||
.routine-item,
|
||||
.supplement-item,
|
||||
.log-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.workout-top,
|
||||
.routine-top,
|
||||
.supplement-top,
|
||||
.log-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.routine-meta,
|
||||
.timeline-meta,
|
||||
.details-line,
|
||||
.log-item p,
|
||||
.supplement-item p {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.details-line {
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--muted-2);
|
||||
padding: 3px 8px;
|
||||
font-size: 0.76rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.complete,
|
||||
.badge.taken,
|
||||
.badge.active,
|
||||
.badge.admin {
|
||||
color: var(--ok);
|
||||
border-color: color-mix(in srgb, var(--ok), transparent 50%);
|
||||
}
|
||||
|
||||
.badge.skipped,
|
||||
.badge.inactive {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger), transparent 50%);
|
||||
}
|
||||
|
||||
.badge.planned,
|
||||
.badge.member {
|
||||
color: var(--warning);
|
||||
border-color: color-mix(in srgb, var(--warning), transparent 50%);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-button {
|
||||
min-height: 30px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 5px 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.status-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.danger-action {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger), transparent 55%);
|
||||
}
|
||||
|
||||
.macro-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.macro-row {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.macro-copy {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: min(var(--value), 100%);
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--accent), transparent 45%);
|
||||
}
|
||||
|
||||
.forms-grid,
|
||||
.library-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.library-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-grid.two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.field.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.chip-cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-height: 430px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-grid;
|
||||
gap: 2px;
|
||||
max-width: 240px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 7px 10px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 110px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
text-align: center;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 30;
|
||||
max-width: min(360px, calc(100vw - 36px));
|
||||
padding: 12px 14px;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateY(16px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace-grid,
|
||||
.forms-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.planner-panel {
|
||||
grid-row: auto;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
width: min(100% - 20px, 720px);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: static;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar-controls,
|
||||
.control-strip,
|
||||
.theme-controls {
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.topbar-controls > *,
|
||||
.compact-field,
|
||||
.date-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segmented,
|
||||
.toggle-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.toggle-group {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.summary-grid,
|
||||
.admin-stats,
|
||||
.workspace-grid,
|
||||
.forms-grid,
|
||||
.library-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-grid.two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workout-top,
|
||||
.routine-top,
|
||||
.supplement-top,
|
||||
.log-top,
|
||||
.admin-user-main,
|
||||
.panel-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-badges {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../app/bootstrap.php';
|
||||
app_db();
|
||||
|
||||
$pageTitle = 'Neon Health Tracker';
|
||||
$activePage = 'home';
|
||||
$heroImage = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/Jogging_Woman_in_Grass.jpg/1280px-Jogging_Woman_in_Grass.jpg';
|
||||
|
||||
require __DIR__ . '/partials/site-header.php';
|
||||
?>
|
||||
<main>
|
||||
<section class="site-hero">
|
||||
<img src="<?= htmlspecialchars($heroImage, ENT_QUOTES) ?>" alt="Runner training outdoors in bright green grass">
|
||||
<div class="site-hero-copy">
|
||||
<p class="site-eyebrow">Workout planning, nutrition balance, supplement tracking</p>
|
||||
<h1>Neon Health Tracker</h1>
|
||||
<p class="site-lede">A multi-user fitness planner for people who want routines, meals, recovery, and body metrics in one fast web dashboard.</p>
|
||||
<div class="site-actions">
|
||||
<a class="site-primary" href="/signup.php">Start tracking</a>
|
||||
<a class="site-secondary" href="/workouts.php">Explore features</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="photo-credit">
|
||||
Photo: <a href="https://commons.wikimedia.org/wiki/File:Jogging_Woman_in_Grass.jpg">Jogging Woman in Grass</a>
|
||||
by Mike Baird, <a href="https://creativecommons.org/licenses/by/2.0/">CC BY 2.0</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="site-band">
|
||||
<div class="site-section-heading">
|
||||
<p class="site-eyebrow">Built for daily use</p>
|
||||
<h2>One workspace for training, food, recovery, and progress.</h2>
|
||||
</div>
|
||||
<div class="feature-grid">
|
||||
<article>
|
||||
<span>01</span>
|
||||
<h3>Routine planning</h3>
|
||||
<p>Schedule weight lifting, cycling, running, swimming, conditioning, mobility, and custom workouts by day, week, or month.</p>
|
||||
</article>
|
||||
<article>
|
||||
<span>02</span>
|
||||
<h3>Nutrition targets</h3>
|
||||
<p>Compare actual logs against daily, weekly, or monthly calories, protein, carbs, fat, and hydration goals.</p>
|
||||
</article>
|
||||
<article>
|
||||
<span>03</span>
|
||||
<h3>Supplement timing</h3>
|
||||
<p>Track pre-workout, intra-workout, post-workout, daily, and evening supplements without mixing them into meal logs.</p>
|
||||
</article>
|
||||
<article>
|
||||
<span>04</span>
|
||||
<h3>Health monitoring</h3>
|
||||
<p>Keep weight, body fat, sleep, resting heart rate, mood, and notes connected to the training plan.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="site-split">
|
||||
<div>
|
||||
<p class="site-eyebrow">For individuals and small teams</p>
|
||||
<h2>Profiles stay separate, admins stay in control.</h2>
|
||||
<p>Each user gets a profile, theme preference, routines, logs, goals, and metric history. Admins can create users, edit details, activate or deactivate accounts, and remove profiles from a dedicated dashboard.</p>
|
||||
<a class="site-secondary inline-link" href="/admin.php">Open admin dashboard</a>
|
||||
</div>
|
||||
<div class="site-metric-stack">
|
||||
<article><strong>Daily</strong><span>Plan today’s workout, meals, supplements, and body check-in.</span></article>
|
||||
<article><strong>Weekly</strong><span>See volume, cardio minutes, macro targets, and completion rate.</span></article>
|
||||
<article><strong>Monthly</strong><span>Compare long-range goals against real nutrition and training behavior.</span></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta-strip">
|
||||
<div>
|
||||
<p class="site-eyebrow">Ready when you are</p>
|
||||
<h2>Create your profile and open the tracker.</h2>
|
||||
</div>
|
||||
<a class="site-primary" href="/signup.php">Sign up</a>
|
||||
</section>
|
||||
</main>
|
||||
<?php require __DIR__ . '/partials/site-footer.php'; ?>
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../app/bootstrap.php';
|
||||
app_db();
|
||||
|
||||
$pageTitle = 'Nutrition Tracking | Neon Health Tracker';
|
||||
$activePage = 'nutrition';
|
||||
$photo = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/A_large_mixed_salad.jpg/1024px-A_large_mixed_salad.jpg';
|
||||
require __DIR__ . '/partials/site-header.php';
|
||||
?>
|
||||
<main>
|
||||
<section class="detail-hero reverse">
|
||||
<figure>
|
||||
<img src="<?= htmlspecialchars($photo, ENT_QUOTES) ?>" alt="Large mixed salad with vegetables and protein">
|
||||
<figcaption>
|
||||
Photo: <a href="https://commons.wikimedia.org/wiki/File:A_large_mixed_salad.jpg">A large mixed salad</a>
|
||||
by Jmabel, <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a>.
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div>
|
||||
<p class="site-eyebrow">Targets vs actuals</p>
|
||||
<h1>Keep nutrition flexible without losing the plan.</h1>
|
||||
<p>Set daily, weekly, or monthly goals for calories, protein, carbs, fat, and hydration. Log actual intake separately so deviations stay visible instead of buried.</p>
|
||||
<a class="site-primary" href="/signup.php">Start a nutrition plan</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-grid">
|
||||
<article>
|
||||
<h2>Separate goals</h2>
|
||||
<p>Nutrition goals can be attached to routines or kept independent for users who want to experiment.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Macro balance</h2>
|
||||
<p>Progress bars show how far actual logs are from the chosen target for the current planner range.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Hydration</h2>
|
||||
<p>Water targets stay beside macros so recovery and performance habits remain part of the daily flow.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Range scaling</h2>
|
||||
<p>Use daily, weekly, or monthly target scopes, with daily goals scaling when a broader target is not set.</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
<?php require __DIR__ . '/partials/site-footer.php'; ?>
|
||||
@@ -0,0 +1,14 @@
|
||||
<footer class="site-footer">
|
||||
<div>
|
||||
<strong>Neon Health Tracker</strong>
|
||||
<p>Workout planning, nutrition tracking, supplement timing, health metrics, and admin-managed profiles.</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="/signup.php">Create profile</a>
|
||||
<a href="/tracker.php">Open tracker</a>
|
||||
<a href="/admin.php">Admin dashboard</a>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="/assets/site.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
$pageTitle = $pageTitle ?? 'Neon Health Tracker';
|
||||
$activePage = $activePage ?? '';
|
||||
function site_active(string $page, string $activePage): string
|
||||
{
|
||||
return $page === $activePage ? ' class="active"' : '';
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= htmlspecialchars($pageTitle, ENT_QUOTES) ?></title>
|
||||
<link rel="stylesheet" href="/assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="site-logo" href="/">
|
||||
<span class="site-logo-bars" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span>Neon Health Tracker</span>
|
||||
</a>
|
||||
<nav class="site-nav" aria-label="Primary navigation">
|
||||
<a<?= site_active('home', $activePage) ?> href="/">Home</a>
|
||||
<a<?= site_active('workouts', $activePage) ?> href="/workouts.php">Workouts</a>
|
||||
<a<?= site_active('nutrition', $activePage) ?> href="/nutrition.php">Nutrition</a>
|
||||
<a<?= site_active('recovery', $activePage) ?> href="/recovery.php">Recovery</a>
|
||||
<a<?= site_active('signup', $activePage) ?> href="/signup.php">Sign up</a>
|
||||
<a href="/tracker.php">Tracker</a>
|
||||
<a href="/admin.php">Admin</a>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../app/bootstrap.php';
|
||||
app_db();
|
||||
|
||||
$pageTitle = 'Recovery and Supplements | Neon Health Tracker';
|
||||
$activePage = 'recovery';
|
||||
$photo = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/Jogging_Woman_in_Grass.jpg/1280px-Jogging_Woman_in_Grass.jpg';
|
||||
require __DIR__ . '/partials/site-header.php';
|
||||
?>
|
||||
<main>
|
||||
<section class="detail-hero">
|
||||
<div>
|
||||
<p class="site-eyebrow">Recovery stack</p>
|
||||
<h1>Track supplements and body signals beside the training plan.</h1>
|
||||
<p>Plan pre-workout, intra-workout, post-workout, daily, and evening supplements. Pair that with weight, body fat, sleep, resting heart rate, mood, and notes.</p>
|
||||
<a class="site-primary" href="/signup.php">Build a recovery log</a>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="<?= htmlspecialchars($photo, ENT_QUOTES) ?>" alt="Runner training outdoors">
|
||||
<figcaption>
|
||||
Photo: <a href="https://commons.wikimedia.org/wiki/File:Jogging_Woman_in_Grass.jpg">Jogging Woman in Grass</a>
|
||||
by Mike Baird, <a href="https://creativecommons.org/licenses/by/2.0/">CC BY 2.0</a>.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="detail-grid">
|
||||
<article>
|
||||
<h2>Supplement library</h2>
|
||||
<p>Start with creatine, caffeine, citrulline, electrolytes, whey, casein, magnesium, omega-3s, greens, and more.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Timing windows</h2>
|
||||
<p>Separate pre-workout, intra-workout, post-workout, daily, and evening routines so supplement habits stay clear.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Health metrics</h2>
|
||||
<p>Log weight, sleep, resting heart rate, body fat, mood, and notes to understand how training is landing.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Custom additions</h2>
|
||||
<p>Add custom supplements and doses as your recovery plan evolves.</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
<?php require __DIR__ . '/partials/site-footer.php'; ?>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../app/bootstrap.php';
|
||||
app_db();
|
||||
|
||||
$pageTitle = 'Sign Up | Neon Health Tracker';
|
||||
$activePage = 'signup';
|
||||
require __DIR__ . '/partials/site-header.php';
|
||||
?>
|
||||
<main>
|
||||
<section class="signup-layout">
|
||||
<div class="signup-copy">
|
||||
<p class="site-eyebrow">Create your profile</p>
|
||||
<h1>Start with the basics. Tune the plan later.</h1>
|
||||
<p>Your profile keeps workouts, nutrition goals, supplement logs, health metrics, and theme preferences separate from every other user.</p>
|
||||
<div class="signup-steps">
|
||||
<article><strong>1</strong><span>Create profile</span></article>
|
||||
<article><strong>2</strong><span>Open tracker</span></article>
|
||||
<article><strong>3</strong><span>Add routines and targets</span></article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="signup-card" id="publicSignupForm">
|
||||
<div>
|
||||
<p class="site-eyebrow">Sign up</p>
|
||||
<h2>New profile</h2>
|
||||
</div>
|
||||
<label class="site-field"><span>Name</span><input name="name" required autocomplete="name"></label>
|
||||
<label class="site-field"><span>Email</span><input name="email" type="email" autocomplete="email"></label>
|
||||
<label class="site-field"><span>Phone</span><input name="phone" type="tel" autocomplete="tel"></label>
|
||||
<label class="site-field"><span>Fitness focus</span><input name="fitness_focus" placeholder="Strength, endurance, recovery"></label>
|
||||
<div class="signup-row">
|
||||
<label class="site-field"><span>Birthday</span><input name="birthday" type="date"></label>
|
||||
<label class="site-field"><span>Height in</span><input name="height_in" type="number" min="0" step="0.5"></label>
|
||||
</div>
|
||||
<label class="site-field"><span>Training level</span><select name="training_level"><option></option><option>Beginner</option><option>Intermediate</option><option>Advanced</option><option>Competitive</option></select></label>
|
||||
<div class="signup-row">
|
||||
<label class="site-field"><span>Theme</span><select name="theme_mode"><option>dark</option><option>light</option></select></label>
|
||||
<label class="site-field"><span>Accent</span><select name="theme_accent"><option>green</option><option>blue</option><option>red</option><option>pink</option><option>orange</option></select></label>
|
||||
</div>
|
||||
<button class="site-primary full" type="submit">Create profile</button>
|
||||
<p class="signup-result" id="signupResult" role="status" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<?php require __DIR__ . '/partials/site-footer.php'; ?>
|
||||
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../app/bootstrap.php';
|
||||
app_db();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Neon Health Tracker</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body data-theme="dark" data-accent="green">
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand-block">
|
||||
<div class="brand-mark" aria-hidden="true">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Neon Health Tracker</h1>
|
||||
<p id="profileFocus">Loading profile</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-controls">
|
||||
<label class="field compact-field">
|
||||
<span>Profile</span>
|
||||
<select id="profileSelect"></select>
|
||||
</label>
|
||||
<div class="segmented" aria-label="Planner scope">
|
||||
<button type="button" data-view="daily">Daily</button>
|
||||
<button type="button" data-view="weekly">Weekly</button>
|
||||
<button type="button" data-view="monthly">Monthly</button>
|
||||
</div>
|
||||
<label class="field date-field">
|
||||
<span>Date</span>
|
||||
<input id="datePicker" type="date">
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="control-strip">
|
||||
<div class="range-copy" id="rangeCopy">Loading range</div>
|
||||
<div class="theme-controls">
|
||||
<div class="toggle-group" aria-label="Theme mode">
|
||||
<button type="button" id="darkModeButton">Dark</button>
|
||||
<button type="button" id="lightModeButton">Light</button>
|
||||
</div>
|
||||
<div class="accent-swatches" aria-label="Accent color">
|
||||
<button type="button" data-accent="green" title="Green"></button>
|
||||
<button type="button" data-accent="blue" title="Blue"></button>
|
||||
<button type="button" data-accent="red" title="Red"></button>
|
||||
<button type="button" data-accent="pink" title="Pink"></button>
|
||||
<button type="button" data-accent="orange" title="Orange"></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="summary-grid" id="summaryGrid" aria-live="polite"></section>
|
||||
|
||||
<section class="panel admin-panel" id="adminPanel" hidden>
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h2>User Management</h2>
|
||||
</div>
|
||||
<button class="text-button" type="button" id="newAdminUserButton">New profile</button>
|
||||
</div>
|
||||
<div class="admin-stats" id="adminStats"></div>
|
||||
<div class="admin-layout">
|
||||
<div class="admin-user-list" id="adminUserList"></div>
|
||||
<form class="admin-editor" id="adminUserForm">
|
||||
<input type="hidden" name="target_user_id">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Profile Editor</p>
|
||||
<h2 id="adminUserFormTitle">Create Profile</h2>
|
||||
</div>
|
||||
<button class="text-button" type="button" id="clearAdminUserFormButton">Clear</button>
|
||||
</div>
|
||||
<div class="form-grid two">
|
||||
<label class="field"><span>Name</span><input name="name" required></label>
|
||||
<label class="field"><span>Email</span><input name="email" type="email"></label>
|
||||
<label class="field"><span>Role</span><select name="role"><option value="member">member</option><option value="admin">admin</option></select></label>
|
||||
<label class="field"><span>Status</span><select name="status"><option value="active">active</option><option value="inactive">inactive</option></select></label>
|
||||
<label class="field"><span>Phone</span><input name="phone" type="tel"></label>
|
||||
<label class="field"><span>Birthday</span><input name="birthday" type="date"></label>
|
||||
<label class="field"><span>Height in</span><input name="height_in" type="number" min="0" step="0.5"></label>
|
||||
<label class="field"><span>Training level</span><select name="training_level"><option></option><option>Beginner</option><option>Intermediate</option><option>Advanced</option><option>Competitive</option></select></label>
|
||||
<label class="field"><span>Theme</span><select name="theme_mode"><option>dark</option><option>light</option></select></label>
|
||||
<label class="field"><span>Accent</span><select name="theme_accent"><option>green</option><option>blue</option><option>red</option><option>pink</option><option>orange</option></select></label>
|
||||
<label class="field wide"><span>Fitness focus</span><input name="fitness_focus" placeholder="Strength, endurance, recovery"></label>
|
||||
<label class="field wide"><span>Emergency contact</span><input name="emergency_contact" placeholder="Name and phone"></label>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Save profile</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-grid">
|
||||
<div class="panel planner-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Planner</p>
|
||||
<h2>Workout Schedule</h2>
|
||||
</div>
|
||||
<button class="text-button" type="button" data-jump="#workoutForm">Add workout</button>
|
||||
</div>
|
||||
<div class="timeline" id="workoutTimeline"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Balance</p>
|
||||
<h2>Nutrition Target vs Actual</h2>
|
||||
</div>
|
||||
<button class="text-button" type="button" data-jump="#nutritionLogForm">Log meal</button>
|
||||
</div>
|
||||
<div id="nutritionCompare"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Plan Library</p>
|
||||
<h2>Routines</h2>
|
||||
</div>
|
||||
<button class="text-button" type="button" data-jump="#routineForm">Add routine</button>
|
||||
</div>
|
||||
<div class="routine-list" id="routineList"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Recovery Stack</p>
|
||||
<h2>Supplements</h2>
|
||||
</div>
|
||||
<button class="text-button" type="button" data-jump="#supplementLogForm">Log dose</button>
|
||||
</div>
|
||||
<div class="supplement-list" id="supplementLogList"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="forms-grid" aria-label="Tracker forms">
|
||||
<form class="panel form-panel" id="workoutForm">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Schedule</p>
|
||||
<h2>Add Workout</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid two">
|
||||
<label class="field"><span>Date</span><input name="scheduled_on" type="date" required></label>
|
||||
<label class="field"><span>Routine</span><select name="routine_id"></select></label>
|
||||
<label class="field"><span>Exercise</span><select name="exercise_id"></select></label>
|
||||
<label class="field"><span>Workout type</span><input name="workout_type" list="workoutTypes" value="Weight lifting"></label>
|
||||
<label class="field wide"><span>Title</span><input name="title" placeholder="Push strength" required></label>
|
||||
<label class="field"><span>Sets</span><input name="sets" type="number" min="0" step="1"></label>
|
||||
<label class="field"><span>Reps</span><input name="reps" type="number" min="0" step="1"></label>
|
||||
<label class="field"><span>Weight lb</span><input name="weight_lbs" type="number" min="0" step="0.5"></label>
|
||||
<label class="field"><span>Minutes</span><input name="duration_min" type="number" min="0" step="1"></label>
|
||||
<label class="field"><span>Miles</span><input name="distance_mi" type="number" min="0" step="0.01"></label>
|
||||
<label class="field"><span>Intensity</span><input name="intensity" placeholder="RPE 8"></label>
|
||||
<label class="field"><span>Status</span><select name="status"><option>planned</option><option>complete</option><option>skipped</option></select></label>
|
||||
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Save workout</button>
|
||||
</form>
|
||||
|
||||
<form class="panel form-panel" id="routineForm">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Program</p>
|
||||
<h2>Add Routine</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid two">
|
||||
<label class="field"><span>Name</span><input name="name" placeholder="Strength + swim" required></label>
|
||||
<label class="field"><span>Workout type</span><input name="workout_type" list="workoutTypes" value="Hybrid"></label>
|
||||
<label class="field"><span>Planner scope</span><select name="planner_scope"><option>daily</option><option selected>weekly</option><option>monthly</option></select></label>
|
||||
<label class="field"><span>Nutrition target</span><select name="nutrition_goal_id"></select></label>
|
||||
<label class="field"><span>Start date</span><input name="start_date" type="date" required></label>
|
||||
<label class="field"><span>Intensity</span><input name="intensity" placeholder="Moderate"></label>
|
||||
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Save routine</button>
|
||||
</form>
|
||||
|
||||
<form class="panel form-panel" id="nutritionGoalForm">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Targets</p>
|
||||
<h2>Add Nutrition Goal</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid two">
|
||||
<label class="field"><span>Label</span><input name="label" placeholder="Cutting day" required></label>
|
||||
<label class="field"><span>Scope</span><select name="scope"><option>daily</option><option>weekly</option><option>monthly</option></select></label>
|
||||
<label class="field"><span>Start date</span><input name="start_date" type="date" required></label>
|
||||
<label class="field"><span>Calories</span><input name="calories" type="number" min="0" step="1"></label>
|
||||
<label class="field"><span>Protein g</span><input name="protein_g" type="number" min="0" step="1"></label>
|
||||
<label class="field"><span>Carbs g</span><input name="carbs_g" type="number" min="0" step="1"></label>
|
||||
<label class="field"><span>Fat g</span><input name="fat_g" type="number" min="0" step="1"></label>
|
||||
<label class="field"><span>Water ml</span><input name="water_ml" type="number" min="0" step="50"></label>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Save target</button>
|
||||
</form>
|
||||
|
||||
<form class="panel form-panel" id="nutritionLogForm">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Food</p>
|
||||
<h2>Log Nutrition</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid two">
|
||||
<label class="field"><span>Date</span><input name="logged_on" type="date" required></label>
|
||||
<label class="field"><span>Calories</span><input name="calories" type="number" min="0" step="1"></label>
|
||||
<label class="field"><span>Protein g</span><input name="protein_g" type="number" min="0" step="1"></label>
|
||||
<label class="field"><span>Carbs g</span><input name="carbs_g" type="number" min="0" step="1"></label>
|
||||
<label class="field"><span>Fat g</span><input name="fat_g" type="number" min="0" step="1"></label>
|
||||
<label class="field"><span>Water ml</span><input name="water_ml" type="number" min="0" step="50"></label>
|
||||
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Save nutrition</button>
|
||||
</form>
|
||||
|
||||
<form class="panel form-panel" id="supplementLogForm">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Dose</p>
|
||||
<h2>Log Supplement</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid two">
|
||||
<label class="field"><span>Date</span><input name="taken_on" type="date" required></label>
|
||||
<label class="field"><span>Supplement</span><select name="supplement_id"></select></label>
|
||||
<label class="field"><span>Timing</span><input name="timing" list="supplementTiming" value="Daily"></label>
|
||||
<label class="field"><span>Dose</span><input name="dose" placeholder="5 g"></label>
|
||||
<label class="field"><span>Status</span><select name="status"><option>planned</option><option>taken</option><option>skipped</option></select></label>
|
||||
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Save dose</button>
|
||||
</form>
|
||||
|
||||
<form class="panel form-panel" id="metricForm">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Health</p>
|
||||
<h2>Log Body Metrics</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid two">
|
||||
<label class="field"><span>Date</span><input name="measured_on" type="date" required></label>
|
||||
<label class="field"><span>Weight lb</span><input name="weight_lbs" type="number" min="0" step="0.1"></label>
|
||||
<label class="field"><span>Body fat %</span><input name="body_fat_pct" type="number" min="0" step="0.1"></label>
|
||||
<label class="field"><span>Sleep hr</span><input name="sleep_hours" type="number" min="0" step="0.1"></label>
|
||||
<label class="field"><span>Resting HR</span><input name="resting_hr" type="number" min="0" step="1"></label>
|
||||
<label class="field"><span>Mood</span><input name="mood" placeholder="Focused"></label>
|
||||
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Save metrics</button>
|
||||
</form>
|
||||
|
||||
<form class="panel form-panel" id="exerciseForm">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Exercises</p>
|
||||
<h2>Add Custom Exercise</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid two">
|
||||
<label class="field"><span>Name</span><input name="name" required></label>
|
||||
<label class="field"><span>Category</span><input name="category" list="workoutTypes" value="Custom"></label>
|
||||
<label class="field"><span>Muscle group</span><input name="muscle_group" placeholder="Back"></label>
|
||||
<label class="field"><span>Default unit</span><input name="default_unit" placeholder="sets x reps"></label>
|
||||
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Save exercise</button>
|
||||
</form>
|
||||
|
||||
<form class="panel form-panel" id="supplementForm">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Supplements</p>
|
||||
<h2>Add Custom Supplement</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid two">
|
||||
<label class="field"><span>Name</span><input name="name" required></label>
|
||||
<label class="field"><span>Timing</span><input name="timing" list="supplementTiming" value="Daily"></label>
|
||||
<label class="field"><span>Dose</span><input name="default_dose" placeholder="1 serving"></label>
|
||||
<label class="field"><span>Purpose</span><input name="purpose" placeholder="Recovery"></label>
|
||||
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Save supplement</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="library-grid">
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Known Movements</p>
|
||||
<h2>Exercise Library</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chip-cloud" id="exerciseLibrary"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Known Stack</p>
|
||||
<h2>Supplement Library</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chip-cloud" id="supplementLibrary"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<datalist id="workoutTypes">
|
||||
<option value="Weight lifting"></option>
|
||||
<option value="Cycling"></option>
|
||||
<option value="Running"></option>
|
||||
<option value="Swimming"></option>
|
||||
<option value="Conditioning"></option>
|
||||
<option value="Mobility"></option>
|
||||
<option value="Hybrid"></option>
|
||||
<option value="Outdoor"></option>
|
||||
<option value="Core"></option>
|
||||
</datalist>
|
||||
<datalist id="supplementTiming">
|
||||
<option value="Pre-workout"></option>
|
||||
<option value="Intra-workout"></option>
|
||||
<option value="Post-workout"></option>
|
||||
<option value="Daily"></option>
|
||||
<option value="Evening"></option>
|
||||
</datalist>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/assets/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../app/bootstrap.php';
|
||||
app_db();
|
||||
|
||||
$pageTitle = 'Workout Planning | Neon Health Tracker';
|
||||
$activePage = 'workouts';
|
||||
$photo = 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/33/Cycling_in_Amsterdam_%28893%29.jpg/1280px-Cycling_in_Amsterdam_%28893%29.jpg';
|
||||
require __DIR__ . '/partials/site-header.php';
|
||||
?>
|
||||
<main>
|
||||
<section class="detail-hero">
|
||||
<div>
|
||||
<p class="site-eyebrow">Daily, weekly, monthly</p>
|
||||
<h1>Plan training without losing the details that matter.</h1>
|
||||
<p>Build routines around weight lifting, cycling, running, swimming, mobility, conditioning, or anything custom. Track volume, duration, distance, intensity, status, and notes from one dashboard.</p>
|
||||
<a class="site-primary" href="/signup.php">Create a profile</a>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="<?= htmlspecialchars($photo, ENT_QUOTES) ?>" alt="Cyclists riding through Amsterdam">
|
||||
<figcaption>
|
||||
Photo: <a href="https://commons.wikimedia.org/wiki/File:Cycling_in_Amsterdam_(893).jpg">Cycling in Amsterdam (893)</a>
|
||||
by FaceMePLS, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC BY-SA 2.0</a>.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="detail-grid">
|
||||
<article>
|
||||
<h2>Routine templates</h2>
|
||||
<p>Create recurring programs for strength blocks, endurance weeks, hybrid plans, or recovery cycles.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Workout library</h2>
|
||||
<p>Start with known movements like squats, deadlifts, bench press, pull-ups, runs, rides, swims, rows, HIIT, and mobility work.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Completion tracking</h2>
|
||||
<p>Mark workouts as planned, complete, or skipped and watch completion rate update across the selected range.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Volume and cardio totals</h2>
|
||||
<p>Lift volume, training minutes, and distance are summarized automatically for the current day, week, or month.</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
<?php require __DIR__ . '/partials/site-footer.php'; ?>
|
||||
Reference in New Issue
Block a user