This commit is contained in:
Ty Clifford
2026-06-30 11:14:42 -04:00
commit 0fe5a92f90
18 changed files with 4775 additions and 0 deletions
+248
View File
@@ -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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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);
}
});
+678
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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);
}
});
+736
View File
@@ -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;
}
}
+43
View File
@@ -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);
}
});
}
+867
View File
@@ -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;
}
}