Compare commits

...

2 Commits

14 changed files with 367 additions and 26 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
__pycache__
*.pyc
node_modules
.env
data/
*.md
.vscode
.idea
+1
View File
@@ -0,0 +1 @@
JWT_SECRET=my-very-long-random-string-for-production
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.11-slim
WORKDIR /app
# Устанавливаем зависимости
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копируем код
COPY . .
# Создаём директорию для БД внутри контейнера (будет смонтирована volume)
RUN mkdir -p /data
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+5 -4
View File
@@ -1,18 +1,19 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
SQLALCHEMY_DATABASE_URL = "sqlite:///./gym.db"
# По умолчанию используем локальный файл, но в Docker мы зададим env var
DB_PATH = os.getenv("GYM_DB_PATH", "./gym.db")
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_PATH}"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False} # только для SQLite
connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Dependency
def get_db():
db = SessionLocal()
try:
+11
View File
@@ -1,3 +1,9 @@
<<<<<<< HEAD
#!/usr/bin/env python3
import os
=======
>>>>>>> ea21db141a6e6289f7763c3cee85f465519abea7
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -11,6 +17,11 @@ import routers.exercises
import routers.metrics
import routers.sync
<<<<<<< HEAD
# Гарантируем, что директория существует
os.makedirs(os.path.dirname(os.getenv("GYM_DB_PATH", "./gym.db")), exist_ok=True)
=======
>>>>>>> ea21db141a6e6289f7763c3cee85f465519abea7
Base.metadata.create_all(bind=engine)
+2
View File
@@ -1,3 +1,5 @@
-i https://mirror.yandex.ru/pypi/simple/
--trusted-host mirror.yandex.ru
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
+32
View File
@@ -0,0 +1,32 @@
version: '3.8'
services:
backend:
build: ./backend
container_name: gym-bro-backend
dns:
- 8.8.8.8
- 1.1.1.1
environment:
- GYM_JWT_SECRET=${JWT_SECRET:-super-secret-key-change-in-prod}
- GYM_DB_PATH=/data/gym.db
volumes:
- ./data:/data # Монтируем локальную папку ./data в /data контейнера
restart: unless-stopped
networks:
- gym-net
frontend:
build: ./frontend
container_name: gym-bro-frontend
ports:
- "80:80" # Или другой порт, если 80 занят
depends_on:
- backend
restart: unless-stopped
networks:
- gym-net
networks:
gym-net:
driver: bridge
+9
View File
@@ -0,0 +1,9 @@
FROM nginx:alpine
# Копируем конфиг
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Копируем статику
COPY . /usr/share/nginx/html
EXPOSE 80
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Gym Tracker</title>
<title>Gym Bro 😈</title>
<link rel="stylesheet" href="styles/main.css">
<link rel="stylesheet" href="styles/components.css">
<link rel="stylesheet" href="styles/workout.css">
+7 -3
View File
@@ -1,8 +1,12 @@
// Базовый URL API. Если фронт и бэк на одном домене, можно использовать '' или '/api'
const API_BASE = 'http://localhost:8000';
// frontend/js/api.js
import { apiFetch } from "./auth.js";
import { API_BASE } from "./config.js"; // <-- Добавили импорт
// УДАЛИТЬ эти строки из начала api.js:
// export const API_BASE = ...
export async function fetchWorkoutTypes() {
const res = await fetch(`${API_BASE}/workout-types/`);
const res = await apiFetch("/workout-types/");
if (!res.ok) throw new Error('Failed to fetch workout types');
return await res.json();
}
+148
View File
@@ -0,0 +1,148 @@
// frontend/js/auth.js
import { API_BASE } from "./config.js";
const TOKEN_KEY = "gym_auth_token";
const USER_KEY = "gym_auth_user";
/**
* Получить сохранённый токен
*/
export function getToken() {
return localStorage.getItem(TOKEN_KEY);
}
/**
* Получить сохранённого пользователя
*/
export function getStoredUser() {
try {
const raw = localStorage.getItem(USER_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
/**
* Сохранить сессию (токен + данные пользователя)
*/
export function setSession(token, user) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
/**
* Очистить сессию (Logout)
*/
export function clearSession() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
/**
* Защищённый Fetch wrapper.
* Автоматически добавляет Authorization header.
* При получении 401 очищает сессию и вызывает событие 'auth:unauthorized'.
*/
export async function apiFetch(path, options = {}) {
const token = getToken();
const headers = new Headers(options.headers || {});
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
// Если есть тело запроса и Content-Type не задан явно — ставим JSON
if (options.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
let res;
try {
res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
});
} catch (e) {
// Сеть недоступна или другая ошибка транспорта
// Не логаутим автоматически, просто пробрасываем ошибку
throw e;
}
// Если сервер ответил 401 Unauthorized — токен протух или неверный
if (res.status === 401) {
clearSession();
window.dispatchEvent(new Event("auth:unauthorized"));
}
return res;
}
/**
* Логин пользователя
*/
export async function login(username, password) {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const err = await parseError(res, "Login failed");
throw new Error(err);
}
const data = await res.json();
setSession(data.access_token, data.user);
return data.user;
}
/**
* Регистрация нового пользователя
*/
export async function register(username, password, role = "personal") {
const res = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, role }),
});
if (!res.ok) {
const err = await parseError(res, "Register failed");
throw new Error(err);
}
const data = await res.json();
setSession(data.access_token, data.user);
return data.user;
}
/**
* Проверка токена / получение текущего пользователя
*/
export async function me() {
const res = await apiFetch("/auth/me");
if (!res.ok) return null;
return await res.json();
}
/**
* Хелпер для парсинга ошибок FastAPI/Pydantic
*/
async function parseError(res, fallbackMessage) {
try {
const err = await res.json();
if (typeof err.detail === "string") return err.detail;
if (Array.isArray(err.detail)) {
return err.detail
.map((d) => `${d.loc?.join(".") || ""}: ${d.msg || ""}`.trim())
.join("; ");
}
return JSON.stringify(err.detail || err);
} catch {
return fallbackMessage || res.statusText;
}
}
+5
View File
@@ -0,0 +1,5 @@
// frontend/js/config.js
export const API_BASE =
window.GYM_API_BASE ||
localStorage.getItem("gym_api_base") ||
"/api";
+24
View File
@@ -0,0 +1,24 @@
server {
listen 80;
server_name localhost;
# Статика фронтенда
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html; # Для SPA роутинга (если понадобится)
}
# Проксирование API на бэкенд
location /api/ {
proxy_pass http://backend:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
# Важно: убираем префикс /api, так как бэк ожидает пути вроде /auth/login
rewrite ^/api/(.*)$ /$1 break;
}
}
+95 -18
View File
@@ -1,21 +1,98 @@
# gym bro
# Gym Bro 😈
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Python](https://img.shields.io/badge/python-3.11+-blue.svg)
![FastAPI](https://img.shields.io/badge/FastAPI-0.104-green.svg)
![Vue/JS](https://img.shields.io/badge/frontend-Vanilla_JS-yellow.svg)
![Docker](https://img.shields.io/badge/docker-ready-2496ED.svg)
**Gym Bro** — это легковесный, self-hosted трекер тренировок с поддержкой офлайн-режима, синхронизации и режима тренера. Без лишнего веса, только мышцы. 💪
## Особенности
* **Offline First:** Работает без интернета. Данные сохраняются локально (`localStorage`) и синхронизируются при появлении связи.
* **Multi-user Support:** Разделение данных между пользователями (JWT Auth). Каждый видит только свои тренировки.
* **Trainer Mode:** Режим для тренеров. Ведите несколько клиентов одновременно, присваивайте им уникальные цвета для визуального разделения.
* **Dropsets & History:** Поддержка дроп-сетов и мгновенная подсказка истории упражнений прямо во время подхода.
* **Body Metrics:** Гибкая система замеров тела (вес, объёмы, % жира) с историей изменений.
* **No Build Step for Frontend:** Чистый HTML/CSS/JS. Не нужен Webpack/Vite. Просто открой файл или залей на любой static host.
* **Single Binary Deployment:** Весь стек упакован в Docker Compose. Один командой поднимается БД, API и Веб-сервер.
## 🛠 Технологии
* **Backend:** Python 3.11, FastAPI, SQLAlchemy, SQLite, PyJWT, Bcrypt.
* **Frontend:** Vanilla JavaScript (ES Modules), CSS Variables, No Frameworks.
* **Infrastructure:** Docker, Nginx (as reverse proxy & static server).
## 🚀 Быстрый старт (Docker)
### Требования
* Docker Engine
* Docker Compose Plugin
### Установка
1. Клонируйте репозиторий:
```bash
git clone https://github.com/your-username/gym-bro.git
cd gym-bro
```
2. Настройте секреты (опционально, для продакшена обязательно):
``` bash
cp .env.example .env
# Отредактируйте .env, замените JWT_SECRET на длинную случайную строку
```
3. Соберите и запустите:
```bash
docker compose up -d --build
```
4. Откройте браузер:
```text
http://localhost
```
5. Зарегистрируйтесь и начинайте качаться!
### 📂 Структура проекта
``` text
clients (ФИО, ДР, цель)
│
│ 1:N
├──► workouts ──────► workout_types (справочник)
│ │
│ │ 1:N
│ ├──► workout_exercises ──► exercises ──► workout_types
│ │ │
│ │ │ 1:N
│ │ └──► sets ──► dropset_items
│ │
│ └──► cardio
│
└──► body_metrics
│
│ 1:N
└──► body_metric_values ──► metric_types (справочник)
gym-bro/
├── backend/ # FastAPI приложение
│ ├── routers/ # API эндпоинты
│ ├── models.py # SQLAlchemy модели
│ ├── schemas.py # Pydantic схемы
│ ├── auth.py # Логика JWT/Bcrypt
│ └── Dockerfile
├── frontend/ # Статические файлы
│ ├── js/ # ES модули логики
│ ├── styles/ # CSS
│ ├── index.html # Точка входа
│ ├── nginx.conf # Конфиг прокси
│ └── Dockerfile
├── data/ # Volume для SQLite (не коммитится!)
├── docker-compose.yml
└── README.md
```
### 🔐 Безопасность
- Пароли хэшируются с помощью bcrypt.
- Сессии управляются через JWT токены (HttpOnly cookie опция планируется).
- Все данные изолированы по owner_id на уровне БД.
- Внимание: Для продакшена обязательно измените JWT_SECRET в .env файле.
### 🤝 Вклад
Pull requests приветствуются!
1. Форкните проект.
2. Создайте ветку feature/amazing-feature.
3. Коммитьте изменения.
4. Пушьте в GitHub.
5. Открывайте Pull Request.
### 📄 Лицензия
MIT License.
----
Made with ❤️ and heavy weights by ivan-igorevich