Implement workout item and set deletion endpoints, enhance API response handling for empty responses
This commit is contained in:
+296
-55
@@ -1,8 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api, type AuthState } from "./api";
|
||||
import type { CatalogEntity, Workout } from "./types";
|
||||
import type { CatalogEntity, Workout, WorkoutItem as WorkoutItemType } from "./types";
|
||||
|
||||
type Tab = "dashboard" | "catalog" | "workout" | "history" | "analytics";
|
||||
|
||||
@@ -236,11 +236,43 @@ function ActiveWorkout({ token }: { token: string }) {
|
||||
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 [catalogKind, setCatalogKind] = useState<"exercise" | "equipment">("exercise");
|
||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
|
||||
const [weight, setWeight] = useState(60);
|
||||
const [reps, setReps] = useState(8);
|
||||
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 startMutation = useMutation({
|
||||
mutationFn: () => api.createWorkout(token),
|
||||
@@ -249,31 +281,42 @@ function ActiveWorkout({ token }: { token: string }) {
|
||||
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,
|
||||
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) => {
|
||||
setActiveItemId(item.id);
|
||||
setActiveWorkout((workout) => workout ? { ...workout, items: [...workout.items, item] } : workout);
|
||||
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 addSetMutation = useMutation({
|
||||
mutationFn: () => api.addWorkoutSet(token, activeItemId, { weight, reps }),
|
||||
onSuccess: (workoutSet) => {
|
||||
setActiveWorkout((workout) => {
|
||||
if (!workout) return workout;
|
||||
mutationFn: (itemId: string) => api.addWorkoutSet(token, itemId, { weight, reps }),
|
||||
onSuccess: (workoutSet, itemId) => {
|
||||
setActiveWorkout((w) => {
|
||||
if (!w) return w;
|
||||
return {
|
||||
...workout,
|
||||
estimated_calories: workout.estimated_calories + (workoutSet.calories ?? 0),
|
||||
items: workout.items.map((item) =>
|
||||
item.id === activeItemId ? { ...item, sets: [...item.sets, workoutSet] } : item,
|
||||
...w,
|
||||
estimated_calories: w.estimated_calories + (workoutSet.calories ?? 0),
|
||||
items: w.items.map((item) =>
|
||||
item.id === itemId ? { ...item, sets: [...item.sets, workoutSet] } : item,
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -282,55 +325,253 @@ function ActiveWorkout({ token }: { token: string }) {
|
||||
},
|
||||
});
|
||||
|
||||
const current = activeWorkout ?? workouts.data?.find((workout) => !workout.finished_at) ?? null;
|
||||
const choices = kind === "exercise" ? exercises.data : equipment.data;
|
||||
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);
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Active workout</p>
|
||||
<p className="eyebrow">Workout</p>
|
||||
<h2>Текущая тренировка</h2>
|
||||
</div>
|
||||
<button className="primary" onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>Новая тренировка</button>
|
||||
<div className="workout-live-stats">
|
||||
<span className="timer-badge">{elapsed || "00:00"}</span>
|
||||
<span className="kcal-badge">{Math.round(current.estimated_calories)} ккал</span>
|
||||
</div>
|
||||
</div>
|
||||
{current ? (
|
||||
<>
|
||||
<section className="card form-grid">
|
||||
|
||||
<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>
|
||||
</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)}
|
||||
onAddSet={expandedItemId === item.id ? () => addSetMutation.mutate(item.id) : undefined}
|
||||
onRemoveSet={(setId) => removeSetMutation.mutate({ itemId: item.id, setId })}
|
||||
weight={weight}
|
||||
reps={reps}
|
||||
onWeightChange={setWeight}
|
||||
onRepsChange={setReps}
|
||||
isAddingSet={addSetMutation.isPending}
|
||||
isRemovingItem={removeItemMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="primary finish-btn"
|
||||
disabled={current.items.length === 0 || finishMutation.isPending}
|
||||
onClick={() => setShowFinishModal(true)}
|
||||
>
|
||||
Завершить тренировку
|
||||
</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>
|
||||
Тип
|
||||
<select value={kind} onChange={(event) => setKind(event.target.value as "exercise" | "equipment")}>
|
||||
<option value="exercise">Упражнение</option>
|
||||
<option value="equipment">Тренажер</option>
|
||||
</select>
|
||||
Заметки
|
||||
<input value={finishNotes} onChange={(e) => setFinishNotes(e.target.value)} placeholder="Как прошла тренировка?" />
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CartItemRow({
|
||||
item,
|
||||
exercises,
|
||||
equipment,
|
||||
expanded,
|
||||
onToggle,
|
||||
onRemoveItem,
|
||||
onAddSet,
|
||||
onRemoveSet,
|
||||
weight,
|
||||
reps,
|
||||
onWeightChange,
|
||||
onRepsChange,
|
||||
isAddingSet,
|
||||
isRemovingItem,
|
||||
}: {
|
||||
item: WorkoutItemType;
|
||||
exercises: CatalogEntity[] | undefined;
|
||||
equipment: CatalogEntity[] | undefined;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onRemoveItem: () => void;
|
||||
onAddSet: (() => void) | undefined;
|
||||
onRemoveSet: (setId: string) => void;
|
||||
weight: number;
|
||||
reps: number;
|
||||
onWeightChange: (v: number) => void;
|
||||
onRepsChange: (v: number) => 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]);
|
||||
|
||||
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>
|
||||
)}
|
||||
{onAddSet && (
|
||||
<div className="add-set-row">
|
||||
<input
|
||||
type="number"
|
||||
value={weight}
|
||||
onChange={(e) => onWeightChange(Number(e.target.value))}
|
||||
placeholder="Вес"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={reps}
|
||||
onChange={(e) => onRepsChange(Number(e.target.value))}
|
||||
placeholder="Повторы"
|
||||
/>
|
||||
<button className="primary" disabled={isAddingSet} onClick={onAddSet}>
|
||||
{isAddingSet ? "..." : "Подход"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function History({ token }: { token: string }) {
|
||||
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
|
||||
return (
|
||||
|
||||
@@ -21,6 +21,9 @@ async function request<T>(path: string, options: RequestInit = {}, token?: strin
|
||||
const body = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail));
|
||||
}
|
||||
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
||||
return undefined as T;
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -72,6 +75,18 @@ export const api = {
|
||||
addWorkoutSet(token: string, itemId: string, payload: Partial<WorkoutSet>) {
|
||||
return request<WorkoutSet>(`/workout-items/${itemId}/sets`, { method: "POST", 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) {
|
||||
const params = new URLSearchParams({ kind });
|
||||
if (entityId) params.set("entity_id", entityId);
|
||||
|
||||
+55
-1
@@ -54,6 +54,54 @@ input, select { width: 100%; border: 1px solid #d1d5db; border-radius: 14px; pad
|
||||
.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: 48px 32px; display: flex; flex-direction: column; align-items: center; gap: 16px; }
|
||||
.workout-cta h3 { margin: 0; font-size: 24px; }
|
||||
.workout-cta p { color: #6b7280; max-width: 420px; }
|
||||
.workout-live-stats { display: flex; gap: 10px; align-items: center; }
|
||||
.timer-badge { background: #151515; color: #dbff5b; font-family: "SF Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; font-size: 18px; font-weight: 800; padding: 8px 16px; border-radius: 14px; letter-spacing: 0.04em; }
|
||||
.kcal-badge { background: #f3e8ff; color: #7c3aed; font-weight: 800; padding: 8px 14px; border-radius: 14px; 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(170px, 1fr)); }
|
||||
.workout-pick-card { padding: 0; cursor: default; transition: box-shadow 0.2s; }
|
||||
.workout-pick-card.added { opacity: 0.55; }
|
||||
.workout-pick-card .image-placeholder { height: 100px; }
|
||||
.workout-pick-card img { height: 100px; }
|
||||
.workout-pick-card div:last-child { padding: 14px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.workout-pick-card h3 { margin: 0; font-size: 14px; }
|
||||
.pick-btn { border-radius: 10px; padding: 8px 12px; font-weight: 800; font-size: 12px; width: 100%; }
|
||||
.pick-btn.picked { background: #dcfce7; color: #166534; cursor: default; }
|
||||
.workout-cart { display: flex; flex-direction: column; gap: 16px; }
|
||||
.workout-cart > h3 { margin: 0; }
|
||||
.cart-items { display: flex; flex-direction: column; gap: 10px; }
|
||||
.cart-item { border: 1px solid rgba(17,24,39,0.08); border-radius: 16px; overflow: hidden; transition: border-color 0.2s; }
|
||||
.cart-item.expanded { border-color: #8a5cf6; }
|
||||
.cart-item-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; cursor: pointer; }
|
||||
.cart-item-header:hover { background: rgba(0,0,0,0.02); }
|
||||
.cart-item-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.cart-item-name { font-weight: 700; font-size: 15px; }
|
||||
.cart-item-summary { color: #6b7280; font-size: 13px; }
|
||||
.cart-item-remove { background: transparent; color: #9ca3af; font-size: 20px; padding: 4px 8px; border-radius: 8px; line-height: 1; }
|
||||
.cart-item-remove:hover { color: #b91c1c; background: #fef2f2; }
|
||||
.cart-item-body { border-top: 1px solid rgba(17,24,39,0.06); padding: 14px 18px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.sets-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.set-row { display: grid; grid-template-columns: 28px 1fr 70px 28px; gap: 8px; align-items: center; padding: 6px 0; }
|
||||
.set-index { font-weight: 800; color: #8a5cf6; font-size: 13px; }
|
||||
.set-data { font-size: 14px; font-weight: 600; }
|
||||
.set-cal { color: #6b7280; font-size: 12px; }
|
||||
.set-remove { background: transparent; color: #d1d5db; font-size: 16px; padding: 2px 6px; border-radius: 6px; line-height: 1; }
|
||||
.set-remove:hover { color: #b91c1c; background: #fef2f2; }
|
||||
.add-set-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; align-items: center; }
|
||||
.add-set-row input { padding: 10px 12px; font-size: 14px; border-radius: 12px; }
|
||||
.add-set-row button { padding: 10px 16px; font-size: 13px; white-space: nowrap; }
|
||||
.finish-btn { align-self: flex-end; margin-top: 8px; }
|
||||
.modal-overlay { position: fixed; inset: 0; z-index: 100; background: rgba(17,24,39,0.45); display: grid; place-items: center; backdrop-filter: blur(4px); }
|
||||
.modal-card { max-width: 440px; width: calc(100% - 32px); display: flex; flex-direction: column; gap: 18px; }
|
||||
.modal-card h2 { margin: 0; }
|
||||
.modal-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.modal-stats .metric { padding: 12px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 12px; }
|
||||
.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; }
|
||||
@@ -70,10 +118,16 @@ input, select { width: 100%; border: 1px solid #d1d5db; border-radius: 14px; pad
|
||||
.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; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
@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; }
|
||||
.workout-catalog-grid { grid-template-columns: 1fr; }
|
||||
.add-set-row { grid-template-columns: 1fr 1fr; }
|
||||
.add-set-row button { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user