Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
ARG VITE_API_BASE_URL=/api
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
COPY package.json ./
|
||||
RUN corepack enable && corepack prepare pnpm@10.12.1 --activate && pnpm install --frozen-lockfile=false
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,25 @@
|
||||
import js from "@eslint/js";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Train Watcher</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://bff:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "train-watcher-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.12.1",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.77.2",
|
||||
"@tanstack/react-router": "^1.120.15",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.27.0",
|
||||
"@types/react": "^19.1.5",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.2.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.32.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
|
||||
import { api, type AuthState } from "./api";
|
||||
import type { CatalogEntity, Workout } from "./types";
|
||||
|
||||
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} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div>
|
||||
<p className="eyebrow">Train Watcher</p>
|
||||
<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>
|
||||
<div className="profile-card">
|
||||
<span>{auth.user.display_name}</span>
|
||||
<small>{auth.user.email}</small>
|
||||
<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>
|
||||
</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" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Пароль
|
||||
<input type="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);
|
||||
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 [kind, setKind] = useState<"exercise" | "equipment">("exercise");
|
||||
const [entityId, setEntityId] = useState("");
|
||||
const [activeItemId, setActiveItemId] = useState("");
|
||||
const [weight, setWeight] = useState(60);
|
||||
const [reps, setReps] = useState(8);
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => api.createWorkout(token),
|
||||
onSuccess: (workout) => {
|
||||
setActiveWorkout(workout);
|
||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
||||
},
|
||||
});
|
||||
const addItemMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!activeWorkout) throw new Error("Нет активной тренировки");
|
||||
return api.addWorkoutItem(token, activeWorkout.id, {
|
||||
exercise_id: kind === "exercise" ? entityId : null,
|
||||
equipment_id: kind === "equipment" ? entityId : null,
|
||||
planned_working_weight: weight,
|
||||
});
|
||||
},
|
||||
onSuccess: (item) => {
|
||||
setActiveItemId(item.id);
|
||||
setActiveWorkout((workout) => workout ? { ...workout, items: [...workout.items, item] } : workout);
|
||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
||||
},
|
||||
});
|
||||
const addSetMutation = useMutation({
|
||||
mutationFn: () => api.addWorkoutSet(token, activeItemId, { weight, reps }),
|
||||
onSuccess: (workoutSet) => {
|
||||
setActiveWorkout((workout) => {
|
||||
if (!workout) return workout;
|
||||
return {
|
||||
...workout,
|
||||
estimated_calories: workout.estimated_calories + (workoutSet.calories ?? 0),
|
||||
items: workout.items.map((item) =>
|
||||
item.id === activeItemId ? { ...item, sets: [...item.sets, workoutSet] } : item,
|
||||
),
|
||||
};
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["calories"] });
|
||||
},
|
||||
});
|
||||
|
||||
const current = activeWorkout ?? workouts.data?.find((workout) => !workout.finished_at) ?? null;
|
||||
const choices = kind === "exercise" ? exercises.data : equipment.data;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Active workout</p>
|
||||
<h2>Текущая тренировка</h2>
|
||||
</div>
|
||||
<button className="primary" onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>Новая тренировка</button>
|
||||
</div>
|
||||
{current ? (
|
||||
<>
|
||||
<section className="card form-grid">
|
||||
<label>
|
||||
Тип
|
||||
<select value={kind} onChange={(event) => setKind(event.target.value as "exercise" | "equipment")}>
|
||||
<option value="exercise">Упражнение</option>
|
||||
<option value="equipment">Тренажер</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Элемент
|
||||
<select value={entityId} onChange={(event) => setEntityId(event.target.value)}>
|
||||
<option value="">Выбрать</option>
|
||||
{choices?.map((entity) => <option key={entity.id} value={entity.id}>{entity.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Вес
|
||||
<input type="number" value={weight} onChange={(event) => setWeight(Number(event.target.value))} />
|
||||
</label>
|
||||
<label>
|
||||
Повторы
|
||||
<input type="number" value={reps} onChange={(event) => setReps(Number(event.target.value))} />
|
||||
</label>
|
||||
<button className="primary" disabled={!entityId || addItemMutation.isPending} onClick={() => addItemMutation.mutate()}>Добавить элемент</button>
|
||||
<button disabled={!activeItemId || addSetMutation.isPending} onClick={() => addSetMutation.mutate()}>Записать подход</button>
|
||||
</section>
|
||||
<WorkoutSummary workout={current} />
|
||||
</>
|
||||
) : (
|
||||
<section className="card empty-state"><h3>Нет активной тренировки</h3><p>Начни тренировку и добавь упражнения или тренажеры.</p></section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Calories, CatalogEntity, Progression, User, Workout, WorkoutItem, WorkoutSet } from "./types";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
|
||||
export type AuthState = {
|
||||
accessToken: string;
|
||||
user: User;
|
||||
};
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}, token?: string): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
register(payload: { email: string; password: string; display_name: string }) {
|
||||
return request<AuthState>("/auth/register", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
login(payload: { email: string; password: string }) {
|
||||
return request<AuthState>("/auth/login", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
me(token: string) {
|
||||
return request<User>("/me", {}, token);
|
||||
},
|
||||
equipment(token: string) {
|
||||
return request<CatalogEntity[]>("/catalog/equipment", {}, token);
|
||||
},
|
||||
exercises(token: string) {
|
||||
return request<CatalogEntity[]>("/catalog/exercises", {}, token);
|
||||
},
|
||||
uploadImage(token: string, entityType: "equipment" | "exercise", file: File) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return request<{ image_s3_url: string; image_s3_key: string }>(
|
||||
`/media/images?entity_type=${entityType}`,
|
||||
{ method: "POST", body: form },
|
||||
token,
|
||||
);
|
||||
},
|
||||
createEquipment(token: string, payload: Partial<CatalogEntity>) {
|
||||
return request<CatalogEntity>("/catalog/equipment", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
createExercise(token: string, payload: Partial<CatalogEntity>) {
|
||||
return request<CatalogEntity>("/catalog/exercises", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
workouts(token: string) {
|
||||
return request<Workout[]>("/workouts", {}, token);
|
||||
},
|
||||
createWorkout(token: string, notes?: string) {
|
||||
return request<Workout>("/workouts", { method: "POST", body: JSON.stringify({ notes }) }, 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>) {
|
||||
return request<WorkoutItem>(`/workouts/${workoutId}/items`, { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
addWorkoutSet(token: string, itemId: string, payload: Partial<WorkoutSet>) {
|
||||
return request<WorkoutSet>(`/workout-items/${itemId}/sets`, { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
progression(token: string, kind: "exercise" | "equipment", entityId?: string) {
|
||||
const params = new URLSearchParams({ kind });
|
||||
if (entityId) params.set("entity_id", entityId);
|
||||
return request<Progression>(`/analytics/progression?${params.toString()}`, {}, token);
|
||||
},
|
||||
calories(token: string) {
|
||||
return request<Calories>("/analytics/calories", {}, token);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
color: #111827;
|
||||
background: #f4f0e8;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||
button, input, select { font: inherit; }
|
||||
button { border: 0; cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 280px 1fr; }
|
||||
.sidebar { background: #151515; color: #f9fafb; padding: 28px; display: flex; flex-direction: column; gap: 28px; }
|
||||
.sidebar h1 { font-size: 28px; line-height: 1; margin: 8px 0 0; }
|
||||
.sidebar nav { display: grid; gap: 8px; }
|
||||
.sidebar nav button, .profile-card button { color: #f9fafb; background: transparent; text-align: left; border-radius: 14px; padding: 12px 14px; }
|
||||
.sidebar nav button.active, .sidebar nav button:hover { background: #dbff5b; color: #151515; }
|
||||
.profile-card { margin-top: auto; display: grid; gap: 6px; padding: 16px; border: 1px solid #333; border-radius: 20px; }
|
||||
.profile-card small { color: #a3a3a3; }
|
||||
main { padding: 32px; }
|
||||
.auth-layout { min-height: 100vh; display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 24px; align-items: center; }
|
||||
.hero-panel { min-height: calc(100vh - 64px); border-radius: 32px; padding: 48px; color: #f9fafb; background: radial-gradient(circle at 20% 20%, #dbff5b 0 12%, transparent 13%), linear-gradient(135deg, #111827, #3f2d20); display: flex; flex-direction: column; justify-content: flex-end; }
|
||||
.hero-panel h1 { font-size: clamp(36px, 6vw, 76px); line-height: 0.95; max-width: 920px; margin: 0; }
|
||||
.hero-panel p:not(.eyebrow) { max-width: 680px; color: #e5e7eb; font-size: 18px; }
|
||||
.auth-card { max-width: 440px; width: 100%; }
|
||||
.stack { display: grid; gap: 24px; }
|
||||
.page-header { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
.page-header h2, .card h3, .auth-card h2 { margin: 0; }
|
||||
.eyebrow { color: #8a5cf6; font-size: 12px; font-weight: 800; letter-spacing: 0.16em; margin: 0 0 8px; text-transform: uppercase; }
|
||||
.card { background: rgba(255,255,255,0.82); border: 1px solid rgba(17,24,39,0.08); border-radius: 24px; box-shadow: 0 18px 60px rgba(17,24,39,0.08); padding: 22px; }
|
||||
.primary { background: #151515; color: #fff; border-radius: 14px; padding: 12px 18px; font-weight: 800; }
|
||||
.ghost { background: transparent; color: #374151; padding: 10px; }
|
||||
label { display: grid; gap: 8px; font-weight: 700; color: #374151; }
|
||||
input, select { width: 100%; border: 1px solid #d1d5db; border-radius: 14px; padding: 12px 14px; background: #fff; color: #111827; }
|
||||
.error { color: #b91c1c; margin: 0; }
|
||||
.muted { color: #6b7280; }
|
||||
.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: #fff; border-radius: 18px; padding: 16px; display: grid; gap: 8px; }
|
||||
.metric span { color: #6b7280; font-size: 13px; }
|
||||
.metric strong { font-size: 26px; }
|
||||
.segmented { display: flex; background: #fff; border-radius: 999px; padding: 4px; }
|
||||
.segmented button { background: transparent; border-radius: 999px; padding: 10px 14px; }
|
||||
.segmented button.active { background: #151515; color: #fff; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; align-items: end; }
|
||||
.catalog-card { overflow: hidden; padding: 0; }
|
||||
.catalog-card img, .image-placeholder { width: 100%; height: 170px; object-fit: cover; background: linear-gradient(135deg, #dbff5b, #8a5cf6); display: grid; place-items: center; font-weight: 900; color: #151515; }
|
||||
.catalog-card div:last-child { padding: 18px; }
|
||||
.catalog-card p { color: #6b7280; }
|
||||
.pill { display: inline-flex; border-radius: 999px; background: #e5e7eb; padding: 5px 10px; font-size: 12px; font-weight: 800; }
|
||||
.pill.user { background: #dcfce7; color: #166534; }
|
||||
.empty-state { text-align: center; padding: 48px; }
|
||||
.workout-card { display: grid; gap: 18px; }
|
||||
.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: #e5e7eb; overflow: hidden; }
|
||||
.bar-row i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #8a5cf6, #dbff5b); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell { grid-template-columns: 1fr; padding-bottom: 88px; }
|
||||
.sidebar { position: fixed; inset: auto 12px 12px; z-index: 10; border-radius: 24px; padding: 12px; display: block; }
|
||||
.sidebar > div:first-child, .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; }
|
||||
.catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.form-grid, .stats-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.page-header { align-items: stretch; flex-direction: column; }
|
||||
.catalog-grid, .workout-stats { grid-template-columns: 1fr; }
|
||||
.bar-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CatalogEntity = {
|
||||
id: string;
|
||||
owner_user_id: string | null;
|
||||
equipment_id?: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
image_s3_url: string | null;
|
||||
image_s3_key: string | null;
|
||||
is_builtin: boolean;
|
||||
default_calories_per_minute?: number | null;
|
||||
};
|
||||
|
||||
export type WorkoutSet = {
|
||||
id: string;
|
||||
workout_item_id: string;
|
||||
set_index: number;
|
||||
weight: number;
|
||||
reps: number;
|
||||
duration_seconds: number | null;
|
||||
calories: number | null;
|
||||
completed_at: string;
|
||||
};
|
||||
|
||||
export type WorkoutItem = {
|
||||
id: string;
|
||||
workout_id: string;
|
||||
exercise_id: string | null;
|
||||
equipment_id: string | null;
|
||||
order_index: number;
|
||||
planned_working_weight: number | null;
|
||||
created_at: string;
|
||||
sets: WorkoutSet[];
|
||||
};
|
||||
|
||||
export type Workout = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
notes: string | null;
|
||||
estimated_calories: number;
|
||||
items: WorkoutItem[];
|
||||
};
|
||||
|
||||
export type Progression = {
|
||||
last_weight: number | null;
|
||||
max_weight: number | null;
|
||||
previous_delta: number | null;
|
||||
points: Array<{ date: string; max_weight: number; volume: number }>;
|
||||
};
|
||||
|
||||
export type Calories = {
|
||||
total_calories: number;
|
||||
workouts: Array<{ id: string; date: string; calories: number }>;
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "eslint.config.js"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
Reference in New Issue
Block a user