feat: Implement active workout flow with status management
- Added `status`, `total_sets`, and `total_volume` fields to the Workout model. - Introduced `source_kind`, `title_snapshot`, and `image_s3_url_snapshot` fields to the WorkoutItem model. - Created endpoints for managing active workouts, including finishing and discarding workouts. - Updated workout creation to ensure only one active workout exists per user. - Implemented batch addition of workout sets and updates to workout set details. - Enhanced database schema with Alembic migrations to support new fields and constraints. - Added validation to ensure at least one field is provided for workout set updates. - Updated calorie estimation logic to reflect new workout set structure.
This commit is contained in:
+110
-636
@@ -1,62 +1,41 @@
|
||||
import { Link, Outlet, RouterProvider, createRootRoute, createRoute, createRouter, useNavigate } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api, type AuthState } from "./api";
|
||||
import type { CatalogEntity, Workout, WorkoutItem as WorkoutItemType, WorkoutSet } from "./types";
|
||||
import { ApiError, api, type AuthState } from "./api";
|
||||
import { AnalyticsPage } from "./features/analytics/AnalyticsPage";
|
||||
import { AuthProvider, useAuth } from "./features/auth/AuthContext";
|
||||
import { AuthScreen } from "./features/auth/AuthScreen";
|
||||
import { clearAuth, loadAuth, saveAuth } from "./features/auth/authStorage";
|
||||
import { CatalogPage } from "./features/catalog/CatalogPage";
|
||||
import { HistoryPage } from "./features/history/HistoryPage";
|
||||
import { WorkoutDetailPage } from "./features/history/WorkoutDetailPage";
|
||||
import { ActiveWorkoutPage } from "./features/workout/ActiveWorkoutPage";
|
||||
import { invalidateWorkoutQueries } from "./features/workout/hooks";
|
||||
import { Metric } from "./shared/Metric";
|
||||
|
||||
type Tab = "dashboard" | "catalog" | "workout" | "history" | "analytics";
|
||||
|
||||
const authStorageKey = "train-watcher-auth";
|
||||
|
||||
function loadAuth(): AuthState | null {
|
||||
const raw = localStorage.getItem(authStorageKey);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as AuthState;
|
||||
} catch {
|
||||
localStorage.removeItem(authStorageKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [auth, setAuth] = useState<AuthState | null>(loadAuth);
|
||||
const [tab, setTab] = useState<Tab>("dashboard");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function saveAuth(next: AuthState) {
|
||||
localStorage.setItem(authStorageKey, JSON.stringify(next));
|
||||
setAuth(next);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem(authStorageKey);
|
||||
queryClient.clear();
|
||||
setAuth(null);
|
||||
}
|
||||
|
||||
if (!auth) {
|
||||
return <AuthScreen onAuth={saveAuth} />;
|
||||
}
|
||||
function AppShell() {
|
||||
const { auth, logout } = useAuth();
|
||||
const nav = [
|
||||
{ to: "/", label: "Сегодня" },
|
||||
{ to: "/workout/active", label: "Тренировка" },
|
||||
{ to: "/catalog/exercises", label: "Упражнения" },
|
||||
{ to: "/history", label: "История" },
|
||||
{ to: "/analytics", label: "Аналитика" },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div>
|
||||
<div className="brand-mark">
|
||||
<p className="eyebrow">Train Watcher</p>
|
||||
<h1>Дневник тренировок</h1>
|
||||
<h1>Дневник силовых сессий</h1>
|
||||
</div>
|
||||
<nav>
|
||||
{([
|
||||
["dashboard", "Обзор"],
|
||||
["catalog", "Каталог"],
|
||||
["workout", "Тренировка"],
|
||||
["history", "История"],
|
||||
["analytics", "Аналитика"],
|
||||
] as Array<[Tab, string]>).map(([key, label]) => (
|
||||
<button className={tab === key ? "active" : ""} key={key} onClick={() => setTab(key)}>
|
||||
{label}
|
||||
</button>
|
||||
<nav aria-label="Основная навигация">
|
||||
{nav.map((item) => (
|
||||
<Link key={item.to} to={item.to} activeProps={{ className: "active" }} activeOptions={{ exact: item.to === "/" }}>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="profile-card">
|
||||
@@ -65,625 +44,120 @@ export function App() {
|
||||
<button onClick={logout}>Выйти</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main>
|
||||
{tab === "dashboard" && <Dashboard token={auth.accessToken} onStart={() => setTab("workout")} />}
|
||||
{tab === "catalog" && <Catalog token={auth.accessToken} />}
|
||||
{tab === "workout" && <ActiveWorkout token={auth.accessToken} />}
|
||||
{tab === "history" && <History token={auth.accessToken} />}
|
||||
{tab === "analytics" && <Analytics token={auth.accessToken} />}
|
||||
<main className="main-stage">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthScreen({ onAuth }: { onAuth: (auth: AuthState) => void }) {
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [email, setEmail] = useState("demo@example.com");
|
||||
const [password, setPassword] = useState("password123");
|
||||
const [displayName, setDisplayName] = useState("Demo Athlete");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
mode === "login"
|
||||
? api.login({ email, password })
|
||||
: api.register({ email, password, display_name: displayName }),
|
||||
onSuccess: onAuth,
|
||||
onError: (err) => setError(err.message),
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-layout">
|
||||
<section className="hero-panel">
|
||||
<p className="eyebrow">Progressive overload tracker</p>
|
||||
<h1>Фиксируй подходы, рабочий вес и динамику без лишнего шума.</h1>
|
||||
<p>
|
||||
MVP уже разделен на frontend, BFF и logic-service, а изображения каталога хранятся в MinIO как S3-объекты.
|
||||
</p>
|
||||
</section>
|
||||
<form className="card auth-card" onSubmit={submit}>
|
||||
<h2>{mode === "login" ? "Вход" : "Регистрация"}</h2>
|
||||
{mode === "register" && (
|
||||
<label>
|
||||
Имя
|
||||
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Email
|
||||
<input type="email" autoComplete="username" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Пароль
|
||||
<input type="password" autoComplete={mode === "register" ? "new-password" : "current-password"} value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button className="primary" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Отправка..." : mode === "login" ? "Войти" : "Создать аккаунт"}
|
||||
</button>
|
||||
<button type="button" className="ghost" onClick={() => setMode(mode === "login" ? "register" : "login")}>
|
||||
{mode === "login" ? "Нужна регистрация" : "Уже есть аккаунт"}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function useCatalog(token: string) {
|
||||
const equipment = useQuery({ queryKey: ["equipment"], queryFn: () => api.equipment(token) });
|
||||
const exercises = useQuery({ queryKey: ["exercises"], queryFn: () => api.exercises(token) });
|
||||
return { equipment, exercises };
|
||||
}
|
||||
|
||||
function Dashboard({ token, onStart }: { token: string; onStart: () => void }) {
|
||||
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
|
||||
const calories = useQuery({ queryKey: ["calories"], queryFn: () => api.calories(token) });
|
||||
const latest = workouts.data?.[0];
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Dashboard</p>
|
||||
<h2>Обзор</h2>
|
||||
</div>
|
||||
<button className="primary" onClick={onStart}>Начать тренировку</button>
|
||||
</div>
|
||||
<div className="stats-grid">
|
||||
<Metric label="Тренировок" value={workouts.data?.length ?? 0} />
|
||||
<Metric label="Калорий всего" value={Math.round(calories.data?.total_calories ?? 0)} />
|
||||
<Metric label="Последняя" value={latest ? new Date(latest.started_at).toLocaleDateString("ru-RU") : "нет"} />
|
||||
</div>
|
||||
<section className="card">
|
||||
<h3>Последняя тренировка</h3>
|
||||
{latest ? <WorkoutSummary workout={latest} /> : <p className="muted">Создай первую тренировку, чтобы увидеть историю.</p>}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Catalog({ token }: { token: string }) {
|
||||
const [kind, setKind] = useState<"equipment" | "exercise">("equipment");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
function HomePage() {
|
||||
const { auth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { equipment, exercises } = useCatalog(token);
|
||||
const list = kind === "equipment" ? equipment.data : exercises.data;
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const image = file ? await api.uploadImage(token, kind, file) : {};
|
||||
const payload = { name, description, ...image };
|
||||
return kind === "equipment" ? api.createEquipment(token, payload) : api.createExercise(token, payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setFile(null);
|
||||
void queryClient.invalidateQueries({ queryKey: [kind === "equipment" ? "equipment" : "exercises"] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Catalog</p>
|
||||
<h2>Тренажеры и упражнения</h2>
|
||||
</div>
|
||||
<div className="segmented">
|
||||
<button className={kind === "equipment" ? "active" : ""} onClick={() => setKind("equipment")}>Тренажеры</button>
|
||||
<button className={kind === "exercise" ? "active" : ""} onClick={() => setKind("exercise")}>Упражнения</button>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
className="card form-grid"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
createMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Название
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Описание
|
||||
<input value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Картинка
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
</label>
|
||||
<button className="primary" disabled={createMutation.isPending}>{createMutation.isPending ? "Сохранение..." : "Добавить"}</button>
|
||||
{createMutation.error && <p className="error">{createMutation.error.message}</p>}
|
||||
</form>
|
||||
<div className="catalog-grid">
|
||||
{list?.map((entity) => <CatalogCard entity={entity} key={entity.id} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveWorkout({ token }: { token: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { equipment, exercises } = useCatalog(token);
|
||||
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
|
||||
const [activeWorkout, setActiveWorkout] = useState<Workout | null>(null);
|
||||
const [catalogKind, setCatalogKind] = useState<"exercise" | "equipment">("exercise");
|
||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
|
||||
const [showFinishModal, setShowFinishModal] = useState(false);
|
||||
const [finishNotes, setFinishNotes] = useState("");
|
||||
const [elapsed, setElapsed] = useState("");
|
||||
|
||||
const current = useMemo(
|
||||
() => activeWorkout ?? workouts.data?.find((w) => !w.finished_at) ?? null,
|
||||
[activeWorkout, workouts.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!current?.started_at) return;
|
||||
const started = new Date(current.started_at).getTime();
|
||||
const tick = () => {
|
||||
const diff = Date.now() - started;
|
||||
const m = Math.floor(diff / 60000);
|
||||
const s = Math.floor((diff % 60000) / 1000);
|
||||
setElapsed(`${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`);
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [current?.started_at]);
|
||||
|
||||
const addedEntityIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
current?.items.forEach((item) => {
|
||||
if (item.exercise_id) ids.add(item.exercise_id);
|
||||
if (item.equipment_id) ids.add(item.equipment_id);
|
||||
});
|
||||
return ids;
|
||||
}, [current?.items]);
|
||||
|
||||
const catalogList = catalogKind === "exercise" ? exercises.data : equipment.data;
|
||||
const activeWorkout = useQuery({ queryKey: ["workout", "active"], queryFn: () => api.activeWorkout(auth.accessToken) });
|
||||
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(auth.accessToken) });
|
||||
const calories = useQuery({ queryKey: ["calories"], queryFn: () => api.calories(auth.accessToken) });
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => api.createWorkout(token),
|
||||
onSuccess: (workout) => {
|
||||
setActiveWorkout(workout);
|
||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
||||
mutationFn: () => api.createWorkout(auth.accessToken),
|
||||
onSuccess: async () => {
|
||||
await invalidateWorkoutQueries(queryClient);
|
||||
void navigate({ to: "/workout/active" });
|
||||
},
|
||||
});
|
||||
|
||||
const addItemMutation = useMutation({
|
||||
mutationFn: (entity: CatalogEntity) => {
|
||||
if (!current) throw new Error("Нет активной тренировки");
|
||||
return api.addWorkoutItem(token, current.id, {
|
||||
exercise_id: catalogKind === "exercise" ? entity.id : null,
|
||||
equipment_id: catalogKind === "equipment" ? entity.id : null,
|
||||
});
|
||||
},
|
||||
onSuccess: (item) => {
|
||||
setActiveWorkout((w) => (w ? { ...w, items: [...w.items, item] } : w));
|
||||
setExpandedItemId(item.id);
|
||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
||||
},
|
||||
});
|
||||
|
||||
const removeItemMutation = useMutation({
|
||||
mutationFn: (itemId: string) => api.removeWorkoutItem(token, itemId),
|
||||
onSuccess: (_, itemId) => {
|
||||
setActiveWorkout((w) => (w ? { ...w, items: w.items.filter((i) => i.id !== itemId) } : w));
|
||||
if (expandedItemId === itemId) setExpandedItemId(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["calories"] });
|
||||
},
|
||||
});
|
||||
|
||||
const addSetsMutation = useMutation({
|
||||
mutationFn: async ({ itemId, sets }: { itemId: string; sets: Array<{ weight: number; reps: number }> }) => {
|
||||
const created: WorkoutSet[] = [];
|
||||
for (const set of sets) {
|
||||
created.push(await api.addWorkoutSet(token, itemId, set));
|
||||
onError: async (error) => {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
await invalidateWorkoutQueries(queryClient);
|
||||
void navigate({ to: "/workout/active" });
|
||||
}
|
||||
return created;
|
||||
},
|
||||
onSuccess: (workoutSets, { itemId }) => {
|
||||
setActiveWorkout((w) => {
|
||||
if (!w) return w;
|
||||
const addedCalories = workoutSets.reduce((sum, set) => sum + (set.calories ?? 0), 0);
|
||||
return {
|
||||
...w,
|
||||
estimated_calories: w.estimated_calories + addedCalories,
|
||||
items: w.items.map((item) =>
|
||||
item.id === itemId ? { ...item, sets: [...item.sets, ...workoutSets] } : item,
|
||||
),
|
||||
};
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["calories"] });
|
||||
},
|
||||
});
|
||||
|
||||
const removeSetMutation = useMutation({
|
||||
mutationFn: ({ itemId, setId }: { itemId: string; setId: string }) =>
|
||||
api.removeWorkoutSet(token, itemId, setId),
|
||||
onSuccess: (_, { itemId, setId }) => {
|
||||
setActiveWorkout((w) => {
|
||||
if (!w) return w;
|
||||
const items = w.items.map((item) =>
|
||||
item.id === itemId ? { ...item, sets: item.sets.filter((s) => s.id !== setId) } : item,
|
||||
);
|
||||
return { ...w, items };
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["calories"] });
|
||||
},
|
||||
});
|
||||
|
||||
const finishMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!current) throw new Error("Нет активной тренировки");
|
||||
return api.finishWorkout(token, current.id, finishNotes || undefined);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActiveWorkout(null);
|
||||
setShowFinishModal(false);
|
||||
setFinishNotes("");
|
||||
setExpandedItemId(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["calories"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Workout</p>
|
||||
<h2>Тренировка</h2>
|
||||
</div>
|
||||
</div>
|
||||
<section className="card workout-cta">
|
||||
<h3>Готов к тренировке?</h3>
|
||||
<p>Добавляй упражнения и тренажеры, записывай подходы — всё в одном месте.</p>
|
||||
<button className="primary" onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>
|
||||
{startMutation.isPending ? "Создание..." : "Начать тренировку"}
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const totalSets = current.items.reduce((sum, item) => sum + item.sets.length, 0);
|
||||
const active = activeWorkout.data;
|
||||
const latestFinished = workouts.data?.find((workout) => workout.status === "finished");
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<section className="stack home-stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Workout</p>
|
||||
<h2>Текущая тренировка</h2>
|
||||
</div>
|
||||
<div className="workout-live-stats">
|
||||
<span className="timer-badge">{elapsed || "00:00"}</span>
|
||||
<span className="kcal-badge">{Math.round(current.estimated_calories)} ккал</span>
|
||||
<p className="eyebrow">Сегодня</p>
|
||||
<h2>Состояние дня</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="card catalog-picker-section">
|
||||
<div className="catalog-picker-header">
|
||||
<h3>Каталог</h3>
|
||||
<div className="segmented">
|
||||
<button className={catalogKind === "exercise" ? "active" : ""} onClick={() => setCatalogKind("exercise")}>Упражнения</button>
|
||||
<button className={catalogKind === "equipment" ? "active" : ""} onClick={() => setCatalogKind("equipment")}>Тренажеры</button>
|
||||
</div>
|
||||
<section className={`daily-command card ${active ? "active-session" : "idle-session"}`}>
|
||||
<div>
|
||||
<p className="eyebrow">{active ? "Active workout" : "Ready state"}</p>
|
||||
<h3>{active ? "Активная тренировка" : "Нет активной тренировки"}</h3>
|
||||
<p>
|
||||
{active
|
||||
? `Начата ${new Date(active.started_at).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}. Продолжи журнал подходов.`
|
||||
: "Запусти сессию и добавляй упражнения по ходу тренировки — каталог откроется отдельно."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="catalog-grid workout-catalog-grid">
|
||||
{catalogList?.map((entity) => {
|
||||
const added = addedEntityIds.has(entity.id);
|
||||
return (
|
||||
<article className={`card catalog-card workout-pick-card${added ? " added" : ""}`} key={entity.id}>
|
||||
{entity.image_s3_url ? <img src={entity.image_s3_url} alt="" /> : <div className="image-placeholder">TW</div>}
|
||||
<div>
|
||||
<h3>{entity.name}</h3>
|
||||
<button
|
||||
className={`pick-btn${added ? " picked" : " primary"}`}
|
||||
disabled={added || addItemMutation.isPending}
|
||||
onClick={() => addItemMutation.mutate(entity)}
|
||||
>
|
||||
{added ? "В тренировке" : "Добавить"}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card workout-cart">
|
||||
<h3>Тренировка ({current.items.length} эл., {totalSets} подх.)</h3>
|
||||
{current.items.length === 0 ? (
|
||||
<p className="muted">Добавь упражнения из каталога выше.</p>
|
||||
) : (
|
||||
<div className="cart-items">
|
||||
{current.items.map((item) => (
|
||||
<CartItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
exercises={exercises.data}
|
||||
equipment={equipment.data}
|
||||
expanded={expandedItemId === item.id}
|
||||
onToggle={() => setExpandedItemId(expandedItemId === item.id ? null : item.id)}
|
||||
onRemoveItem={() => removeItemMutation.mutate(item.id)}
|
||||
onAddSets={(sets) => addSetsMutation.mutate({ itemId: item.id, sets })}
|
||||
onRemoveSet={(setId) => removeSetMutation.mutate({ itemId: item.id, setId })}
|
||||
isAddingSet={addSetsMutation.isPending}
|
||||
isRemovingItem={removeItemMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="primary finish-btn"
|
||||
disabled={current.items.length === 0 || finishMutation.isPending}
|
||||
onClick={() => setShowFinishModal(true)}
|
||||
>
|
||||
Завершить тренировку
|
||||
<button className="primary pulse-action" onClick={() => (active ? navigate({ to: "/workout/active" }) : startMutation.mutate())} disabled={startMutation.isPending}>
|
||||
{startMutation.isPending ? "Создание..." : active ? "Продолжить" : "Начать тренировку"}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{showFinishModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowFinishModal(false)}>
|
||||
<div className="modal-card card" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Завершить тренировку?</h2>
|
||||
<div className="modal-stats">
|
||||
<Metric label="Упражнений" value={current.items.length} />
|
||||
<Metric label="Подходов" value={totalSets} />
|
||||
<Metric label="Ккал" value={Math.round(current.estimated_calories)} />
|
||||
</div>
|
||||
<label>
|
||||
Заметки
|
||||
<input value={finishNotes} onChange={(e) => setFinishNotes(e.target.value)} placeholder="Как прошла тренировка?" />
|
||||
</label>
|
||||
<div className="modal-actions">
|
||||
<button className="ghost" onClick={() => setShowFinishModal(false)}>Отмена</button>
|
||||
<button className="primary" disabled={finishMutation.isPending} onClick={() => finishMutation.mutate()}>
|
||||
{finishMutation.isPending ? "Сохранение..." : "Завершить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="stats-grid">
|
||||
<Metric label="Всего тренировок" value={workouts.data?.filter((w) => w.status !== "discarded").length ?? 0} />
|
||||
<Metric label="Калорий" value={Math.round(calories.data?.total_calories ?? 0)} />
|
||||
<Metric label="Последняя" value={latestFinished ? new Date(latestFinished.started_at).toLocaleDateString("ru-RU") : "нет"} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CartItemRow({
|
||||
item,
|
||||
exercises,
|
||||
equipment,
|
||||
expanded,
|
||||
onToggle,
|
||||
onRemoveItem,
|
||||
onAddSets,
|
||||
onRemoveSet,
|
||||
isAddingSet,
|
||||
isRemovingItem,
|
||||
}: {
|
||||
item: WorkoutItemType;
|
||||
exercises: CatalogEntity[] | undefined;
|
||||
equipment: CatalogEntity[] | undefined;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onRemoveItem: () => void;
|
||||
onAddSets: (sets: Array<{ weight: number; reps: number }>) => void;
|
||||
onRemoveSet: (setId: string) => void;
|
||||
isAddingSet: boolean;
|
||||
isRemovingItem: boolean;
|
||||
}) {
|
||||
const entityName = useMemo(() => {
|
||||
if (item.exercise_id) return exercises?.find((e) => e.id === item.exercise_id)?.name ?? "Упражнение";
|
||||
if (item.equipment_id) return equipment?.find((e) => e.id === item.equipment_id)?.name ?? "Тренажер";
|
||||
return "Неизвестно";
|
||||
}, [item.exercise_id, item.equipment_id, exercises, equipment]);
|
||||
const [draftCount, setDraftCount] = useState(3);
|
||||
const [draftSets, setDraftSets] = useState(() =>
|
||||
Array.from({ length: 8 }, () => ({ weight: 60, reps: 8 })),
|
||||
);
|
||||
const rootRoute = createRootRoute({ component: AppShell });
|
||||
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", component: HomePage });
|
||||
const activeWorkoutRoute = createRoute({ getParentRoute: () => rootRoute, path: "/workout/active", component: ActiveWorkoutPage });
|
||||
const catalogExercisesRoute = createRoute({ getParentRoute: () => rootRoute, path: "/catalog/exercises", component: () => <CatalogPage kind="exercise" /> });
|
||||
const catalogEquipmentRoute = createRoute({ getParentRoute: () => rootRoute, path: "/catalog/equipment", component: () => <CatalogPage kind="equipment" /> });
|
||||
const historyRoute = createRoute({ getParentRoute: () => rootRoute, path: "/history", component: HistoryPage });
|
||||
const analyticsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/analytics", component: AnalyticsPage });
|
||||
const workoutDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: "/workouts/$workoutId", component: WorkoutDetailPage });
|
||||
|
||||
function updateDraftSet(index: number, key: "weight" | "reps", value: number) {
|
||||
setDraftSets((sets) => sets.map((set, i) => (i === index ? { ...set, [key]: value } : set)));
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
activeWorkoutRoute,
|
||||
catalogExercisesRoute,
|
||||
catalogEquipmentRoute,
|
||||
historyRoute,
|
||||
analyticsRoute,
|
||||
workoutDetailRoute,
|
||||
]);
|
||||
|
||||
const router = createRouter({ routeTree });
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [auth, setAuth] = useState<AuthState | null>(loadAuth);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function handleAuth(next: AuthState) {
|
||||
saveAuth(next);
|
||||
setAuth(next);
|
||||
}
|
||||
|
||||
function saveDraftSets() {
|
||||
onAddSets(draftSets.slice(0, draftCount));
|
||||
function logout() {
|
||||
clearAuth();
|
||||
queryClient.clear();
|
||||
setAuth(null);
|
||||
}
|
||||
|
||||
if (!auth) {
|
||||
return <AuthScreen onAuth={handleAuth} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`cart-item${expanded ? " expanded" : ""}`}>
|
||||
<div className="cart-item-header" onClick={onToggle}>
|
||||
<div className="cart-item-info">
|
||||
<span className="cart-item-name">{entityName}</span>
|
||||
<span className="cart-item-summary">
|
||||
{item.sets.length > 0 ? `${item.sets.length} × ${item.sets[item.sets.length - 1].weight} кг` : "нет подходов"}
|
||||
</span>
|
||||
</div>
|
||||
<button className="cart-item-remove" disabled={isRemovingItem} onClick={(e) => { e.stopPropagation(); onRemoveItem(); }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="cart-item-body">
|
||||
{item.sets.length > 0 && (
|
||||
<div className="sets-list">
|
||||
{item.sets.map((set) => (
|
||||
<div className="set-row" key={set.id}>
|
||||
<span className="set-index">{set.set_index}</span>
|
||||
<span className="set-data">{set.weight} кг × {set.reps}</span>
|
||||
<span className="set-cal">{set.calories ? Math.round(set.calories) : "—"} ккал</span>
|
||||
<button className="set-remove" onClick={() => onRemoveSet(set.id)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="set-builder">
|
||||
<div className="slider-head">
|
||||
<span>Новые подходы</span>
|
||||
<strong>{draftCount}</strong>
|
||||
</div>
|
||||
<input
|
||||
aria-label="Количество подходов"
|
||||
className="set-count-slider"
|
||||
type="range"
|
||||
min="1"
|
||||
max="8"
|
||||
value={draftCount}
|
||||
onChange={(e) => setDraftCount(Number(e.target.value))}
|
||||
/>
|
||||
<div className="draft-sets">
|
||||
{draftSets.slice(0, draftCount).map((set, index) => (
|
||||
<div className="draft-set-row" key={index}>
|
||||
<span>{index + 1}</span>
|
||||
<label>
|
||||
Вес
|
||||
<input
|
||||
type="number"
|
||||
value={set.weight}
|
||||
onChange={(e) => updateDraftSet(index, "weight", Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Повторы
|
||||
<input
|
||||
type="number"
|
||||
value={set.reps}
|
||||
onChange={(e) => updateDraftSet(index, "reps", Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="primary add-sets-btn" disabled={isAddingSet} onClick={saveDraftSets}>
|
||||
{isAddingSet ? "Записываю..." : `Записать ${draftCount} подх.`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function History({ token }: { token: string }) {
|
||||
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header"><div><p className="eyebrow">History</p><h2>История</h2></div></div>
|
||||
{workouts.data?.map((workout) => <WorkoutSummary workout={workout} key={workout.id} />)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Analytics({ token }: { token: string }) {
|
||||
const { exercises } = useCatalog(token);
|
||||
const [exerciseId, setExerciseId] = useState("");
|
||||
const progression = useQuery({
|
||||
queryKey: ["progression", exerciseId],
|
||||
queryFn: () => api.progression(token, "exercise", exerciseId || undefined),
|
||||
});
|
||||
const calories = useQuery({ queryKey: ["calories"], queryFn: () => api.calories(token) });
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header"><div><p className="eyebrow">Analytics</p><h2>Прогрессия и калораж</h2></div></div>
|
||||
<section className="card form-grid">
|
||||
<label>
|
||||
Упражнение
|
||||
<select value={exerciseId} onChange={(event) => setExerciseId(event.target.value)}>
|
||||
<option value="">Все упражнения</option>
|
||||
{exercises.data?.map((exercise) => <option key={exercise.id} value={exercise.id}>{exercise.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<Metric label="Последний вес" value={progression.data?.last_weight ?? "нет"} />
|
||||
<Metric label="Максимальный вес" value={progression.data?.max_weight ?? "нет"} />
|
||||
<Metric label="Калорий всего" value={Math.round(calories.data?.total_calories ?? 0)} />
|
||||
</section>
|
||||
<section className="card chart-card">
|
||||
<h3>Объем по датам</h3>
|
||||
<div className="bars">
|
||||
{progression.data?.points.map((point) => (
|
||||
<div className="bar-row" key={point.date}>
|
||||
<span>{point.date}</span>
|
||||
<div><i style={{ width: `${Math.min(point.volume / 20, 100)}%` }} /></div>
|
||||
<b>{Math.round(point.volume)}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogCard({ entity }: { entity: CatalogEntity }) {
|
||||
return (
|
||||
<article className="card catalog-card">
|
||||
{entity.image_s3_url ? <img src={entity.image_s3_url} alt="" /> : <div className="image-placeholder">TW</div>}
|
||||
<div>
|
||||
<span className={entity.is_builtin ? "pill" : "pill user"}>{entity.is_builtin ? "стандартное" : "мое"}</span>
|
||||
<h3>{entity.name}</h3>
|
||||
<p>{entity.description || "Без описания"}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkoutSummary({ workout }: { workout: Workout }) {
|
||||
const setCount = useMemo(() => workout.items.reduce((total, item) => total + item.sets.length, 0), [workout.items]);
|
||||
return (
|
||||
<section className="card workout-card">
|
||||
<div>
|
||||
<h3>{new Date(workout.started_at).toLocaleString("ru-RU")}</h3>
|
||||
<p className="muted">{workout.notes || "Без заметок"}</p>
|
||||
</div>
|
||||
<div className="workout-stats">
|
||||
<Metric label="Элементов" value={workout.items.length} />
|
||||
<Metric label="Подходов" value={setCount} />
|
||||
<Metric label="Ккал" value={Math.round(workout.estimated_calories ?? 0)} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="metric">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
<AuthProvider auth={auth} logout={logout}>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+59
-17
@@ -1,4 +1,14 @@
|
||||
import type { Calories, CatalogEntity, Progression, User, Workout, WorkoutItem, WorkoutSet } from "./types";
|
||||
import type {
|
||||
Calories,
|
||||
CatalogEntity,
|
||||
CatalogKind,
|
||||
Progression,
|
||||
User,
|
||||
Workout,
|
||||
WorkoutItem,
|
||||
WorkoutSet,
|
||||
WorkoutSetInput,
|
||||
} from "./types";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
|
||||
@@ -7,9 +17,19 @@ export type AuthState = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}, token?: string): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
if (!(options.body instanceof FormData)) {
|
||||
if (!(options.body instanceof FormData) && options.body !== undefined) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (token) {
|
||||
@@ -19,7 +39,8 @@ async function request<T>(path: string, options: RequestInit = {}, token?: strin
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail));
|
||||
const detail = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail);
|
||||
throw new ApiError(detail, response.status);
|
||||
}
|
||||
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
||||
return undefined as T;
|
||||
@@ -29,11 +50,17 @@ async function request<T>(path: string, options: RequestInit = {}, token?: strin
|
||||
|
||||
export const api = {
|
||||
async register(payload: { email: string; password: string; display_name: string }): Promise<AuthState> {
|
||||
const raw = await request<{ access_token: string; user: User }>("/auth/register", { method: "POST", body: JSON.stringify(payload) });
|
||||
const raw = await request<{ access_token: string; user: User }>(
|
||||
"/auth/register",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
);
|
||||
return { accessToken: raw.access_token, user: raw.user };
|
||||
},
|
||||
async login(payload: { email: string; password: string }): Promise<AuthState> {
|
||||
const raw = await request<{ access_token: string; user: User }>("/auth/login", { method: "POST", body: JSON.stringify(payload) });
|
||||
const raw = await request<{ access_token: string; user: User }>(
|
||||
"/auth/login",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
);
|
||||
return { accessToken: raw.access_token, user: raw.user };
|
||||
},
|
||||
me(token: string) {
|
||||
@@ -45,7 +72,7 @@ export const api = {
|
||||
exercises(token: string) {
|
||||
return request<CatalogEntity[]>("/catalog/exercises", {}, token);
|
||||
},
|
||||
uploadImage(token: string, entityType: "equipment" | "exercise", file: File) {
|
||||
uploadImage(token: string, entityType: CatalogKind, file: File) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return request<{ image_s3_url: string; image_s3_key: string }>(
|
||||
@@ -63,31 +90,46 @@ export const api = {
|
||||
workouts(token: string) {
|
||||
return request<Workout[]>("/workouts", {}, token);
|
||||
},
|
||||
createWorkout(token: string, notes?: string) {
|
||||
return request<Workout>("/workouts", { method: "POST", body: JSON.stringify({ notes }) }, token);
|
||||
activeWorkout(token: string) {
|
||||
return request<Workout | null>("/workouts/active", {}, token);
|
||||
},
|
||||
getWorkout(token: string, workoutId: string) {
|
||||
return request<Workout>(`/workouts/${workoutId}`, {}, token);
|
||||
},
|
||||
createWorkout(token: string) {
|
||||
return request<Workout>("/workouts", { method: "POST", body: JSON.stringify({}) }, token);
|
||||
},
|
||||
updateWorkout(token: string, workoutId: string, payload: Partial<Workout>) {
|
||||
return request<Workout>(`/workouts/${workoutId}`, { method: "PATCH", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
addWorkoutItem(token: string, workoutId: string, payload: Partial<WorkoutItem>) {
|
||||
finishWorkout(token: string, workoutId: string, notes?: string) {
|
||||
return request<Workout>(`/workouts/${workoutId}/finish`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(notes ? { notes } : {}),
|
||||
}, token);
|
||||
},
|
||||
discardWorkout(token: string, workoutId: string) {
|
||||
return request<Workout>(`/workouts/${workoutId}/discard`, { method: "POST" }, token);
|
||||
},
|
||||
addWorkoutItem(token: string, workoutId: string, payload: { exercise_id?: string | null; equipment_id?: string | null }) {
|
||||
return request<WorkoutItem>(`/workouts/${workoutId}/items`, { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
addWorkoutSet(token: string, itemId: string, payload: Partial<WorkoutSet>) {
|
||||
addWorkoutSet(token: string, itemId: string, payload: WorkoutSetInput) {
|
||||
return request<WorkoutSet>(`/workout-items/${itemId}/sets`, { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
addWorkoutSetBatch(token: string, itemId: string, payload: { sets: WorkoutSetInput[] }) {
|
||||
return request<WorkoutSet[]>(`/workout-items/${itemId}/sets/batch`, { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
updateWorkoutSet(token: string, setId: string, payload: Partial<WorkoutSetInput>) {
|
||||
return request<WorkoutSet>(`/workout-sets/${setId}`, { method: "PATCH", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
removeWorkoutItem(token: string, itemId: string) {
|
||||
return request<void>(`/workout-items/${itemId}`, { method: "DELETE" }, token);
|
||||
},
|
||||
removeWorkoutSet(token: string, itemId: string, setId: string) {
|
||||
return request<void>(`/workout-items/${itemId}/sets/${setId}`, { method: "DELETE" }, token);
|
||||
},
|
||||
finishWorkout(token: string, workoutId: string, notes?: string) {
|
||||
return request<Workout>(`/workouts/${workoutId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ finished_at: new Date().toISOString(), notes }),
|
||||
}, token);
|
||||
},
|
||||
progression(token: string, kind: "exercise" | "equipment", entityId?: string) {
|
||||
progression(token: string, kind: CatalogKind, entityId?: string) {
|
||||
const params = new URLSearchParams({ kind });
|
||||
if (entityId) params.set("entity_id", entityId);
|
||||
return request<Progression>(`/analytics/progression?${params.toString()}`, {}, token);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "../../api";
|
||||
import { Metric } from "../../shared/Metric";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useCatalog } from "../catalog/hooks";
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const { auth } = useAuth();
|
||||
const token = auth.accessToken;
|
||||
const { exercises } = useCatalog(token);
|
||||
const [exerciseId, setExerciseId] = useState("");
|
||||
const progression = useQuery({
|
||||
queryKey: ["progression", exerciseId],
|
||||
queryFn: () => api.progression(token, "exercise", exerciseId || undefined),
|
||||
});
|
||||
const calories = useQuery({ queryKey: ["calories"], queryFn: () => api.calories(token) });
|
||||
|
||||
const maxVolume = Math.max(...(progression.data?.points.map((point) => point.volume) ?? [1]), 1);
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Analytics</p>
|
||||
<h2>Прогрессия и калораж</h2>
|
||||
</div>
|
||||
</div>
|
||||
<section className="card analytics-controls">
|
||||
<label>
|
||||
Упражнение
|
||||
<select value={exerciseId} onChange={(event) => setExerciseId(event.target.value)}>
|
||||
<option value="">Все упражнения</option>
|
||||
{exercises.data?.map((exercise) => <option key={exercise.id} value={exercise.id}>{exercise.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<Metric label="Последний вес" value={progression.data?.last_weight ?? "нет"} />
|
||||
<Metric label="Максимальный вес" value={progression.data?.max_weight ?? "нет"} />
|
||||
<Metric label="Калорий всего" value={Math.round(calories.data?.total_calories ?? 0)} />
|
||||
</section>
|
||||
<section className="card chart-card">
|
||||
<h3>Объем по датам</h3>
|
||||
<div className="bars">
|
||||
{progression.data?.points.map((point) => (
|
||||
<div className="bar-row" key={point.date}>
|
||||
<span>{point.date}</span>
|
||||
<div><i style={{ width: `${Math.max(4, (point.volume / maxVolume) * 100)}%` }} /></div>
|
||||
<b>{Math.round(point.volume)}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
|
||||
import type { AuthState } from "../../api";
|
||||
|
||||
const AuthContext = createContext<{
|
||||
auth: AuthState;
|
||||
logout: () => void;
|
||||
} | null>(null);
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used inside AuthContext");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function AuthProvider({ auth, logout, children }: { auth: AuthState; logout: () => void; children: ReactNode }) {
|
||||
const value = useMemo(() => ({ auth, logout }), [auth, logout]);
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { FormEvent } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api, type AuthState } from "../../api";
|
||||
|
||||
export function AuthScreen({ onAuth }: { onAuth: (auth: AuthState) => void }) {
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [email, setEmail] = useState("demo@example.com");
|
||||
const [password, setPassword] = useState("password123");
|
||||
const [displayName, setDisplayName] = useState("Demo Athlete");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
mode === "login"
|
||||
? api.login({ email, password })
|
||||
: api.register({ email, password, display_name: displayName }),
|
||||
onSuccess: onAuth,
|
||||
onError: (err) => setError(err.message),
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-layout">
|
||||
<section className="hero-panel" aria-label="Train Watcher intro">
|
||||
<p className="eyebrow">Live workout console</p>
|
||||
<h1>Один экран. Один подход. Весь прогресс под рукой.</h1>
|
||||
<p>
|
||||
Train Watcher теперь работает как живой журнал: стартуй сессию, добавляй упражнения через drawer и фиксируй каждый подход без ухода в каталог.
|
||||
</p>
|
||||
</section>
|
||||
<form className="card auth-card" onSubmit={submit}>
|
||||
<p className="eyebrow">{mode === "login" ? "Welcome back" : "New athlete"}</p>
|
||||
<h2>{mode === "login" ? "Вход" : "Регистрация"}</h2>
|
||||
{mode === "register" && (
|
||||
<label>
|
||||
Имя
|
||||
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Email
|
||||
<input type="email" autoComplete="username" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Пароль
|
||||
<input
|
||||
type="password"
|
||||
autoComplete={mode === "register" ? "new-password" : "current-password"}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button className="primary" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Отправка..." : mode === "login" ? "Войти" : "Создать аккаунт"}
|
||||
</button>
|
||||
<button type="button" className="ghost" onClick={() => setMode(mode === "login" ? "register" : "login")}>
|
||||
{mode === "login" ? "Нужна регистрация" : "Уже есть аккаунт"}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AuthState } from "../../api";
|
||||
|
||||
const authStorageKey = "train-watcher-auth";
|
||||
|
||||
export function loadAuth(): AuthState | null {
|
||||
const raw = localStorage.getItem(authStorageKey);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as AuthState;
|
||||
} catch {
|
||||
localStorage.removeItem(authStorageKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAuth(auth: AuthState) {
|
||||
localStorage.setItem(authStorageKey, JSON.stringify(auth));
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem(authStorageKey);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { CatalogEntity } from "../../types";
|
||||
|
||||
export function CatalogCard({ entity, action }: { entity: CatalogEntity; action?: ReactNode }) {
|
||||
return (
|
||||
<article className="card catalog-card">
|
||||
{entity.image_s3_url ? <img src={entity.image_s3_url} alt="" /> : <div className="image-placeholder">TW</div>}
|
||||
<div>
|
||||
<span className={entity.is_builtin ? "pill" : "pill user"}>{entity.is_builtin ? "стандартное" : "мое"}</span>
|
||||
<h3>{entity.name}</h3>
|
||||
<p>{entity.description || "Без описания"}</p>
|
||||
{action}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { FormEvent } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { api } from "../../api";
|
||||
import type { CatalogKind } from "../../types";
|
||||
import { CatalogCard } from "./CatalogCard";
|
||||
import { useCatalog } from "./hooks";
|
||||
|
||||
export function CatalogPage({ kind }: { kind: CatalogKind }) {
|
||||
const { auth } = useAuth();
|
||||
const token = auth.accessToken;
|
||||
const queryClient = useQueryClient();
|
||||
const { equipment, exercises } = useCatalog(token);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const list = useMemo(() => (kind === "exercise" ? exercises.data : equipment.data) ?? [], [kind, exercises.data, equipment.data]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const image = file ? await api.uploadImage(token, kind, file) : {};
|
||||
const payload = { name, description, ...image };
|
||||
return kind === "equipment" ? api.createEquipment(token, payload) : api.createExercise(token, payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setFile(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["catalog", kind === "equipment" ? "equipment" : "exercises"] });
|
||||
},
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
createMutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Catalog</p>
|
||||
<h2>{kind === "exercise" ? "Упражнения" : "Тренажеры"}</h2>
|
||||
</div>
|
||||
<div className="segmented">
|
||||
<Link to="/catalog/exercises" activeProps={{ className: "active" }}>Упражнения</Link>
|
||||
<Link to="/catalog/equipment" activeProps={{ className: "active" }}>Тренажеры</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="card form-grid" onSubmit={submit}>
|
||||
<label>
|
||||
Название
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Описание
|
||||
<input value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Картинка
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
</label>
|
||||
<button className="primary" disabled={createMutation.isPending}>{createMutation.isPending ? "Сохранение..." : "Добавить"}</button>
|
||||
{createMutation.error && <p className="error">{createMutation.error.message}</p>}
|
||||
</form>
|
||||
|
||||
<div className="catalog-grid">
|
||||
{list.map((entity) => <CatalogCard entity={entity} key={entity.id} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "../../api";
|
||||
|
||||
export function useCatalog(token: string) {
|
||||
const equipment = useQuery({ queryKey: ["catalog", "equipment"], queryFn: () => api.equipment(token) });
|
||||
const exercises = useQuery({ queryKey: ["catalog", "exercises"], queryFn: () => api.exercises(token) });
|
||||
return { equipment, exercises };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "../../api";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { WorkoutSummary } from "./WorkoutSummary";
|
||||
|
||||
export function HistoryPage() {
|
||||
const { auth } = useAuth();
|
||||
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(auth.accessToken) });
|
||||
const visible = workouts.data?.filter((workout) => workout.status !== "discarded") ?? [];
|
||||
const discarded = workouts.data?.filter((workout) => workout.status === "discarded") ?? [];
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">History</p>
|
||||
<h2>История тренировок</h2>
|
||||
</div>
|
||||
</div>
|
||||
{visible.length === 0 && <section className="card empty-state">История пока пустая.</section>}
|
||||
{visible.map((workout) => <WorkoutSummary workout={workout} key={workout.id} />)}
|
||||
{discarded.length > 0 && (
|
||||
<details className="discarded-list card">
|
||||
<summary>Отмененные тренировки ({discarded.length})</summary>
|
||||
<div className="stack compact-stack">
|
||||
{discarded.map((workout) => <WorkoutSummary workout={workout} key={workout.id} />)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "../../api";
|
||||
import { Metric } from "../../shared/Metric";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
export function WorkoutDetailPage() {
|
||||
const { workoutId } = useParams({ from: "/workouts/$workoutId" });
|
||||
const { auth } = useAuth();
|
||||
const workout = useQuery({ queryKey: ["workouts", workoutId], queryFn: () => api.getWorkout(auth.accessToken, workoutId) });
|
||||
|
||||
if (workout.isLoading) return <section className="card loading-card">Загружаю тренировку...</section>;
|
||||
if (!workout.data) return <section className="card empty-state">Тренировка не найдена.</section>;
|
||||
|
||||
const data = workout.data;
|
||||
return (
|
||||
<section className="stack workout-detail">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Workout detail</p>
|
||||
<h2>{new Date(data.started_at).toLocaleDateString("ru-RU")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<section className="card detail-hero">
|
||||
<Metric label="Статус" value={data.status} />
|
||||
<Metric label="Упражнения" value={data.items.length} />
|
||||
<Metric label="Подходы" value={data.total_sets} />
|
||||
<Metric label="Объем" value={`${Math.round(data.total_volume)} кг`} />
|
||||
<Metric label="Ккал" value={`~${Math.round(data.estimated_calories)}`} />
|
||||
</section>
|
||||
{data.notes && <section className="card notes-card"><h3>Заметки</h3><p>{data.notes}</p></section>}
|
||||
{data.items.map((item) => (
|
||||
<section className="card detail-item" key={item.id}>
|
||||
<h3>{item.title_snapshot}</h3>
|
||||
<div className="sets-list">
|
||||
{item.sets.map((set) => (
|
||||
<div className="set-row" key={set.id}>
|
||||
<span className="set-index">{set.set_index}</span>
|
||||
<span className="set-data">{set.weight} кг × {set.reps}</span>
|
||||
<span className="set-cal">{set.calories ? Math.round(set.calories) : "—"} ккал</span>
|
||||
<span />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
import type { Workout } from "../../types";
|
||||
import { Metric } from "../../shared/Metric";
|
||||
|
||||
function statusLabel(status: Workout["status"]) {
|
||||
return status === "finished" ? "завершена" : status === "discarded" ? "отменена" : "активная";
|
||||
}
|
||||
|
||||
export function WorkoutSummary({ workout }: { workout: Workout }) {
|
||||
const duration = workout.finished_at
|
||||
? Math.round((new Date(workout.finished_at).getTime() - new Date(workout.started_at).getTime()) / 60000)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className={`card workout-summary status-${workout.status}`}>
|
||||
<div className="summary-title">
|
||||
<div>
|
||||
<span className="status-chip">{statusLabel(workout.status)}</span>
|
||||
<h3>{new Date(workout.started_at).toLocaleString("ru-RU")}</h3>
|
||||
<p>{workout.notes || "Без заметок"}</p>
|
||||
</div>
|
||||
<Link to="/workouts/$workoutId" params={{ workoutId: workout.id }} className="ghost detail-link">Детали</Link>
|
||||
</div>
|
||||
<div className="workout-stats">
|
||||
<Metric label="Длительность" value={duration === null ? "идет" : `${duration} мин`} />
|
||||
<Metric label="Упражнения" value={workout.items.length} />
|
||||
<Metric label="Подходы" value={workout.total_sets} />
|
||||
<Metric label="Объем" value={`${Math.round(workout.total_volume)} кг`} />
|
||||
<Metric label="Ккал" value={`~${Math.round(workout.estimated_calories ?? 0)}`} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import type { CatalogEntity, CatalogKind, WorkoutSetInput } from "../../types";
|
||||
import { Metric } from "../../shared/Metric";
|
||||
import { AddExerciseDrawer } from "./AddExerciseDrawer";
|
||||
import { EmptyWorkoutState } from "./EmptyWorkoutState";
|
||||
import { FinishWorkoutDialog } from "./FinishWorkoutDialog";
|
||||
import { useActiveWorkout, useWorkoutMutations } from "./hooks";
|
||||
import { WorkoutExerciseCard } from "./WorkoutExerciseCard";
|
||||
|
||||
function useElapsed(startedAt?: string) {
|
||||
const [elapsed, setElapsed] = useState("00:00");
|
||||
|
||||
useEffect(() => {
|
||||
if (!startedAt) return;
|
||||
const started = new Date(startedAt).getTime();
|
||||
const tick = () => {
|
||||
const diff = Math.max(0, Date.now() - started);
|
||||
const totalSeconds = Math.floor(diff / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
setElapsed(hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` : `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`);
|
||||
};
|
||||
tick();
|
||||
const intervalId = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [startedAt]);
|
||||
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
export function ActiveWorkoutPage() {
|
||||
const { auth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const activeWorkout = useActiveWorkout();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [finishOpen, setFinishOpen] = useState(false);
|
||||
const [discardOpen, setDiscardOpen] = useState(false);
|
||||
const workout = activeWorkout.data;
|
||||
const elapsed = useElapsed(workout?.started_at);
|
||||
const mutations = useWorkoutMutations({
|
||||
onStartConflict: () => void navigate({ to: "/workout/active" }),
|
||||
onFinish: () => void navigate({ to: "/history" }),
|
||||
onDiscard: () => void navigate({ to: "/" }),
|
||||
});
|
||||
|
||||
const sortedItems = useMemo(() => [...(workout?.items ?? [])].sort((a, b) => a.order_index - b.order_index), [workout?.items]);
|
||||
|
||||
function addFromDrawer(entity: CatalogEntity, kind: CatalogKind, closeAfter: boolean) {
|
||||
if (!workout) return;
|
||||
mutations.addWorkoutItem.mutate(
|
||||
{ workoutId: workout.id, sourceId: entity.id, kind },
|
||||
{ onSuccess: () => { if (closeAfter) setDrawerOpen(false); } },
|
||||
);
|
||||
}
|
||||
|
||||
if (activeWorkout.isLoading) {
|
||||
return <section className="card loading-card">Загружаю активную тренировку...</section>;
|
||||
}
|
||||
|
||||
if (!workout) {
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Active Workout</p>
|
||||
<h2>Живой журнал</h2>
|
||||
</div>
|
||||
</div>
|
||||
<section className="card workout-cta">
|
||||
<h3>Нет активной тренировки</h3>
|
||||
<p>Новая сессия создаст защищенный active workout. Если сессия уже существует, приложение восстановит ее после обновления.</p>
|
||||
<button className="primary" disabled={mutations.startWorkout.isPending} onClick={() => mutations.startWorkout.mutate()}>
|
||||
{mutations.startWorkout.isPending ? "Создание..." : "Начать тренировку"}
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const anyPending =
|
||||
mutations.addWorkoutItem.isPending ||
|
||||
mutations.recordWorkoutSet.isPending ||
|
||||
mutations.recordWorkoutSetsBatch.isPending ||
|
||||
mutations.removeWorkoutItem.isPending ||
|
||||
mutations.removeWorkoutSet.isPending ||
|
||||
mutations.updateWorkoutSet.isPending;
|
||||
|
||||
return (
|
||||
<section className="stack workout-page">
|
||||
<div className="live-header card">
|
||||
<div>
|
||||
<p className="eyebrow">Active Workout</p>
|
||||
<h2>Живой журнал</h2>
|
||||
<span>Старт: {new Date(workout.started_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "short" })}</span>
|
||||
</div>
|
||||
<div className="live-clock" aria-label="Прошло времени">{elapsed}</div>
|
||||
<div className="live-metrics">
|
||||
<Metric label="Подходы" value={workout.total_sets} tone="dark" />
|
||||
<Metric label="Объем" value={`${Math.round(workout.total_volume)} кг`} tone="dark" />
|
||||
<Metric label="Ккал" value={`~${Math.round(workout.estimated_calories)}`} tone="dark" />
|
||||
</div>
|
||||
<div className="live-actions">
|
||||
<button className="primary" onClick={() => setDrawerOpen(true)}>+ Добавить упражнение</button>
|
||||
<button className="ghost" onClick={() => setFinishOpen(true)}>Завершить</button>
|
||||
<button className="danger-ghost" onClick={() => setDiscardOpen(true)}>Отменить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortedItems.length === 0 ? (
|
||||
<EmptyWorkoutState onOpenDrawer={() => setDrawerOpen(true)} />
|
||||
) : (
|
||||
<div className="exercise-journal">
|
||||
{sortedItems.map((item) => (
|
||||
<WorkoutExerciseCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
pending={anyPending}
|
||||
onRecordSet={(payload: WorkoutSetInput) => mutations.recordWorkoutSet.mutate({ itemId: item.id, payload })}
|
||||
onRecordBatch={(sets) => mutations.recordWorkoutSetsBatch.mutate({ itemId: item.id, sets })}
|
||||
onRemoveItem={() => mutations.removeWorkoutItem.mutate(item.id)}
|
||||
onRemoveSet={(setId) => mutations.removeWorkoutSet.mutate({ itemId: item.id, setId })}
|
||||
onUpdateSet={(setId, payload) => mutations.updateWorkoutSet.mutate({ setId, payload })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="primary sticky-add" onClick={() => setDrawerOpen(true)}>+ Добавить упражнение</button>
|
||||
|
||||
<AddExerciseDrawer
|
||||
open={drawerOpen}
|
||||
token={auth.accessToken}
|
||||
workout={workout}
|
||||
pending={mutations.addWorkoutItem.isPending}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onAdd={addFromDrawer}
|
||||
/>
|
||||
<FinishWorkoutDialog
|
||||
open={finishOpen}
|
||||
workout={workout}
|
||||
pending={mutations.finishWorkout.isPending}
|
||||
onClose={() => setFinishOpen(false)}
|
||||
onFinish={(notes) => mutations.finishWorkout.mutate({ workoutId: workout.id, notes })}
|
||||
/>
|
||||
{discardOpen && (
|
||||
<div className="dialog-backdrop" onClick={() => setDiscardOpen(false)}>
|
||||
<div className="modal-card discard-dialog card" onClick={(event) => event.stopPropagation()}>
|
||||
<p className="eyebrow">Danger command</p>
|
||||
<h2>Отменить тренировку?</h2>
|
||||
<p>Запись будет помечена как отмененная и исчезнет из основной аналитики. Подходы не удаляются физически.</p>
|
||||
<div className="modal-actions">
|
||||
<button className="ghost" onClick={() => setDiscardOpen(false)}>Назад</button>
|
||||
<button className="danger" disabled={mutations.discardWorkout.isPending} onClick={() => mutations.discardWorkout.mutate(workout.id)}>
|
||||
{mutations.discardWorkout.isPending ? "Отмена..." : "Отменить тренировку"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { CatalogEntity, CatalogKind, Workout } from "../../types";
|
||||
import { useCatalog } from "../catalog/hooks";
|
||||
|
||||
export function AddExerciseDrawer({
|
||||
open,
|
||||
token,
|
||||
workout,
|
||||
onClose,
|
||||
onAdd,
|
||||
pending,
|
||||
}: {
|
||||
open: boolean;
|
||||
token: string;
|
||||
workout: Workout;
|
||||
onClose: () => void;
|
||||
onAdd: (entity: CatalogEntity, kind: CatalogKind, closeAfter: boolean) => void;
|
||||
pending?: boolean;
|
||||
}) {
|
||||
const [kind, setKind] = useState<CatalogKind>("exercise");
|
||||
const [search, setSearch] = useState("");
|
||||
const { exercises, equipment } = useCatalog(token);
|
||||
|
||||
const addedIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
workout.items.forEach((item) => {
|
||||
if (item.exercise_id) ids.add(item.exercise_id);
|
||||
if (item.equipment_id) ids.add(item.equipment_id);
|
||||
});
|
||||
return ids;
|
||||
}, [workout.items]);
|
||||
|
||||
const list = useMemo(() => {
|
||||
const source = kind === "exercise" ? exercises.data ?? [] : equipment.data ?? [];
|
||||
const needle = search.trim().toLowerCase();
|
||||
return needle ? source.filter((entity) => entity.name.toLowerCase().includes(needle)) : source;
|
||||
}, [kind, search, exercises.data, equipment.data]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="drawer-scrim" onClick={onClose}>
|
||||
<aside className="add-drawer" aria-label="Добавить в тренировку" onClick={(event) => event.stopPropagation()}>
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">Catalog drawer</p>
|
||||
<h2>Добавить в тренировку</h2>
|
||||
</div>
|
||||
<button className="round-close" onClick={onClose} aria-label="Закрыть">×</button>
|
||||
</header>
|
||||
|
||||
<input className="drawer-search" placeholder="Поиск упражнения или тренажера" value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||
<div className="segmented drawer-tabs">
|
||||
<button className={kind === "exercise" ? "active" : ""} onClick={() => setKind("exercise")}>Упражнения</button>
|
||||
<button className={kind === "equipment" ? "active" : ""} onClick={() => setKind("equipment")}>Тренажеры</button>
|
||||
</div>
|
||||
|
||||
<div className="drawer-list">
|
||||
{list.map((entity) => {
|
||||
const added = addedIds.has(entity.id);
|
||||
return (
|
||||
<article className={`drawer-pick ${added ? "already-added" : ""}`} key={`${kind}-${entity.id}`} onClick={() => onAdd(entity, kind, true)}>
|
||||
{entity.image_s3_url ? <img src={entity.image_s3_url} alt="" /> : <span className="drawer-placeholder">{kind === "exercise" ? "EX" : "EQ"}</span>}
|
||||
<div>
|
||||
<h3>{entity.name}</h3>
|
||||
<p>{entity.description || "Без описания"}</p>
|
||||
{added && <b>В тренировке</b>}
|
||||
</div>
|
||||
<button
|
||||
className="plus-chip"
|
||||
disabled={pending}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAdd(entity, kind, false);
|
||||
}}
|
||||
aria-label="Добавить и оставить drawer открытым"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{list.length === 0 && <p className="muted">Ничего не найдено.</p>}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function EmptyWorkoutState({ onOpenDrawer }: { onOpenDrawer: () => void }) {
|
||||
return (
|
||||
<section className="empty-workout card">
|
||||
<p className="eyebrow">Журнал пуст</p>
|
||||
<h3>Добавь первое упражнение</h3>
|
||||
<p>Каталог теперь открывается отдельным drawer, поэтому основной экран остается чистым журналом выполнения.</p>
|
||||
<button className="primary" onClick={onOpenDrawer}>+ Добавить упражнение</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Workout } from "../../types";
|
||||
import { Metric } from "../../shared/Metric";
|
||||
|
||||
function minutesBetween(start: string, end = new Date().toISOString()) {
|
||||
return Math.max(0, Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000));
|
||||
}
|
||||
|
||||
export function FinishWorkoutDialog({
|
||||
workout,
|
||||
open,
|
||||
pending,
|
||||
onClose,
|
||||
onFinish,
|
||||
}: {
|
||||
workout: Workout;
|
||||
open: boolean;
|
||||
pending?: boolean;
|
||||
onClose: () => void;
|
||||
onFinish: (notes?: string) => void;
|
||||
}) {
|
||||
const [notes, setNotes] = useState(workout.notes ?? "");
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="dialog-backdrop" onClick={onClose}>
|
||||
<div className="modal-card finish-dialog card" onClick={(event) => event.stopPropagation()}>
|
||||
<p className="eyebrow">Workout summary</p>
|
||||
<h2>Итог тренировки</h2>
|
||||
<div className="modal-stats">
|
||||
<Metric label="Длительность" value={`${minutesBetween(workout.started_at)} мин`} />
|
||||
<Metric label="Упражнения" value={workout.items.length} />
|
||||
<Metric label="Подходы" value={workout.total_sets} />
|
||||
<Metric label="Объем" value={`${Math.round(workout.total_volume)} кг`} />
|
||||
<Metric label="Калории" value={`~${Math.round(workout.estimated_calories)}`} />
|
||||
</div>
|
||||
<label>
|
||||
Заметка
|
||||
<textarea value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Сегодня хорошо пошёл жим..." />
|
||||
</label>
|
||||
<div className="modal-actions">
|
||||
<button className="ghost" onClick={onClose}>Продолжить</button>
|
||||
<button className="primary" disabled={pending} onClick={() => onFinish(notes || undefined)}>
|
||||
{pending ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { WorkoutItem, WorkoutSetInput } from "../../types";
|
||||
import { WorkoutSetRow } from "./WorkoutSetRow";
|
||||
|
||||
function initialDraft(item: WorkoutItem) {
|
||||
const last = item.sets[item.sets.length - 1];
|
||||
return {
|
||||
weight: last?.weight ?? item.planned_working_weight ?? 0,
|
||||
reps: last?.reps ?? 8,
|
||||
};
|
||||
}
|
||||
|
||||
export function WorkoutExerciseCard({
|
||||
item,
|
||||
onRecordSet,
|
||||
onRecordBatch,
|
||||
onRemoveItem,
|
||||
onRemoveSet,
|
||||
onUpdateSet,
|
||||
pending,
|
||||
}: {
|
||||
item: WorkoutItem;
|
||||
onRecordSet: (payload: WorkoutSetInput) => void;
|
||||
onRecordBatch: (sets: WorkoutSetInput[]) => void;
|
||||
onRemoveItem: () => void;
|
||||
onRemoveSet: (setId: string) => void;
|
||||
onUpdateSet: (setId: string, payload: Partial<WorkoutSetInput>) => void;
|
||||
pending?: boolean;
|
||||
}) {
|
||||
const seed = useMemo(() => initialDraft(item), [item]);
|
||||
const [weight, setWeight] = useState(seed.weight);
|
||||
const [reps, setReps] = useState(seed.reps);
|
||||
const [showBatch, setShowBatch] = useState(false);
|
||||
const [batchCount, setBatchCount] = useState(3);
|
||||
const [batchWeight, setBatchWeight] = useState(seed.weight);
|
||||
const [batchReps, setBatchReps] = useState(seed.reps);
|
||||
|
||||
useEffect(() => {
|
||||
setWeight(seed.weight);
|
||||
setReps(seed.reps);
|
||||
setBatchWeight(seed.weight);
|
||||
setBatchReps(seed.reps);
|
||||
}, [item.id, seed.weight, seed.reps]);
|
||||
|
||||
const volume = item.sets.reduce((sum, set) => sum + set.weight * set.reps, 0);
|
||||
const lastSet = item.sets[item.sets.length - 1];
|
||||
|
||||
function stepWeight(delta: number) {
|
||||
setWeight((value) => Math.max(0, Number((value + delta).toFixed(1))));
|
||||
}
|
||||
|
||||
function stepReps(delta: number) {
|
||||
setReps((value) => Math.max(0, value + delta));
|
||||
}
|
||||
|
||||
function recordBatch() {
|
||||
onRecordBatch(Array.from({ length: batchCount }, () => ({ weight: batchWeight, reps: batchReps })));
|
||||
setShowBatch(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="workout-exercise-card card">
|
||||
<div className="exercise-card-media">
|
||||
{item.image_s3_url_snapshot ? <img src={item.image_s3_url_snapshot} alt="" /> : <span>{item.source_kind === "exercise" ? "EX" : "EQ"}</span>}
|
||||
</div>
|
||||
<div className="exercise-card-body">
|
||||
<header className="exercise-card-header">
|
||||
<div>
|
||||
<span className="source-pill">{item.source_kind === "exercise" ? "упражнение" : "тренажер"}</span>
|
||||
<h3>{item.title_snapshot}</h3>
|
||||
<p>{lastSet ? `Последний раз: ${lastSet.weight} кг × ${lastSet.reps}` : "Подходов еще нет"}</p>
|
||||
</div>
|
||||
<button className="danger-ghost" disabled={pending} onClick={onRemoveItem}>Удалить</button>
|
||||
</header>
|
||||
|
||||
<div className="exercise-microstats">
|
||||
<span>{item.sets.length} подх.</span>
|
||||
<span>{Math.round(volume)} кг объема</span>
|
||||
<span>рек. {Math.max(weight, item.planned_working_weight ?? weight)} × {reps}</span>
|
||||
</div>
|
||||
|
||||
<div className="sets-list">
|
||||
{item.sets.map((set) => (
|
||||
<WorkoutSetRow
|
||||
key={set.id}
|
||||
set={set}
|
||||
disabled={pending}
|
||||
onRemove={() => onRemoveSet(set.id)}
|
||||
onUpdate={(payload) => onUpdateSet(set.id, payload)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="single-set-console">
|
||||
<div className="stepper-field">
|
||||
<span>Вес</span>
|
||||
<div>
|
||||
<button onClick={() => stepWeight(-2.5)}>-</button>
|
||||
<input aria-label="Вес" type="number" min="0" step="0.5" value={weight} onChange={(event) => setWeight(Number(event.target.value))} />
|
||||
<button onClick={() => stepWeight(2.5)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stepper-field">
|
||||
<span>Повторы</span>
|
||||
<div>
|
||||
<button onClick={() => stepReps(-1)}>-</button>
|
||||
<input aria-label="Повторы" type="number" min="0" step="1" value={reps} onChange={(event) => setReps(Number(event.target.value))} />
|
||||
<button onClick={() => stepReps(1)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className="primary record-set" disabled={pending} onClick={() => onRecordSet({ weight, reps })}>
|
||||
{pending ? "Запись..." : "Записать подход"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button className="ghost batch-link" onClick={() => setShowBatch(true)}>Добавить несколько подходов</button>
|
||||
</div>
|
||||
|
||||
{showBatch && (
|
||||
<div className="dialog-backdrop" onClick={() => setShowBatch(false)}>
|
||||
<div className="modal-card batch-dialog card" onClick={(event) => event.stopPropagation()}>
|
||||
<p className="eyebrow">Batch sets</p>
|
||||
<h2>Добавить несколько подходов</h2>
|
||||
<div className="batch-grid">
|
||||
<label>Кол-во<input type="number" min="1" max="12" value={batchCount} onChange={(event) => setBatchCount(Number(event.target.value))} /></label>
|
||||
<label>Вес<input type="number" min="0" step="0.5" value={batchWeight} onChange={(event) => setBatchWeight(Number(event.target.value))} /></label>
|
||||
<label>Повторы<input type="number" min="0" value={batchReps} onChange={(event) => setBatchReps(Number(event.target.value))} /></label>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="ghost" onClick={() => setShowBatch(false)}>Отмена</button>
|
||||
<button className="primary" disabled={pending} onClick={recordBatch}>Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { WorkoutSet, WorkoutSetInput } from "../../types";
|
||||
|
||||
export function WorkoutSetRow({
|
||||
set,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
disabled,
|
||||
}: {
|
||||
set: WorkoutSet;
|
||||
onRemove: () => void;
|
||||
onUpdate: (payload: Partial<WorkoutSetInput>) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [weight, setWeight] = useState(set.weight);
|
||||
const [reps, setReps] = useState(set.reps);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="set-row editing">
|
||||
<span className="set-index">{set.set_index}</span>
|
||||
<input aria-label="Вес подхода" type="number" min="0" step="0.5" value={weight} onChange={(event) => setWeight(Number(event.target.value))} />
|
||||
<input aria-label="Повторы подхода" type="number" min="0" step="1" value={reps} onChange={(event) => setReps(Number(event.target.value))} />
|
||||
<div className="set-row-actions">
|
||||
<button className="tiny success" disabled={disabled} onClick={() => { onUpdate({ weight, reps }); setEditing(false); }}>OK</button>
|
||||
<button className="tiny" onClick={() => setEditing(false)}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="set-row">
|
||||
<span className="set-index">{set.set_index}</span>
|
||||
<button className="set-data" onClick={() => setEditing(true)} title="Редактировать подход">
|
||||
{set.weight} кг × {set.reps}
|
||||
</button>
|
||||
<span className="set-cal">{set.calories ? Math.round(set.calories) : "—"} ккал</span>
|
||||
<button className="set-remove" disabled={disabled} onClick={onRemove} aria-label="Удалить подход">×</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { api } from "../../api";
|
||||
import type { WorkoutSetInput } from "../../types";
|
||||
|
||||
export const workoutApi = {
|
||||
active: api.activeWorkout,
|
||||
start: api.createWorkout,
|
||||
finish: api.finishWorkout,
|
||||
discard: api.discardWorkout,
|
||||
addItem: api.addWorkoutItem,
|
||||
addSet: api.addWorkoutSet,
|
||||
addSetBatch: (token: string, itemId: string, sets: WorkoutSetInput[]) => api.addWorkoutSetBatch(token, itemId, { sets }),
|
||||
updateSet: api.updateWorkoutSet,
|
||||
removeItem: api.removeWorkoutItem,
|
||||
removeSet: api.removeWorkoutSet,
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ApiError } from "../../api";
|
||||
import type { CatalogKind, WorkoutSetInput } from "../../types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { workoutApi } from "./api";
|
||||
|
||||
export async function invalidateWorkoutQueries(queryClient: QueryClient) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["workout", "active"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workouts"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["calories"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["progression"] }),
|
||||
]);
|
||||
}
|
||||
|
||||
export function useActiveWorkout() {
|
||||
const { auth } = useAuth();
|
||||
return useQuery({ queryKey: ["workout", "active"], queryFn: () => workoutApi.active(auth.accessToken) });
|
||||
}
|
||||
|
||||
export function useWorkoutMutations(options: { onStartConflict?: () => void; onFinish?: () => void; onDiscard?: () => void } = {}) {
|
||||
const { auth } = useAuth();
|
||||
const token = auth.accessToken;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const refresh = () => invalidateWorkoutQueries(queryClient);
|
||||
|
||||
const startWorkout = useMutation({
|
||||
mutationFn: () => workoutApi.start(token),
|
||||
onSuccess: refresh,
|
||||
onError: async (error) => {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
await refresh();
|
||||
options.onStartConflict?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const addWorkoutItem = useMutation({
|
||||
mutationFn: ({ workoutId, sourceId, kind }: { workoutId: string; sourceId: string; kind: CatalogKind }) =>
|
||||
workoutApi.addItem(token, workoutId, {
|
||||
exercise_id: kind === "exercise" ? sourceId : null,
|
||||
equipment_id: kind === "equipment" ? sourceId : null,
|
||||
}),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
|
||||
const recordWorkoutSet = useMutation({
|
||||
mutationFn: ({ itemId, payload }: { itemId: string; payload: WorkoutSetInput }) => workoutApi.addSet(token, itemId, payload),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
|
||||
const recordWorkoutSetsBatch = useMutation({
|
||||
mutationFn: ({ itemId, sets }: { itemId: string; sets: WorkoutSetInput[] }) => workoutApi.addSetBatch(token, itemId, sets),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
|
||||
const removeWorkoutItem = useMutation({
|
||||
mutationFn: (itemId: string) => workoutApi.removeItem(token, itemId),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
|
||||
const removeWorkoutSet = useMutation({
|
||||
mutationFn: ({ itemId, setId }: { itemId: string; setId: string }) => workoutApi.removeSet(token, itemId, setId),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
|
||||
const updateWorkoutSet = useMutation({
|
||||
mutationFn: ({ setId, payload }: { setId: string; payload: Partial<WorkoutSetInput> }) => workoutApi.updateSet(token, setId, payload),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
|
||||
const finishWorkout = useMutation({
|
||||
mutationFn: ({ workoutId, notes }: { workoutId: string; notes?: string }) => workoutApi.finish(token, workoutId, notes),
|
||||
onSuccess: async () => {
|
||||
await refresh();
|
||||
options.onFinish?.();
|
||||
},
|
||||
});
|
||||
|
||||
const discardWorkout = useMutation({
|
||||
mutationFn: (workoutId: string) => workoutApi.discard(token, workoutId),
|
||||
onSuccess: async () => {
|
||||
await refresh();
|
||||
options.onDiscard?.();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
startWorkout,
|
||||
addWorkoutItem,
|
||||
recordWorkoutSet,
|
||||
recordWorkoutSetsBatch,
|
||||
removeWorkoutItem,
|
||||
removeWorkoutSet,
|
||||
updateWorkoutSet,
|
||||
finishWorkout,
|
||||
discardWorkout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CatalogKind, Workout, WorkoutItem, WorkoutSet, WorkoutSetInput } from "../../types";
|
||||
|
||||
export type { CatalogKind, Workout, WorkoutItem, WorkoutSet, WorkoutSetInput };
|
||||
|
||||
export type BatchSetDraft = {
|
||||
count: number;
|
||||
weight: number;
|
||||
reps: number;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Metric({ label, value, tone = "default" }: { label: string; value: ReactNode; tone?: "default" | "dark" | "danger" }) {
|
||||
return (
|
||||
<div className={`metric metric-${tone}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+220
-148
@@ -1,18 +1,23 @@
|
||||
:root {
|
||||
color: #172033;
|
||||
background: #edf1f5;
|
||||
font-family: "Manrope", "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #131821;
|
||||
background: #ece7dc;
|
||||
font-family: "Aptos", "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--ink: #111827;
|
||||
--muted: #64748b;
|
||||
--line: rgba(17, 24, 39, 0.1);
|
||||
--panel: rgba(255, 255, 255, 0.88);
|
||||
--panel-strong: #ffffff;
|
||||
--lime: #c8ff3d;
|
||||
--blue: #2563eb;
|
||||
--danger: #dc2626;
|
||||
--shadow: 0 22px 70px rgba(15, 23, 42, 0.12);
|
||||
--ink: #121620;
|
||||
--ink-2: #1f2937;
|
||||
--muted: #6b7280;
|
||||
--paper: rgba(255, 252, 242, 0.9);
|
||||
--paper-strong: #fffaf0;
|
||||
--charcoal: #0b0e14;
|
||||
--amber: #ffb000;
|
||||
--acid: #d9ff45;
|
||||
--blue: #2764ff;
|
||||
--danger: #df2b2b;
|
||||
--green: #1f9d55;
|
||||
--line: rgba(18, 22, 32, 0.12);
|
||||
--white-line: rgba(255, 255, 255, 0.12);
|
||||
--shadow: 0 26px 90px rgba(34, 28, 18, 0.16);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -21,208 +26,275 @@ body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 85% -10%, rgba(200,255,61,0.22), transparent 32%),
|
||||
radial-gradient(circle at 15% 8%, rgba(37,99,235,0.12), transparent 30%),
|
||||
linear-gradient(180deg, #f7f8fb 0%, #e7ecf2 100%);
|
||||
linear-gradient(90deg, rgba(18,22,32,0.035) 1px, transparent 1px) 0 0 / 38px 38px,
|
||||
linear-gradient(0deg, rgba(18,22,32,0.035) 1px, transparent 1px) 0 0 / 38px 38px,
|
||||
radial-gradient(circle at 86% -8%, rgba(255,176,0,0.42), transparent 30%),
|
||||
radial-gradient(circle at 6% 10%, rgba(39,100,255,0.18), transparent 32%),
|
||||
linear-gradient(180deg, #fff8e8 0%, #e9e2d5 100%);
|
||||
}
|
||||
button, input, select { font: inherit; }
|
||||
button { border: 0; cursor: pointer; transition: transform .16s ease, background .16s ease, border-color .16s ease, opacity .16s ease; }
|
||||
button:hover:not(:disabled) { transform: translateY(-1px); }
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.24;
|
||||
background-image: radial-gradient(rgba(18,22,32,0.18) 0.7px, transparent 0.7px);
|
||||
background-size: 5px 5px;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button, a { transition: transform .16s ease, background .16s ease, border-color .16s ease, color .16s ease, opacity .16s ease; }
|
||||
button { border: 0; cursor: pointer; }
|
||||
button:hover:not(:disabled), a:hover { transform: translateY(-1px); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 292px 1fr; }
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 304px 1fr; }
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
background: #0b0f16;
|
||||
color: #f8fafc;
|
||||
color: #fff7e8;
|
||||
padding: 30px 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
gap: 32px;
|
||||
background:
|
||||
linear-gradient(155deg, rgba(217,255,69,0.12), transparent 34%),
|
||||
linear-gradient(180deg, #11151e, #07090d);
|
||||
border-right: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.sidebar h1 { font-size: 30px; line-height: .94; letter-spacing: -0.05em; margin: 8px 0 0; }
|
||||
.brand-mark h1 { font-size: 31px; line-height: .93; letter-spacing: -0.06em; margin: 8px 0 0; }
|
||||
.sidebar nav { display: grid; gap: 8px; }
|
||||
.sidebar nav button, .profile-card button {
|
||||
color: #dbe4ef;
|
||||
.sidebar nav a, .profile-card button {
|
||||
color: #dce4f2;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
border-radius: 16px;
|
||||
border-radius: 18px;
|
||||
padding: 13px 14px;
|
||||
font-weight: 750;
|
||||
font-weight: 850;
|
||||
}
|
||||
.sidebar nav button.active, .sidebar nav button:hover {
|
||||
background: var(--lime);
|
||||
color: #0b0f16;
|
||||
.sidebar nav a.active, .sidebar nav a:hover {
|
||||
background: var(--acid);
|
||||
color: #0b0e14;
|
||||
}
|
||||
.profile-card {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 22px;
|
||||
padding: 17px;
|
||||
border: 1px solid rgba(255,255,255,0.11);
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.03));
|
||||
}
|
||||
.profile-card small { color: #94a3b8; }
|
||||
main { padding: 34px; }
|
||||
.profile-card small { color: #9aa9bc; word-break: break-word; }
|
||||
.main-stage { padding: 34px; }
|
||||
|
||||
.auth-layout { min-height: 100vh; display: grid; grid-template-columns: 1.18fr 0.82fr; gap: 24px; align-items: center; }
|
||||
.auth-layout { min-height: 100vh; display: grid; grid-template-columns: 1.16fr 0.84fr; gap: 24px; align-items: center; padding: 34px; }
|
||||
.hero-panel {
|
||||
min-height: calc(100vh - 68px);
|
||||
border-radius: 36px;
|
||||
padding: 52px;
|
||||
color: #f8fafc;
|
||||
border-radius: 42px;
|
||||
padding: 54px;
|
||||
color: #fff8ea;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(200,255,61,0.95), rgba(200,255,61,0) 26%),
|
||||
radial-gradient(circle at 80% 24%, rgba(37,99,235,0.55), transparent 30%),
|
||||
#0b0f16;
|
||||
linear-gradient(135deg, rgba(217,255,69,0.92), rgba(217,255,69,0) 25%),
|
||||
radial-gradient(circle at 80% 24%, rgba(39,100,255,0.62), transparent 30%),
|
||||
#0b0e14;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero-panel h1 { font-size: clamp(40px, 6vw, 82px); line-height: .88; letter-spacing: -0.07em; max-width: 920px; margin: 0; }
|
||||
.hero-panel p:not(.eyebrow) { max-width: 680px; color: #cbd5e1; font-size: 18px; }
|
||||
.auth-card { max-width: 440px; width: 100%; }
|
||||
.hero-panel h1 { font-size: clamp(42px, 6.4vw, 92px); line-height: .86; letter-spacing: -0.08em; max-width: 960px; margin: 0; }
|
||||
.hero-panel p:not(.eyebrow) { max-width: 700px; color: #cbd5e1; font-size: 18px; }
|
||||
.auth-card { max-width: 450px; width: 100%; display: grid; gap: 16px; }
|
||||
.auth-card h2 { margin: 0; font-size: 36px; letter-spacing: -0.05em; }
|
||||
|
||||
.stack { display: grid; gap: 22px; max-width: 1240px; }
|
||||
.stack { display: grid; gap: 22px; max-width: 1260px; }
|
||||
.compact-stack { margin-top: 16px; gap: 12px; }
|
||||
.page-header { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
.page-header h2, .card h3, .auth-card h2 { margin: 0; }
|
||||
.page-header h2 { font-size: clamp(30px, 4vw, 52px); line-height: .95; letter-spacing: -0.055em; }
|
||||
.eyebrow { color: #2563eb; font-size: 11px; font-weight: 900; letter-spacing: .18em; margin: 0 0 8px; text-transform: uppercase; }
|
||||
.page-header h2, .card h3 { margin: 0; }
|
||||
.page-header h2 { font-size: clamp(32px, 4vw, 56px); line-height: .92; letter-spacing: -0.065em; }
|
||||
.eyebrow { color: var(--blue); font-size: 11px; font-weight: 950; letter-spacing: .18em; margin: 0 0 8px; text-transform: uppercase; }
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(255,255,255,0.74);
|
||||
border-radius: 28px;
|
||||
background: var(--paper);
|
||||
border: 1px solid rgba(255,255,255,0.68);
|
||||
border-radius: 30px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 22px;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.primary { background: #0b0f16; color: #fff; border-radius: 16px; padding: 12px 18px; font-weight: 900; box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); }
|
||||
.primary:hover:not(:disabled) { background: #172033; }
|
||||
.ghost { background: transparent; color: #475569; padding: 10px 12px; border-radius: 12px; }
|
||||
.ghost:hover { background: rgba(15,23,42,0.06); }
|
||||
label { display: grid; gap: 8px; font-weight: 800; color: #475569; font-size: 13px; }
|
||||
input, select {
|
||||
.primary, .danger, .ghost, .danger-ghost, .segmented a, .segmented button {
|
||||
border-radius: 16px;
|
||||
font-weight: 950;
|
||||
}
|
||||
.primary { background: var(--charcoal); color: #fffaf0; padding: 12px 18px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); }
|
||||
.primary:hover:not(:disabled) { background: #1a2230; }
|
||||
.danger { background: var(--danger); color: #fff; padding: 12px 18px; }
|
||||
.ghost { background: transparent; color: #475569; padding: 10px 12px; }
|
||||
.ghost:hover { background: rgba(15,23,42,0.07); }
|
||||
.danger-ghost { background: rgba(223,43,43,.09); color: #b91c1c; padding: 10px 12px; }
|
||||
.danger-ghost:hover { background: rgba(223,43,43,.16); }
|
||||
label { display: grid; gap: 8px; font-weight: 850; color: #566170; font-size: 13px; }
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
background: rgba(255,255,255,0.9);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
input:focus, select:focus { border-color: rgba(37,99,235,0.45); box-shadow: 0 0 0 4px rgba(37,99,235,0.1); }
|
||||
input[type="range"] { padding: 0; box-shadow: none; border: 0; background: transparent; accent-color: var(--lime); }
|
||||
textarea { min-height: 112px; resize: vertical; }
|
||||
input:focus, select:focus, textarea:focus { border-color: rgba(39,100,255,0.5); box-shadow: 0 0 0 4px rgba(39,100,255,0.11); }
|
||||
.error { color: var(--danger); margin: 0; }
|
||||
.muted { color: var(--muted); }
|
||||
.loading-card, .empty-state { text-align: center; padding: 48px; color: var(--muted); }
|
||||
|
||||
.stats-grid, .catalog-grid, .workout-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.catalog-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.metric { background: var(--panel-strong); border-radius: 20px; padding: 16px; display: grid; gap: 8px; border: 1px solid var(--line); }
|
||||
.metric span { color: var(--muted); font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.metric strong { font-size: 28px; letter-spacing: -0.04em; }
|
||||
.stats-grid, .workout-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.workout-stats { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.metric { background: var(--paper-strong); border-radius: 20px; padding: 16px; display: grid; gap: 8px; border: 1px solid var(--line); min-width: 0; }
|
||||
.metric span { color: var(--muted); font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.metric strong { font-size: 26px; letter-spacing: -0.045em; overflow-wrap: anywhere; }
|
||||
.metric-dark { background: rgba(255,255,255,.075); border-color: var(--white-line); color: #fff7e8; }
|
||||
.metric-dark span { color: #9aa9bc; }
|
||||
.metric-danger strong { color: var(--danger); }
|
||||
.segmented { display: flex; background: rgba(15,23,42,0.06); border-radius: 999px; padding: 4px; border: 1px solid var(--line); }
|
||||
.segmented button { background: transparent; border-radius: 999px; padding: 10px 14px; font-weight: 850; color: #475569; }
|
||||
.segmented button.active { background: #0b0f16; color: #fff; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; align-items: end; }
|
||||
.segmented a, .segmented button { background: transparent; padding: 10px 14px; color: #586579; }
|
||||
.segmented a.active, .segmented button.active { background: var(--charcoal); color: #fff8e8; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 16px; align-items: end; }
|
||||
|
||||
.daily-command { min-height: 260px; display: flex; justify-content: space-between; gap: 26px; align-items: flex-end; overflow: hidden; position: relative; }
|
||||
.daily-command::after { content: ""; position: absolute; inset: auto -10% -38% auto; width: 360px; height: 360px; border-radius: 50%; background: rgba(217,255,69,.32); filter: blur(6px); }
|
||||
.daily-command h3 { font-size: clamp(34px, 5vw, 70px); line-height: .9; letter-spacing: -0.07em; max-width: 660px; }
|
||||
.daily-command p { color: var(--muted); max-width: 560px; }
|
||||
.active-session { background: linear-gradient(135deg, rgba(11,14,20,.96), rgba(37,100,255,.55)); color: #fff8e8; border-color: rgba(255,255,255,.14); }
|
||||
.active-session p { color: #cbd5e1; }
|
||||
.pulse-action { position: relative; z-index: 1; min-width: 190px; }
|
||||
|
||||
.catalog-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
||||
.catalog-card { overflow: hidden; padding: 0; transition: transform .18s ease, box-shadow .18s ease; }
|
||||
.catalog-card:hover { transform: translateY(-2px); }
|
||||
.catalog-card img, .image-placeholder { width: 100%; height: 170px; object-fit: cover; background: linear-gradient(135deg, #c8ff3d, #dbeafe 55%, #1e293b); display: grid; place-items: center; font-weight: 950; color: #0b0f16; }
|
||||
.catalog-card div:last-child { padding: 18px; }
|
||||
.catalog-card p { color: var(--muted); }
|
||||
.pill { display: inline-flex; border-radius: 999px; background: #eef2f7; color: #475569; padding: 5px 10px; font-size: 11px; font-weight: 900; }
|
||||
.catalog-card:hover { transform: translateY(-2px); box-shadow: 0 28px 80px rgba(34, 28, 18, 0.21); }
|
||||
.catalog-card img, .image-placeholder { width: 100%; height: 172px; object-fit: cover; background: linear-gradient(135deg, var(--acid), #dbeafe 56%, #1e293b); display: grid; place-items: center; font-weight: 950; color: var(--charcoal); }
|
||||
.catalog-card div:last-child { padding: 18px; display: grid; gap: 10px; }
|
||||
.catalog-card p { color: var(--muted); margin: 0; }
|
||||
.pill, .source-pill, .status-chip { display: inline-flex; width: fit-content; border-radius: 999px; background: #eef2f7; color: #475569; padding: 5px 10px; font-size: 11px; font-weight: 950; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.pill.user { background: #dcfce7; color: #166534; }
|
||||
.empty-state { text-align: center; padding: 48px; }
|
||||
.workout-card { display: grid; gap: 18px; }
|
||||
|
||||
.workout-cta { text-align: center; padding: 58px 32px; display: flex; flex-direction: column; align-items: center; gap: 16px; background: linear-gradient(135deg, rgba(255,255,255,.92), rgba(200,255,61,.18)); }
|
||||
.workout-cta h3 { margin: 0; font-size: 32px; letter-spacing: -0.04em; }
|
||||
.workout-cta p { color: var(--muted); max-width: 460px; }
|
||||
.workout-live-stats { display: flex; gap: 10px; align-items: center; }
|
||||
.timer-badge { background: #0b0f16; color: var(--lime); font-family: "SF Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; font-size: 18px; font-weight: 900; padding: 9px 16px; border-radius: 16px; letter-spacing: .04em; }
|
||||
.kcal-badge { background: #dbeafe; color: #1d4ed8; font-weight: 900; padding: 9px 14px; border-radius: 16px; font-size: 14px; }
|
||||
.catalog-picker-section { padding: 20px 24px; }
|
||||
.catalog-picker-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.catalog-picker-header h3 { margin: 0; }
|
||||
.workout-catalog-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
|
||||
.workout-pick-card { padding: 0; cursor: default; }
|
||||
.workout-pick-card.added { opacity: .56; }
|
||||
.workout-pick-card .image-placeholder, .workout-pick-card img { height: 112px; }
|
||||
.workout-pick-card div:last-child { padding: 14px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.workout-pick-card h3 { margin: 0; font-size: 15px; }
|
||||
.pick-btn { border-radius: 13px; padding: 9px 12px; font-weight: 900; font-size: 12px; width: 100%; }
|
||||
.pick-btn.picked { background: #dcfce7; color: #166534; cursor: default; }
|
||||
.workout-cart { display: flex; flex-direction: column; gap: 16px; background: #0b0f16; color: #f8fafc; border-color: rgba(255,255,255,.1); }
|
||||
.workout-cart > h3 { margin: 0; }
|
||||
.workout-cart .muted { color: #94a3b8; }
|
||||
.cart-items { display: flex; flex-direction: column; gap: 10px; }
|
||||
.cart-item { border: 1px solid rgba(255,255,255,.1); border-radius: 20px; overflow: hidden; background: rgba(255,255,255,.05); }
|
||||
.cart-item.expanded { border-color: rgba(200,255,61,.72); background: rgba(255,255,255,.075); }
|
||||
.cart-item-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 18px; cursor: pointer; }
|
||||
.cart-item-header:hover { background: rgba(255,255,255,.04); }
|
||||
.cart-item-info { display: flex; flex-direction: column; gap: 3px; }
|
||||
.cart-item-name { font-weight: 900; font-size: 16px; }
|
||||
.cart-item-summary { color: #94a3b8; font-size: 13px; }
|
||||
.cart-item-remove { background: transparent; color: #94a3b8; font-size: 22px; padding: 4px 9px; border-radius: 10px; line-height: 1; }
|
||||
.cart-item-remove:hover { color: #fecaca; background: rgba(220,38,38,.18); }
|
||||
.cart-item-body { border-top: 1px solid rgba(255,255,255,.08); padding: 16px 18px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.sets-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.set-row { display: grid; grid-template-columns: 32px 1fr 78px 32px; gap: 8px; align-items: center; padding: 8px 10px; background: rgba(255,255,255,.06); border-radius: 12px; }
|
||||
.set-index { font-weight: 950; color: var(--lime); font-size: 13px; }
|
||||
.set-data { font-size: 14px; font-weight: 850; }
|
||||
.set-cal { color: #94a3b8; font-size: 12px; }
|
||||
.set-remove { background: transparent; color: #64748b; font-size: 17px; padding: 2px 6px; border-radius: 8px; line-height: 1; }
|
||||
.set-remove:hover { color: #fecaca; background: rgba(220,38,38,.18); }
|
||||
.set-builder { display: grid; gap: 14px; padding: 16px; border-radius: 18px; background: rgba(255,255,255,.08); }
|
||||
.slider-head { display: flex; justify-content: space-between; align-items: center; color: #cbd5e1; font-weight: 900; }
|
||||
.slider-head strong { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 50%; background: var(--lime); color: #0b0f16; font-size: 20px; }
|
||||
.set-count-slider { height: 30px; }
|
||||
.draft-sets { display: grid; gap: 10px; }
|
||||
.draft-set-row { display: grid; grid-template-columns: 36px 1fr 1fr; gap: 10px; align-items: end; }
|
||||
.draft-set-row > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; background: rgba(200,255,61,.16); color: var(--lime); font-weight: 950; }
|
||||
.draft-set-row label { color: #cbd5e1; }
|
||||
.draft-set-row input { background: rgba(255,255,255,.92); }
|
||||
.add-sets-btn { justify-self: end; }
|
||||
.finish-btn { align-self: flex-end; margin-top: 8px; background: var(--lime); color: #0b0f16; }
|
||||
.modal-overlay { position: fixed; inset: 0; z-index: 100; background: rgba(2,6,23,.6); display: grid; place-items: center; backdrop-filter: blur(8px); }
|
||||
.modal-card { max-width: 460px; width: calc(100% - 32px); display: flex; flex-direction: column; gap: 18px; }
|
||||
.modal-card h2 { margin: 0; letter-spacing: -0.04em; }
|
||||
.modal-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.modal-stats .metric { padding: 12px; }
|
||||
.workout-cta { text-align: center; padding: 58px 32px; display: flex; flex-direction: column; align-items: center; gap: 16px; background: linear-gradient(135deg, rgba(255,250,240,.94), rgba(217,255,69,.18)); }
|
||||
.workout-cta h3 { font-size: 34px; letter-spacing: -0.045em; }
|
||||
.workout-cta p { color: var(--muted); max-width: 580px; }
|
||||
.live-header { position: sticky; top: 18px; z-index: 4; display: grid; grid-template-columns: 1.15fr auto 1.4fr auto; gap: 18px; align-items: center; color: #fff8e8; background: linear-gradient(135deg, #0b0e14, #161e2a); border-color: rgba(255,255,255,.1); }
|
||||
.live-header h2 { margin: 0; font-size: clamp(34px, 5vw, 58px); line-height: .9; letter-spacing: -0.07em; }
|
||||
.live-header span { color: #9aa9bc; }
|
||||
.live-clock { font-family: "Cascadia Code", "SF Mono", ui-monospace, monospace; font-size: clamp(34px, 5vw, 58px); font-weight: 950; color: var(--acid); letter-spacing: -0.06em; }
|
||||
.live-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
.live-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
|
||||
.exercise-journal { display: grid; gap: 18px; }
|
||||
.empty-workout { min-height: 330px; display: grid; place-items: center; text-align: center; align-content: center; gap: 14px; background: linear-gradient(135deg, rgba(255,250,240,.92), rgba(39,100,255,.12)); }
|
||||
.empty-workout h3 { font-size: 40px; letter-spacing: -0.055em; }
|
||||
.empty-workout p { color: var(--muted); max-width: 520px; }
|
||||
.sticky-add { justify-self: center; }
|
||||
|
||||
.workout-exercise-card { padding: 0; overflow: hidden; display: grid; grid-template-columns: 180px 1fr; background: rgba(255,252,242,.94); }
|
||||
.exercise-card-media { min-height: 100%; background: #0b0e14; display: grid; place-items: center; color: var(--acid); font-weight: 950; letter-spacing: .12em; }
|
||||
.exercise-card-media img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.exercise-card-body { padding: 22px; display: grid; gap: 16px; }
|
||||
.exercise-card-header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; }
|
||||
.exercise-card-header h3 { font-size: 30px; letter-spacing: -0.055em; margin: 8px 0 4px; }
|
||||
.exercise-card-header p { color: var(--muted); margin: 0; }
|
||||
.exercise-microstats { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.exercise-microstats span { border: 1px solid var(--line); border-radius: 999px; padding: 7px 10px; font-size: 12px; font-weight: 900; color: #475569; background: rgba(255,255,255,.64); }
|
||||
.sets-list { display: grid; gap: 8px; }
|
||||
.set-row { display: grid; grid-template-columns: 36px 1fr 82px 42px; gap: 8px; align-items: center; padding: 8px 10px; background: rgba(11,14,20,.055); border-radius: 14px; }
|
||||
.set-row.editing { grid-template-columns: 36px 1fr 1fr auto; }
|
||||
.set-index { width: 28px; height: 28px; border-radius: 10px; background: var(--charcoal); color: var(--acid); display: grid; place-items: center; font-weight: 950; font-size: 12px; }
|
||||
.set-data { background: transparent; text-align: left; color: var(--ink); font-weight: 900; padding: 6px; border-radius: 10px; }
|
||||
.set-data:hover { background: rgba(39,100,255,.08); }
|
||||
.set-cal { color: var(--muted); font-size: 12px; }
|
||||
.set-remove, .tiny { background: transparent; color: #64748b; font-size: 17px; padding: 4px 7px; border-radius: 9px; line-height: 1; }
|
||||
.set-remove:hover, .tiny:hover { color: #fecaca; background: rgba(223,43,43,.18); }
|
||||
.set-row-actions { display: flex; gap: 4px; }
|
||||
.tiny.success { color: var(--green); }
|
||||
.single-set-console { display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: end; padding: 16px; border-radius: 22px; background: #11151e; color: #fff8e8; }
|
||||
.stepper-field > span { display: block; color: #9aa9bc; font-size: 11px; font-weight: 950; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; }
|
||||
.stepper-field > div { display: grid; grid-template-columns: 42px minmax(80px, 1fr) 42px; gap: 7px; }
|
||||
.stepper-field button { background: rgba(255,255,255,.1); color: #fff8e8; border-radius: 14px; font-size: 22px; font-weight: 950; }
|
||||
.stepper-field input { text-align: center; font-weight: 950; }
|
||||
.record-set { min-height: 48px; background: var(--acid); color: var(--charcoal); }
|
||||
.batch-link { justify-self: start; }
|
||||
|
||||
.drawer-scrim, .dialog-backdrop { position: fixed; inset: 0; z-index: 50; background: rgba(5,7,11,.62); backdrop-filter: blur(9px); }
|
||||
.add-drawer { position: absolute; inset: 18px 18px 18px auto; width: min(500px, calc(100vw - 36px)); overflow: auto; border-radius: 34px; padding: 24px; background: #fff8ea; box-shadow: 0 34px 120px rgba(0,0,0,.36); display: grid; align-content: start; gap: 16px; }
|
||||
.add-drawer header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; }
|
||||
.add-drawer h2, .modal-card h2 { margin: 0; font-size: 34px; line-height: .96; letter-spacing: -0.055em; }
|
||||
.round-close { width: 40px; height: 40px; border-radius: 50%; background: #11151e; color: #fff8ea; font-size: 24px; line-height: 1; }
|
||||
.drawer-tabs { width: fit-content; }
|
||||
.drawer-list { display: grid; gap: 10px; }
|
||||
.drawer-pick { display: grid; grid-template-columns: 76px 1fr 42px; gap: 12px; align-items: center; padding: 10px; border-radius: 20px; border: 1px solid var(--line); background: rgba(255,255,255,.56); cursor: pointer; }
|
||||
.drawer-pick:hover { background: #fff; }
|
||||
.drawer-pick.already-added { box-shadow: inset 0 0 0 2px rgba(31,157,85,.22); }
|
||||
.drawer-pick img, .drawer-placeholder { width: 76px; height: 76px; border-radius: 16px; object-fit: cover; display: grid; place-items: center; background: linear-gradient(135deg, var(--acid), #dbeafe); font-weight: 950; }
|
||||
.drawer-pick h3 { font-size: 16px; margin: 0 0 4px; }
|
||||
.drawer-pick p { margin: 0; color: var(--muted); font-size: 13px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.drawer-pick b { color: var(--green); font-size: 12px; }
|
||||
.plus-chip { width: 38px; height: 38px; border-radius: 14px; background: var(--charcoal); color: #fff8ea; font-size: 22px; font-weight: 950; }
|
||||
.modal-card { max-width: 560px; width: calc(100% - 32px); display: grid; gap: 18px; position: fixed; inset: 50% auto auto 50%; transform: translate(-50%, -50%); }
|
||||
.modal-card:hover { transform: translate(-50%, -50%); }
|
||||
.modal-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 12px; }
|
||||
.batch-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.discard-dialog { border-color: rgba(223,43,43,.25); }
|
||||
.discard-dialog p { color: var(--muted); }
|
||||
|
||||
.workout-summary { display: grid; gap: 18px; }
|
||||
.status-discarded { opacity: .7; filter: grayscale(.35); }
|
||||
.summary-title { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; }
|
||||
.summary-title h3 { margin: 8px 0 6px; }
|
||||
.summary-title p { margin: 0; color: var(--muted); }
|
||||
.detail-link { display: inline-flex; }
|
||||
.discarded-list summary { cursor: pointer; font-weight: 950; }
|
||||
.detail-hero, .analytics-controls { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; align-items: end; }
|
||||
.analytics-controls { grid-template-columns: 1.4fr repeat(3, 1fr); }
|
||||
.detail-item h3, .notes-card h3, .chart-card h3 { margin-bottom: 14px; }
|
||||
.bars { display: grid; gap: 12px; }
|
||||
.bar-row { display: grid; grid-template-columns: 110px 1fr 70px; gap: 12px; align-items: center; }
|
||||
.bar-row div { height: 16px; border-radius: 999px; background: #e2e8f0; overflow: hidden; }
|
||||
.bar-row i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #2563eb, #c8ff3d); }
|
||||
.bar-row div { height: 16px; border-radius: 999px; background: #e2dccf; overflow: hidden; }
|
||||
.bar-row i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--blue), var(--acid), var(--amber)); }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.live-header { grid-template-columns: 1fr; position: static; }
|
||||
.live-actions { justify-content: flex-start; }
|
||||
.detail-hero, .analytics-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell { grid-template-columns: 1fr; padding-bottom: 88px; }
|
||||
.sidebar { position: fixed; inset: auto 12px 12px; z-index: 10; height: auto; border-radius: 24px; padding: 12px; display: block; }
|
||||
.sidebar > div:first-child, .profile-card { display: none; }
|
||||
.sidebar { position: fixed; inset: auto 12px 12px; z-index: 20; height: auto; border-radius: 26px; padding: 12px; display: block; }
|
||||
.brand-mark, .profile-card { display: none; }
|
||||
.sidebar nav { grid-template-columns: repeat(5, 1fr); }
|
||||
.sidebar nav button { text-align: center; padding: 10px 6px; font-size: 12px; }
|
||||
main { padding: 20px; }
|
||||
.auth-layout { grid-template-columns: 1fr; }
|
||||
.hero-panel { min-height: 48vh; padding: 28px; }
|
||||
.sidebar nav a { text-align: center; padding: 10px 6px; font-size: 12px; }
|
||||
.main-stage { padding: 20px; }
|
||||
.auth-layout { grid-template-columns: 1fr; padding: 20px; }
|
||||
.hero-panel { min-height: 48vh; padding: 30px; }
|
||||
.catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.form-grid, .stats-grid { grid-template-columns: 1fr; }
|
||||
.workout-catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.workout-live-stats { gap: 6px; }
|
||||
.timer-badge { font-size: 15px; padding: 6px 12px; }
|
||||
.catalog-picker-header { flex-direction: column; align-items: stretch; gap: 10px; }
|
||||
.form-grid, .stats-grid, .workout-stats { grid-template-columns: 1fr; }
|
||||
.workout-exercise-card { grid-template-columns: 1fr; }
|
||||
.exercise-card-media { min-height: 130px; }
|
||||
.single-set-console { grid-template-columns: 1fr; }
|
||||
.live-metrics { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.page-header { align-items: stretch; flex-direction: column; }
|
||||
.catalog-grid, .workout-stats { grid-template-columns: 1fr; }
|
||||
.workout-catalog-grid { grid-template-columns: 1fr; }
|
||||
.draft-set-row { grid-template-columns: 28px 1fr 1fr; }
|
||||
.modal-stats { grid-template-columns: 1fr; }
|
||||
.page-header, .daily-command, .exercise-card-header, .summary-title { align-items: stretch; flex-direction: column; }
|
||||
.daily-command { min-height: auto; }
|
||||
.catalog-grid, .detail-hero, .analytics-controls, .batch-grid, .modal-stats { grid-template-columns: 1fr; }
|
||||
.add-drawer { inset: auto 0 0; width: 100%; max-height: 86vh; border-radius: 32px 32px 0 0; }
|
||||
.drawer-pick { grid-template-columns: 62px 1fr 38px; }
|
||||
.drawer-pick img, .drawer-placeholder { width: 62px; height: 62px; }
|
||||
.set-row, .set-row.editing { grid-template-columns: 30px 1fr; }
|
||||
.set-cal, .set-remove, .set-row-actions { justify-self: start; }
|
||||
.bar-row { grid-template-columns: 1fr; }
|
||||
.modal-actions { flex-direction: column-reverse; }
|
||||
.modal-actions button { width: 100%; }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export type User = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CatalogKind = "exercise" | "equipment";
|
||||
|
||||
export type CatalogEntity = {
|
||||
id: string;
|
||||
owner_user_id: string | null;
|
||||
@@ -17,6 +19,8 @@ export type CatalogEntity = {
|
||||
default_calories_per_minute?: number | null;
|
||||
};
|
||||
|
||||
export type WorkoutStatus = "active" | "finished" | "discarded";
|
||||
|
||||
export type WorkoutSet = {
|
||||
id: string;
|
||||
workout_item_id: string;
|
||||
@@ -31,8 +35,11 @@ export type WorkoutSet = {
|
||||
export type WorkoutItem = {
|
||||
id: string;
|
||||
workout_id: string;
|
||||
source_kind: CatalogKind;
|
||||
exercise_id: string | null;
|
||||
equipment_id: string | null;
|
||||
title_snapshot: string;
|
||||
image_s3_url_snapshot: string | null;
|
||||
order_index: number;
|
||||
planned_working_weight: number | null;
|
||||
created_at: string;
|
||||
@@ -42,10 +49,15 @@ export type WorkoutItem = {
|
||||
export type Workout = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
status: WorkoutStatus;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
notes: string | null;
|
||||
total_sets: number;
|
||||
total_volume: number;
|
||||
estimated_calories: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
items: WorkoutItem[];
|
||||
};
|
||||
|
||||
@@ -60,3 +72,10 @@ export type Calories = {
|
||||
total_calories: number;
|
||||
workouts: Array<{ id: string; date: string; calories: number }>;
|
||||
};
|
||||
|
||||
export type WorkoutSetInput = {
|
||||
weight: number;
|
||||
reps: number;
|
||||
duration_seconds?: number | null;
|
||||
calories?: number | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user