Add boto3 dependency and update exercise/machine assets
- Added boto3 as a dependency in pyproject.toml and uv.lock. - Introduced multiple new exercise images in various formats (jpg, webp, avif, png). - Added new machine images to enhance the workout assets library.
This commit is contained in:
@@ -1,15 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { CatalogEntity } from "../../types";
|
||||
import { categoryLabels, difficultyLabels, equipmentLabels, measurementLabels } from "./meta";
|
||||
|
||||
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>
|
||||
<div className="card-pills">
|
||||
<span className={entity.is_builtin ? "pill" : "pill user"}>{entity.is_builtin ? "стандартное" : "мое"}</span>
|
||||
<span className="pill kind">{entity.kind === "exercise" ? "упражнение" : "тренажер"}</span>
|
||||
</div>
|
||||
<h3>{entity.title}</h3>
|
||||
<small>{categoryLabels[entity.category]} · {equipmentLabels[entity.equipment]}</small>
|
||||
<p>{entity.description || "Без описания"}</p>
|
||||
<div className="card-pills muted-pills">
|
||||
<span>{measurementLabels[entity.measurement_type]}</span>
|
||||
<span>{difficultyLabels[entity.difficulty]}</span>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -3,34 +3,73 @@ 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 type { ActivityCategory, ActivityEquipment, CatalogKind, Difficulty, MeasurementType } from "../../types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { CatalogCard } from "./CatalogCard";
|
||||
import { useCatalog } from "./hooks";
|
||||
import { categoryLabels, categoryOptions, difficultyLabels, difficultyOptions, equipmentLabels, equipmentOptions, measurementLabels, measurementOptions } from "./meta";
|
||||
|
||||
export function CatalogPage({ kind }: { kind: CatalogKind }) {
|
||||
type CatalogView = CatalogKind | "mine";
|
||||
|
||||
const categoryTabs: Array<ActivityCategory | "all" | "arms"> = ["all", "chest", "back", "legs", "shoulders", "arms", "core", "cardio"];
|
||||
|
||||
function categoryTabLabel(category: ActivityCategory | "all" | "arms") {
|
||||
if (category === "all") return "Все";
|
||||
if (category === "arms") return "Руки";
|
||||
return categoryLabels[category];
|
||||
}
|
||||
|
||||
export function CatalogPage({ kind = "exercise" }: { kind?: CatalogView }) {
|
||||
const { auth } = useAuth();
|
||||
const token = auth.accessToken;
|
||||
const queryClient = useQueryClient();
|
||||
const { equipment, exercises } = useCatalog(token);
|
||||
const [name, setName] = useState("");
|
||||
const { sources } = useCatalog(token);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [category, setCategory] = useState<ActivityCategory>("other");
|
||||
const [equipment, setEquipment] = useState<ActivityEquipment>("other");
|
||||
const [measurementType, setMeasurementType] = useState<MeasurementType>("weight_reps");
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>("intermediate");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<ActivityCategory | "all" | "arms">("all");
|
||||
|
||||
const list = useMemo(() => (kind === "exercise" ? exercises.data : equipment.data) ?? [], [kind, exercises.data, equipment.data]);
|
||||
const createKind: CatalogKind = kind === "machine" ? "machine" : "exercise";
|
||||
const list = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
return (sources.data ?? []).filter((entity) => {
|
||||
if (kind === "mine") {
|
||||
if (entity.owner_user_id !== auth.user.id) return false;
|
||||
} else if (entity.kind !== kind) {
|
||||
return false;
|
||||
}
|
||||
if (categoryFilter === "arms" && entity.category !== "biceps" && entity.category !== "triceps") return false;
|
||||
if (categoryFilter !== "all" && categoryFilter !== "arms" && entity.category !== categoryFilter) return false;
|
||||
if (!needle) return true;
|
||||
return `${entity.title} ${entity.description ?? ""}`.toLowerCase().includes(needle);
|
||||
});
|
||||
}, [auth.user.id, categoryFilter, kind, search, sources.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);
|
||||
const image = file ? await api.uploadImage(token, createKind, file) : {};
|
||||
return api.createActivitySource(token, {
|
||||
kind: createKind,
|
||||
title,
|
||||
description: description || null,
|
||||
category,
|
||||
equipment,
|
||||
measurement_type: measurementType,
|
||||
difficulty,
|
||||
...image,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setFile(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["catalog", kind === "equipment" ? "equipment" : "exercises"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["catalog", "activity-sources"] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,18 +83,54 @@ export function CatalogPage({ kind }: { kind: CatalogKind }) {
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Catalog</p>
|
||||
<h2>{kind === "exercise" ? "Упражнения" : "Тренажеры"}</h2>
|
||||
<h2>{kind === "machine" ? "Тренажеры" : kind === "mine" ? "Мой каталог" : "Упражнения"}</h2>
|
||||
</div>
|
||||
<div className="segmented">
|
||||
<Link to="/catalog/exercises" activeProps={{ className: "active" }}>Упражнения</Link>
|
||||
<Link to="/catalog/equipment" activeProps={{ className: "active" }}>Тренажеры</Link>
|
||||
<Link to="/catalog" activeProps={{ className: "active" }} activeOptions={{ exact: true }}>Мои</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="card form-grid" onSubmit={submit}>
|
||||
<section className="card catalog-controls">
|
||||
<input placeholder="Поиск по названию" value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||
<div className="chip-row">
|
||||
{categoryTabs.map((tab) => (
|
||||
<button key={tab} className={categoryFilter === tab ? "active" : ""} onClick={() => setCategoryFilter(tab)}>
|
||||
{categoryTabLabel(tab)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form className="card form-grid catalog-form" onSubmit={submit}>
|
||||
<label>
|
||||
Название
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} required />
|
||||
<input value={title} onChange={(event) => setTitle(event.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Категория
|
||||
<select value={category} onChange={(event) => setCategory(event.target.value as ActivityCategory)}>
|
||||
{categoryOptions.map((option) => <option key={option} value={option}>{categoryLabels[option]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Оборудование
|
||||
<select value={equipment} onChange={(event) => setEquipment(event.target.value as ActivityEquipment)}>
|
||||
{equipmentOptions.map((option) => <option key={option} value={option}>{equipmentLabels[option]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Логирование
|
||||
<select value={measurementType} onChange={(event) => setMeasurementType(event.target.value as MeasurementType)}>
|
||||
{measurementOptions.map((option) => <option key={option} value={option}>{measurementLabels[option]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Сложность
|
||||
<select value={difficulty} onChange={(event) => setDifficulty(event.target.value as Difficulty)}>
|
||||
{difficultyOptions.map((option) => <option key={option} value={option}>{difficultyLabels[option]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Описание
|
||||
@@ -63,7 +138,7 @@ export function CatalogPage({ kind }: { kind: CatalogKind }) {
|
||||
</label>
|
||||
<label>
|
||||
Картинка
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp,image/avif" 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>}
|
||||
@@ -72,6 +147,7 @@ export function CatalogPage({ kind }: { kind: CatalogKind }) {
|
||||
<div className="catalog-grid">
|
||||
{list.map((entity) => <CatalogCard entity={entity} key={entity.id} />)}
|
||||
</div>
|
||||
{list.length === 0 && <p className="muted">Ничего не найдено.</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ 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 };
|
||||
const sources = useQuery({ queryKey: ["catalog", "activity-sources"], queryFn: () => api.activitySources(token) });
|
||||
const exercises = { ...sources, data: sources.data?.filter((source) => source.kind === "exercise") };
|
||||
const machines = { ...sources, data: sources.data?.filter((source) => source.kind === "machine") };
|
||||
return { sources, exercises, machines, equipment: machines };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ActivityCategory, ActivityEquipment, Difficulty, MeasurementType } from "../../types";
|
||||
|
||||
export const categoryLabels: Record<ActivityCategory, string> = {
|
||||
chest: "Грудь",
|
||||
back: "Спина",
|
||||
legs: "Ноги",
|
||||
shoulders: "Плечи",
|
||||
biceps: "Бицепс",
|
||||
triceps: "Трицепс",
|
||||
core: "Пресс",
|
||||
cardio: "Кардио",
|
||||
full_body: "Все тело",
|
||||
other: "Другое",
|
||||
};
|
||||
|
||||
export const equipmentLabels: Record<ActivityEquipment, string> = {
|
||||
barbell: "Штанга",
|
||||
dumbbell: "Гантели",
|
||||
machine: "Тренажер",
|
||||
cable: "Блок",
|
||||
bodyweight: "Свой вес",
|
||||
kettlebell: "Гиря",
|
||||
cardio_machine: "Кардио",
|
||||
other: "Другое",
|
||||
};
|
||||
|
||||
export const measurementLabels: Record<MeasurementType, string> = {
|
||||
weight_reps: "Вес × повторы",
|
||||
reps_only: "Повторы",
|
||||
duration: "Время",
|
||||
distance_duration: "Дистанция + время",
|
||||
duration_calories: "Время + ккал",
|
||||
};
|
||||
|
||||
export const difficultyLabels: Record<Difficulty, string> = {
|
||||
beginner: "Новичок",
|
||||
intermediate: "Средний",
|
||||
advanced: "Сложно",
|
||||
};
|
||||
|
||||
export const categoryOptions = Object.keys(categoryLabels) as ActivityCategory[];
|
||||
export const equipmentOptions = Object.keys(equipmentLabels) as ActivityEquipment[];
|
||||
export const measurementOptions = Object.keys(measurementLabels) as MeasurementType[];
|
||||
export const difficultyOptions = Object.keys(difficultyLabels) as Difficulty[];
|
||||
Reference in New Issue
Block a user