Refactor code structure for improved readability and maintainability

This commit is contained in:
Artem Kashaev
2026-05-28 10:36:17 +05:00
commit d5a889ed6d
49 changed files with 6853 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.tsbuildinfo
.env
+13
View File
@@ -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
+25
View File
@@ -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 }],
},
},
);
+12
View File
@@ -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>
+19
View File
@@ -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;
}
}
+32
View File
@@ -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"
}
}
+421
View File
@@ -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>
);
}
+81
View File
@@ -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);
},
};
+16
View File
@@ -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>,
);
+79
View File
@@ -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; }
}
+62
View File
@@ -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 }>;
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+20
View File
@@ -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"]
}
+6
View File
@@ -0,0 +1,6 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
});