Initial commit: Gravl MVP med onboarding
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# Gravl - Träningsapp
|
||||
|
||||
En enkel träningsapp för att följa PPL-program (Push/Pull/Legs) med progressionsspårning.
|
||||
|
||||
## Features
|
||||
|
||||
- 📋 **PPL Program** - 6-dagars Push/Pull/Legs split
|
||||
- 📊 **Träningslogg** - Logga vikt/reps för varje set
|
||||
- 📈 **Progression** - Automatiska viktrekommendationer
|
||||
- 📱 **Mobilanpassad** - Fungerar perfekt på telefon
|
||||
- 🌙 **Mörkt tema** - Bekvämt för gymmet
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend:** React (Vite) + CSS
|
||||
- **Backend:** Node.js/Express
|
||||
- **Database:** PostgreSQL
|
||||
- **Container:** Docker med nginx
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Initiera databasen
|
||||
psql -h localhost -U postgres -d gravl -f db/init.sql
|
||||
|
||||
# Starta med Docker Compose
|
||||
cd /workspace/gravl
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Åtkomst
|
||||
|
||||
- **URL:** https://gravl.homelab.local
|
||||
- **API:** https://gravl.homelab.local/api
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Beskrivning |
|
||||
|----------|--------|-------------|
|
||||
| `/api/health` | GET | Hälsokontroll |
|
||||
| `/api/programs` | GET | Lista alla program |
|
||||
| `/api/programs/:id` | GET | Hämta program med dagar |
|
||||
| `/api/days/:id/exercises` | GET | Hämta övningar för en dag |
|
||||
| `/api/logs` | GET | Hämta träningsloggar |
|
||||
| `/api/logs` | POST | Logga ett set |
|
||||
| `/api/progression/:id` | GET | Få viktrekommendation |
|
||||
|
||||
## Databasschema
|
||||
|
||||
- `programs` - Träningsprogram
|
||||
- `program_days` - Dagar i programmet (Push A, Pull A, etc.)
|
||||
- `exercises` - Övningar (Bench Press, Squat, etc.)
|
||||
- `program_exercises` - Kopplar övningar till dagar med sets/reps
|
||||
- `workout_logs` - Loggade träningsset
|
||||
|
||||
## Progression
|
||||
|
||||
Appen rekommenderar att öka vikten med 2.5kg när du når max reps på alla sets.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Gravl - Feature Roadmap
|
||||
|
||||
## 🔐 Onboarding & Signup
|
||||
- [ ] Registrering/inloggning (email + lösenord)
|
||||
- [ ] Onboarding-wizard med steg-för-steg guide
|
||||
|
||||
## 👤 Användarprofil
|
||||
- [ ] Kön
|
||||
- [ ] Ålder
|
||||
- [ ] Vikt
|
||||
- [ ] Kroppsmått för kroppsfettberäkning:
|
||||
- [ ] Hals
|
||||
- [ ] Mage
|
||||
- [ ] Höft (för kvinnor)
|
||||
- [ ] Automatisk kroppsfett-kalkylering (US Navy-metoden)
|
||||
|
||||
## 🎯 Mål & Erfarenhet
|
||||
- [ ] Ange träningserfarenhet (nybörjare/medel/avancerad)
|
||||
- [ ] Ange 1RM på basövningar (bänk, knäböj, marklyft)
|
||||
- [ ] Estimera startvik baserat på erfarenhet/1RM
|
||||
- [ ] Nybörjare startar lätt automatiskt
|
||||
- [ ] Ange träningsmål:
|
||||
- [ ] Styrka
|
||||
- [ ] Hypertrofi
|
||||
- [ ] Fettförbränning
|
||||
- [ ] Allmän fitness
|
||||
|
||||
## 📅 Träningsupplägg
|
||||
- [ ] Användaren anger antal pass/vecka
|
||||
- [ ] Generera anpassat program utifrån frekvens
|
||||
- [ ] Adaptiva pass som matchar mål
|
||||
- [ ] Progressiv överbelastning som pushar användaren
|
||||
|
||||
## 📊 Uppföljning & Benchmarks
|
||||
- [ ] Regelbundna benchmark-tester (var 4-6 vecka)
|
||||
- [ ] Progressgrafer (vikt, styrka, kroppsfett)
|
||||
- [ ] Jämförelse mot tidigare resultat
|
||||
- [ ] Notifikationer/påminnelser för benchmarks
|
||||
|
||||
## 📖 Övningsinformation
|
||||
- [ ] Dedikerad infosida per övning
|
||||
- [ ] Beskrivning av utförande
|
||||
- [ ] Muskelgrupper som tränas
|
||||
- [ ] Demo-video/animation
|
||||
- [ ] Länk till alternativa övningar
|
||||
- [ ] Tips & vanliga misstag
|
||||
|
||||
## 🔮 Framtida features
|
||||
- [ ] Social/dela resultat
|
||||
- [ ] Vila-timer med notis
|
||||
- [ ] Export av träningsdata
|
||||
- [ ] Apple Health / Google Fit integration
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY src ./src
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["npm", "start"]
|
||||
Generated
+1490
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "gravl-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Gravl Training App Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { Pool } = require('pg');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'gravl-secret-key-change-in-production';
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'postgres',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'homelab_postgres_2026',
|
||||
database: process.env.DB_NAME || 'gravl'
|
||||
});
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
const authMiddleware = (req, res, next) => {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) return res.status(401).json({ error: 'No token' });
|
||||
try {
|
||||
req.user = jwt.verify(token, JWT_SECRET);
|
||||
next();
|
||||
} catch { res.status(401).json({ error: 'Invalid token' }); }
|
||||
};
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.post('/api/auth/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const result = await pool.query(
|
||||
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email',
|
||||
[email.toLowerCase(), hash]
|
||||
);
|
||||
const token = jwt.sign({ id: result.rows[0].id, email: result.rows[0].email }, JWT_SECRET, { expiresIn: '30d' });
|
||||
res.json({ token, user: result.rows[0] });
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(400).json({ error: 'Email already exists' });
|
||||
console.error('Register error:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email.toLowerCase()]);
|
||||
if (!result.rows.length) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
const user = result.rows[0];
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '30d' });
|
||||
const { password_hash, ...safeUser } = user;
|
||||
res.json({ token, user: safeUser });
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/user/profile', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT id, email, gender, age, weight, neck_cm, waist_cm, hip_cm, experience_level, bench_1rm, squat_1rm, deadlift_1rm, goal, workouts_per_week, onboarding_complete FROM users WHERE id = $1', [req.user.id]);
|
||||
if (!result.rows.length) return res.status(404).json({ error: 'User not found' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error('Profile error:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/user/profile', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { gender, age, weight, neck_cm, waist_cm, hip_cm, experience_level, bench_1rm, squat_1rm, deadlift_1rm, goal, workouts_per_week, onboarding_complete } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE users SET gender=$1, age=$2, weight=$3, neck_cm=$4, waist_cm=$5, hip_cm=$6, experience_level=$7, bench_1rm=$8, squat_1rm=$9, deadlift_1rm=$10, goal=$11, workouts_per_week=$12, onboarding_complete=$13 WHERE id=$14 RETURNING id, email, gender, age, weight, neck_cm, waist_cm, hip_cm, experience_level, bench_1rm, squat_1rm, deadlift_1rm, goal, workouts_per_week, onboarding_complete`,
|
||||
[gender, age, weight, neck_cm, waist_cm, hip_cm, experience_level, bench_1rm, squat_1rm, deadlift_1rm, goal, workouts_per_week, onboarding_complete, req.user.id]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error('Update profile error:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all programs
|
||||
app.get('/api/programs', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM programs ORDER BY id');
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error('Error fetching programs:', err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get program details with days
|
||||
app.get('/api/programs/:id', async (req, res) => {
|
||||
try {
|
||||
const program = await pool.query('SELECT * FROM programs WHERE id = $1', [req.params.id]);
|
||||
if (program.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Program not found' });
|
||||
}
|
||||
|
||||
const days = await pool.query(`
|
||||
SELECT pd.*,
|
||||
json_agg(json_build_object(
|
||||
'id', pe.id,
|
||||
'exercise_id', e.id,
|
||||
'name', e.name,
|
||||
'muscle_group', e.muscle_group,
|
||||
'sets', pe.sets,
|
||||
'reps_min', pe.reps_min,
|
||||
'reps_max', pe.reps_max,
|
||||
'order', pe.order_num
|
||||
) ORDER BY pe.order_num) as exercises
|
||||
FROM program_days pd
|
||||
LEFT JOIN program_exercises pe ON pd.id = pe.program_day_id
|
||||
LEFT JOIN exercises e ON pe.exercise_id = e.id
|
||||
WHERE pd.program_id = $1
|
||||
GROUP BY pd.id
|
||||
ORDER BY pd.day_number
|
||||
`, [req.params.id]);
|
||||
|
||||
res.json({
|
||||
...program.rows[0],
|
||||
days: days.rows
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching program:', err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get exercises for a specific day
|
||||
app.get('/api/days/:dayId/exercises', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT pe.id, pe.sets, pe.reps_min, pe.reps_max, pe.order_num,
|
||||
e.id as exercise_id, e.name, e.muscle_group, e.description
|
||||
FROM program_exercises pe
|
||||
JOIN exercises e ON pe.exercise_id = e.id
|
||||
WHERE pe.program_day_id = $1
|
||||
ORDER BY pe.order_num
|
||||
`, [req.params.dayId]);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error('Error fetching exercises:', err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get workout logs for a user and date
|
||||
app.get('/api/logs', async (req, res) => {
|
||||
try {
|
||||
const { user_id, date, program_exercise_id } = req.query;
|
||||
let query = 'SELECT * FROM workout_logs WHERE 1=1';
|
||||
const params = [];
|
||||
|
||||
if (user_id) {
|
||||
params.push(user_id);
|
||||
query += ` AND user_id = $${params.length}`;
|
||||
}
|
||||
if (date) {
|
||||
params.push(date);
|
||||
query += ` AND date = $${params.length}`;
|
||||
}
|
||||
if (program_exercise_id) {
|
||||
params.push(program_exercise_id);
|
||||
query += ` AND program_exercise_id = $${params.length}`;
|
||||
}
|
||||
|
||||
query += ' ORDER BY date DESC, set_number ASC';
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error('Error fetching logs:', err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get last workout for an exercise (for progression)
|
||||
app.get('/api/logs/last/:programExerciseId', async (req, res) => {
|
||||
try {
|
||||
const { user_id } = req.query;
|
||||
const result = await pool.query(`
|
||||
SELECT * FROM workout_logs
|
||||
WHERE program_exercise_id = $1 AND user_id = $2
|
||||
ORDER BY date DESC, set_number ASC
|
||||
LIMIT 10
|
||||
`, [req.params.programExerciseId, user_id || 1]);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error('Error fetching last workout:', err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Log a set
|
||||
app.post('/api/logs', async (req, res) => {
|
||||
try {
|
||||
const { user_id, program_exercise_id, date, set_number, weight, reps, completed } = req.body;
|
||||
|
||||
// Check if log exists for this set
|
||||
const existing = await pool.query(
|
||||
'SELECT id FROM workout_logs WHERE user_id = $1 AND program_exercise_id = $2 AND date = $3 AND set_number = $4',
|
||||
[user_id, program_exercise_id, date, set_number]
|
||||
);
|
||||
|
||||
let result;
|
||||
if (existing.rows.length > 0) {
|
||||
// Update existing
|
||||
result = await pool.query(
|
||||
'UPDATE workout_logs SET weight = $1, reps = $2, completed = $3 WHERE id = $4 RETURNING *',
|
||||
[weight, reps, completed, existing.rows[0].id]
|
||||
);
|
||||
} else {
|
||||
// Insert new
|
||||
result = await pool.query(
|
||||
'INSERT INTO workout_logs (user_id, program_exercise_id, date, set_number, weight, reps, completed) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *',
|
||||
[user_id, program_exercise_id, date, set_number, weight, reps, completed]
|
||||
);
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error('Error logging set:', err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate suggested weight based on progression
|
||||
app.get('/api/progression/:programExerciseId', async (req, res) => {
|
||||
try {
|
||||
const { user_id } = req.query;
|
||||
|
||||
// Get exercise details
|
||||
const exerciseInfo = await pool.query(`
|
||||
SELECT pe.*, e.name FROM program_exercises pe
|
||||
JOIN exercises e ON pe.exercise_id = e.id
|
||||
WHERE pe.id = $1
|
||||
`, [req.params.programExerciseId]);
|
||||
|
||||
if (exerciseInfo.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Exercise not found' });
|
||||
}
|
||||
|
||||
const exercise = exerciseInfo.rows[0];
|
||||
|
||||
// Get last workout logs for this exercise
|
||||
const lastLogs = await pool.query(`
|
||||
SELECT * FROM workout_logs
|
||||
WHERE program_exercise_id = $1 AND user_id = $2 AND completed = true
|
||||
ORDER BY date DESC, set_number ASC
|
||||
LIMIT $3
|
||||
`, [req.params.programExerciseId, user_id || 1, exercise.sets]);
|
||||
|
||||
if (lastLogs.rows.length === 0) {
|
||||
return res.json({
|
||||
suggestedWeight: 20, // Starting weight
|
||||
reason: 'No previous data - start light'
|
||||
});
|
||||
}
|
||||
|
||||
const lastWeight = lastLogs.rows[0].weight;
|
||||
const allSetsHitMaxReps = lastLogs.rows.every(log => log.reps >= exercise.reps_max);
|
||||
|
||||
if (allSetsHitMaxReps) {
|
||||
// Progress: increase weight by 2.5kg
|
||||
return res.json({
|
||||
suggestedWeight: lastWeight + 2.5,
|
||||
reason: `Hit ${exercise.reps_max} reps on all sets - increase weight!`
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
suggestedWeight: lastWeight,
|
||||
reason: 'Keep same weight until you hit max reps on all sets'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error calculating progression:', err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get today's workout based on program day cycle
|
||||
app.get('/api/today/:programId', async (req, res) => {
|
||||
try {
|
||||
const { week } = req.query;
|
||||
const currentWeek = week || 1;
|
||||
|
||||
// Get program days
|
||||
const days = await pool.query(`
|
||||
SELECT pd.*,
|
||||
json_agg(json_build_object(
|
||||
'id', pe.id,
|
||||
'exercise_id', e.id,
|
||||
'name', e.name,
|
||||
'muscle_group', e.muscle_group,
|
||||
'sets', pe.sets,
|
||||
'reps_min', pe.reps_min,
|
||||
'reps_max', pe.reps_max,
|
||||
'order', pe.order_num
|
||||
) ORDER BY pe.order_num) as exercises
|
||||
FROM program_days pd
|
||||
LEFT JOIN program_exercises pe ON pd.id = pe.program_day_id
|
||||
LEFT JOIN exercises e ON pe.exercise_id = e.id
|
||||
WHERE pd.program_id = $1
|
||||
GROUP BY pd.id
|
||||
ORDER BY pd.day_number
|
||||
`, [req.params.programId]);
|
||||
|
||||
res.json({
|
||||
week: parseInt(currentWeek),
|
||||
days: days.rows
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching today workout:', err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Gravl API running on port ${PORT}`);
|
||||
});
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
-- Gravl Database Schema
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
gender VARCHAR(10),
|
||||
age INT,
|
||||
weight DECIMAL(5,1),
|
||||
neck_cm DECIMAL(4,1),
|
||||
waist_cm DECIMAL(4,1),
|
||||
hip_cm DECIMAL(4,1),
|
||||
experience_level VARCHAR(20),
|
||||
bench_1rm DECIMAL(5,1),
|
||||
squat_1rm DECIMAL(5,1),
|
||||
deadlift_1rm DECIMAL(5,1),
|
||||
goal VARCHAR(30),
|
||||
workouts_per_week INT,
|
||||
onboarding_complete BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Programs table
|
||||
CREATE TABLE IF NOT EXISTS programs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
weeks INTEGER DEFAULT 6,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Program days (e.g., Push, Pull, Legs)
|
||||
CREATE TABLE IF NOT EXISTS program_days (
|
||||
id SERIAL PRIMARY KEY,
|
||||
program_id INTEGER REFERENCES programs(id) ON DELETE CASCADE,
|
||||
day_number INTEGER NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Exercises master table
|
||||
CREATE TABLE IF NOT EXISTS exercises (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
muscle_group VARCHAR(100),
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Program exercises (which exercises on which day)
|
||||
CREATE TABLE IF NOT EXISTS program_exercises (
|
||||
id SERIAL PRIMARY KEY,
|
||||
program_day_id INTEGER REFERENCES program_days(id) ON DELETE CASCADE,
|
||||
exercise_id INTEGER REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
sets INTEGER DEFAULT 3,
|
||||
reps_min INTEGER DEFAULT 8,
|
||||
reps_max INTEGER DEFAULT 12,
|
||||
order_num INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Workout logs
|
||||
CREATE TABLE IF NOT EXISTS workout_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER DEFAULT 1,
|
||||
program_exercise_id INTEGER REFERENCES program_exercises(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
set_number INTEGER NOT NULL,
|
||||
weight DECIMAL(10,2),
|
||||
reps INTEGER,
|
||||
completed BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_workout_logs_user_date ON workout_logs(user_id, date);
|
||||
CREATE INDEX IF NOT EXISTS idx_workout_logs_exercise ON workout_logs(program_exercise_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_program_days_program ON program_days(program_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_program_exercises_day ON program_exercises(program_day_id);
|
||||
|
||||
-- Insert PPL Program
|
||||
INSERT INTO programs (name, description, weeks) VALUES
|
||||
('Push/Pull/Legs', 'Classic 6-day PPL split for strength and hypertrophy. 6-week progressive program.', 6)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Insert exercises
|
||||
INSERT INTO exercises (name, muscle_group, description) VALUES
|
||||
-- Push exercises
|
||||
('Bench Press', 'Chest', 'Barbell bench press - main chest compound'),
|
||||
('Overhead Press', 'Shoulders', 'Standing barbell overhead press'),
|
||||
('Incline Dumbbell Press', 'Chest', 'Incline dumbbell press for upper chest'),
|
||||
('Lateral Raises', 'Shoulders', 'Dumbbell lateral raises for side delts'),
|
||||
('Tricep Pushdowns', 'Triceps', 'Cable tricep pushdowns'),
|
||||
('Overhead Tricep Extension', 'Triceps', 'Cable or dumbbell overhead extension'),
|
||||
|
||||
-- Pull exercises
|
||||
('Deadlift', 'Back', 'Conventional deadlift - main posterior chain compound'),
|
||||
('Barbell Rows', 'Back', 'Bent over barbell rows'),
|
||||
('Pull-ups', 'Back', 'Bodyweight or weighted pull-ups'),
|
||||
('Face Pulls', 'Rear Delts', 'Cable face pulls for rear delts and rotator cuff'),
|
||||
('Barbell Curls', 'Biceps', 'Standing barbell curls'),
|
||||
('Hammer Curls', 'Biceps', 'Dumbbell hammer curls'),
|
||||
|
||||
-- Legs exercises
|
||||
('Squat', 'Quads', 'Barbell back squat - main leg compound'),
|
||||
('Romanian Deadlift', 'Hamstrings', 'RDL for hamstrings and glutes'),
|
||||
('Leg Press', 'Quads', 'Machine leg press'),
|
||||
('Leg Curls', 'Hamstrings', 'Lying or seated leg curls'),
|
||||
('Calf Raises', 'Calves', 'Standing or seated calf raises'),
|
||||
('Walking Lunges', 'Quads', 'Dumbbell walking lunges')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Insert program days for PPL
|
||||
INSERT INTO program_days (program_id, day_number, name) VALUES
|
||||
(1, 1, 'Push A'),
|
||||
(1, 2, 'Pull A'),
|
||||
(1, 3, 'Legs A'),
|
||||
(1, 4, 'Push B'),
|
||||
(1, 5, 'Pull B'),
|
||||
(1, 6, 'Legs B')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Insert program exercises
|
||||
-- Push A (day 1)
|
||||
INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps_max, order_num) VALUES
|
||||
(1, 1, 4, 6, 8, 1), -- Bench Press 4x6-8
|
||||
(1, 2, 3, 8, 10, 2), -- OHP 3x8-10
|
||||
(1, 3, 3, 10, 12, 3), -- Incline DB Press 3x10-12
|
||||
(1, 4, 3, 12, 15, 4), -- Lateral Raises 3x12-15
|
||||
(1, 5, 3, 10, 12, 5), -- Tricep Pushdowns 3x10-12
|
||||
(1, 6, 3, 10, 12, 6) -- Overhead Extension 3x10-12
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Pull A (day 2)
|
||||
INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps_max, order_num) VALUES
|
||||
(2, 7, 4, 5, 6, 1), -- Deadlift 4x5-6
|
||||
(2, 8, 4, 8, 10, 2), -- Barbell Rows 4x8-10
|
||||
(2, 9, 3, 6, 10, 3), -- Pull-ups 3x6-10
|
||||
(2, 10, 3, 15, 20, 4), -- Face Pulls 3x15-20
|
||||
(2, 11, 3, 10, 12, 5), -- Barbell Curls 3x10-12
|
||||
(2, 12, 3, 10, 12, 6) -- Hammer Curls 3x10-12
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Legs A (day 3)
|
||||
INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps_max, order_num) VALUES
|
||||
(3, 13, 4, 6, 8, 1), -- Squat 4x6-8
|
||||
(3, 14, 3, 10, 12, 2), -- RDL 3x10-12
|
||||
(3, 15, 3, 10, 12, 3), -- Leg Press 3x10-12
|
||||
(3, 16, 3, 12, 15, 4), -- Leg Curls 3x12-15
|
||||
(3, 17, 4, 12, 15, 5) -- Calf Raises 4x12-15
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Push B (day 4)
|
||||
INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps_max, order_num) VALUES
|
||||
(4, 2, 4, 6, 8, 1), -- OHP 4x6-8 (main lift)
|
||||
(4, 1, 3, 8, 10, 2), -- Bench Press 3x8-10
|
||||
(4, 3, 3, 10, 12, 3), -- Incline DB Press 3x10-12
|
||||
(4, 4, 4, 12, 15, 4), -- Lateral Raises 4x12-15
|
||||
(4, 5, 3, 10, 12, 5), -- Tricep Pushdowns 3x10-12
|
||||
(4, 6, 3, 10, 12, 6) -- Overhead Extension 3x10-12
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Pull B (day 5)
|
||||
INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps_max, order_num) VALUES
|
||||
(5, 8, 4, 6, 8, 1), -- Barbell Rows 4x6-8 (main lift)
|
||||
(5, 9, 4, 6, 10, 2), -- Pull-ups 4x6-10
|
||||
(5, 7, 3, 8, 10, 3), -- Deadlift 3x8-10 (lighter)
|
||||
(5, 10, 3, 15, 20, 4), -- Face Pulls 3x15-20
|
||||
(5, 11, 4, 10, 12, 5), -- Barbell Curls 4x10-12
|
||||
(5, 12, 3, 10, 12, 6) -- Hammer Curls 3x10-12
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Legs B (day 6)
|
||||
INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps_max, order_num) VALUES
|
||||
(6, 13, 3, 8, 10, 1), -- Squat 3x8-10
|
||||
(6, 14, 4, 8, 10, 2), -- RDL 4x8-10 (main lift)
|
||||
(6, 18, 3, 10, 12, 3), -- Walking Lunges 3x10-12
|
||||
(6, 16, 4, 10, 12, 4), -- Leg Curls 4x10-12
|
||||
(6, 17, 4, 12, 15, 5) -- Calf Raises 4x12-15
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,46 @@
|
||||
services:
|
||||
gravl-backend:
|
||||
container_name: gravl-backend
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_USER=postgres
|
||||
- DB_PASSWORD=homelab_postgres_2026
|
||||
- DB_NAME=gravl
|
||||
networks:
|
||||
- proxy
|
||||
- homelab
|
||||
expose:
|
||||
- "3001"
|
||||
|
||||
gravl-frontend:
|
||||
container_name: gravl-frontend
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- gravl-backend
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.gravl.rule=Host(`gravl.homelab.local`)"
|
||||
- "traefik.http.routers.gravl.entrypoints=web"
|
||||
- "traefik.http.routers.gravl.service=gravl"
|
||||
- "traefik.http.routers.gravl-secure.rule=Host(`gravl.homelab.local`)"
|
||||
- "traefik.http.routers.gravl-secure.entrypoints=websecure"
|
||||
- "traefik.http.routers.gravl-secure.tls=true"
|
||||
- "traefik.http.routers.gravl-secure.service=gravl"
|
||||
- "traefik.http.services.gravl.loadbalancer.server.port=80"
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
homelab:
|
||||
name: compose_homelab
|
||||
external: true
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="sv">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#1a1a2e" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<title>Gravl - Träning</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
# API proxy to backend
|
||||
location /api {
|
||||
proxy_pass http://gravl-backend:3001;
|
||||
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;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
Generated
+1753
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "gravl-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app.loading {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.week-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.week-selector button {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.week-selector button:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.week-selector button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.week-selector span {
|
||||
font-weight: 600;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Main */
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Program Info */
|
||||
.program-info {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.program-info h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.program-info p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Days List */
|
||||
.days-list h3 {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.day-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.day-card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.day-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.day-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.day-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.day-exercises {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.exercise-tag {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.exercise-tag.more {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.day-action {
|
||||
text-align: right;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Workout View */
|
||||
.workout-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Exercise Card */
|
||||
.exercise-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.exercise-card.expanded {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.exercise-header {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.exercise-info h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.muscle-group {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.exercise-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.sets-info {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.progress-badge {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-badge.complete {
|
||||
background: var(--success);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Exercise Body */
|
||||
.exercise-body {
|
||||
padding: 0 1rem 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.progression-hint {
|
||||
background: rgba(233, 69, 96, 0.1);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.progression-hint strong {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Sets List */
|
||||
.sets-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.set-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.set-row.completed {
|
||||
background: rgba(78, 204, 163, 0.15);
|
||||
border: 1px solid var(--success);
|
||||
}
|
||||
|
||||
.set-number {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 45px;
|
||||
}
|
||||
|
||||
.set-inputs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.weight-input,
|
||||
.reps-input {
|
||||
width: 70px;
|
||||
padding: 0.6rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.weight-input:focus,
|
||||
.reps-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input-separator {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.complete-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.25rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.complete-btn:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.complete-btn.done {
|
||||
background: var(--success);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.weight-input,
|
||||
.reps-input {
|
||||
width: 60px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area for notched phones */
|
||||
@supports (padding: env(safe-area-inset-bottom)) {
|
||||
.main {
|
||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from './context/AuthContext'
|
||||
import './App.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function App() {
|
||||
const { user, logout } = useAuth()
|
||||
const [view, setView] = useState('program')
|
||||
const [program, setProgram] = useState(null)
|
||||
const [selectedDay, setSelectedDay] = useState(null)
|
||||
const [currentWeek, setCurrentWeek] = useState(1)
|
||||
const [logs, setLogs] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const userId = user?.id || 1
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
useEffect(() => {
|
||||
fetchProgram()
|
||||
}, [])
|
||||
|
||||
const fetchProgram = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/programs/1`)
|
||||
const data = await res.json()
|
||||
setProgram(data)
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch program:', err)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLogs = async (dayId) => {
|
||||
try {
|
||||
const day = program.days.find(d => d.id === dayId)
|
||||
if (!day) return
|
||||
|
||||
const newLogs = {}
|
||||
for (const exercise of day.exercises) {
|
||||
if (!exercise.id) continue
|
||||
const res = await fetch(`${API_URL}/logs?user_id=${userId}&date=${today}&program_exercise_id=${exercise.id}`)
|
||||
const data = await res.json()
|
||||
newLogs[exercise.id] = data
|
||||
}
|
||||
setLogs(newLogs)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch logs:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProgression = async (programExerciseId) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/progression/${programExerciseId}?user_id=${userId}`)
|
||||
return await res.json()
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch progression:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const logSet = async (programExerciseId, setNumber, weight, reps, completed) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/logs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
program_exercise_id: programExerciseId,
|
||||
date: today,
|
||||
set_number: setNumber,
|
||||
weight: parseFloat(weight) || 0,
|
||||
reps: parseInt(reps) || 0,
|
||||
completed
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
// Update local logs
|
||||
setLogs(prev => ({
|
||||
...prev,
|
||||
[programExerciseId]: [
|
||||
...(prev[programExerciseId] || []).filter(l => l.set_number !== setNumber),
|
||||
data
|
||||
].sort((a, b) => a.set_number - b.set_number)
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Failed to log set:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const startWorkout = (day) => {
|
||||
setSelectedDay(day)
|
||||
setView('workout')
|
||||
fetchLogs(day.id)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="app loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Laddar program...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (view === 'workout' && selectedDay) {
|
||||
return (
|
||||
<WorkoutView
|
||||
day={selectedDay}
|
||||
week={currentWeek}
|
||||
logs={logs}
|
||||
onLogSet={logSet}
|
||||
onBack={() => setView('program')}
|
||||
fetchProgression={fetchProgression}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
<h1>🏋️ Gravl</h1>
|
||||
<button className="logout-btn" onClick={logout}>Logga ut</button>
|
||||
</div>
|
||||
<div className="week-selector">
|
||||
<button
|
||||
onClick={() => setCurrentWeek(w => Math.max(1, w - 1))}
|
||||
disabled={currentWeek === 1}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span>Vecka {currentWeek}</span>
|
||||
<button
|
||||
onClick={() => setCurrentWeek(w => Math.min(program?.weeks || 6, w + 1))}
|
||||
disabled={currentWeek === (program?.weeks || 6)}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="main">
|
||||
<section className="program-info">
|
||||
<h2>{program?.name || 'Laddar...'}</h2>
|
||||
<p>{program?.description}</p>
|
||||
</section>
|
||||
|
||||
<section className="days-list">
|
||||
<h3>Veckans pass</h3>
|
||||
{program?.days?.map((day, idx) => (
|
||||
<div key={day.id} className="day-card" onClick={() => startWorkout(day)}>
|
||||
<div className="day-header">
|
||||
<span className="day-number">Dag {day.day_number}</span>
|
||||
<span className="day-name">{day.name}</span>
|
||||
</div>
|
||||
<div className="day-exercises">
|
||||
{day.exercises?.filter(e => e.name).slice(0, 3).map((ex, i) => (
|
||||
<span key={i} className="exercise-tag">{ex.name}</span>
|
||||
))}
|
||||
{day.exercises?.filter(e => e.name).length > 3 && (
|
||||
<span className="exercise-tag more">+{day.exercises.filter(e => e.name).length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="day-action">
|
||||
Starta →
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkoutView({ day, week, logs, onLogSet, onBack, fetchProgression }) {
|
||||
const [progressions, setProgressions] = useState({})
|
||||
const [expandedExercise, setExpandedExercise] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadProgressions()
|
||||
}, [day])
|
||||
|
||||
const loadProgressions = async () => {
|
||||
const progs = {}
|
||||
for (const exercise of day.exercises) {
|
||||
if (exercise.id) {
|
||||
progs[exercise.id] = await fetchProgression(exercise.id)
|
||||
}
|
||||
}
|
||||
setProgressions(progs)
|
||||
}
|
||||
|
||||
const exercises = day.exercises?.filter(e => e.name) || []
|
||||
|
||||
return (
|
||||
<div className="app workout-view">
|
||||
<header className="header workout-header">
|
||||
<button className="back-btn" onClick={onBack}>← Tillbaka</button>
|
||||
<div className="header-title">
|
||||
<h1>{day.name}</h1>
|
||||
<span className="header-subtitle">Vecka {week} • Dag {day.day_number}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="main workout-main">
|
||||
{exercises.map((exercise, idx) => (
|
||||
<ExerciseCard
|
||||
key={exercise.id || idx}
|
||||
exercise={exercise}
|
||||
logs={logs[exercise.id] || []}
|
||||
progression={progressions[exercise.id]}
|
||||
expanded={expandedExercise === exercise.id}
|
||||
onToggle={() => setExpandedExercise(
|
||||
expandedExercise === exercise.id ? null : exercise.id
|
||||
)}
|
||||
onLogSet={onLogSet}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet }) {
|
||||
const [setInputs, setSetInputs] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize with suggested weight or last logged
|
||||
const initial = {}
|
||||
for (let i = 1; i <= exercise.sets; i++) {
|
||||
const existingLog = logs.find(l => l.set_number === i)
|
||||
initial[i] = {
|
||||
weight: existingLog?.weight || progression?.suggestedWeight || '',
|
||||
reps: existingLog?.reps || '',
|
||||
completed: existingLog?.completed || false
|
||||
}
|
||||
}
|
||||
setSetInputs(initial)
|
||||
}, [exercise, logs, progression])
|
||||
|
||||
const handleInputChange = (setNum, field, value) => {
|
||||
setSetInputs(prev => ({
|
||||
...prev,
|
||||
[setNum]: { ...prev[setNum], [field]: value }
|
||||
}))
|
||||
}
|
||||
|
||||
const handleComplete = (setNum) => {
|
||||
const input = setInputs[setNum]
|
||||
const newCompleted = !input.completed
|
||||
setSetInputs(prev => ({
|
||||
...prev,
|
||||
[setNum]: { ...prev[setNum], completed: newCompleted }
|
||||
}))
|
||||
onLogSet(exercise.id, setNum, input.weight, input.reps, newCompleted)
|
||||
}
|
||||
|
||||
const completedSets = Object.values(setInputs).filter(s => s.completed).length
|
||||
|
||||
return (
|
||||
<div className={`exercise-card ${expanded ? 'expanded' : ''}`}>
|
||||
<div className="exercise-header" onClick={onToggle}>
|
||||
<div className="exercise-info">
|
||||
<h3>{exercise.name}</h3>
|
||||
<span className="muscle-group">{exercise.muscle_group}</span>
|
||||
</div>
|
||||
<div className="exercise-meta">
|
||||
<span className="sets-info">{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
|
||||
<span className={`progress-badge ${completedSets === exercise.sets ? 'complete' : ''}`}>
|
||||
{completedSets}/{exercise.sets}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="exercise-body">
|
||||
{progression && (
|
||||
<div className="progression-hint">
|
||||
💡 {progression.reason}
|
||||
{progression.suggestedWeight && (
|
||||
<strong> → {progression.suggestedWeight} kg</strong>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sets-list">
|
||||
{Array.from({ length: exercise.sets }, (_, i) => i + 1).map(setNum => {
|
||||
const input = setInputs[setNum] || { weight: '', reps: '', completed: false }
|
||||
return (
|
||||
<div key={setNum} className={`set-row ${input.completed ? 'completed' : ''}`}>
|
||||
<span className="set-number">Set {setNum}</span>
|
||||
<div className="set-inputs">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="kg"
|
||||
value={input.weight}
|
||||
onChange={(e) => handleInputChange(setNum, 'weight', e.target.value)}
|
||||
className="weight-input"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<span className="input-separator">×</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="reps"
|
||||
value={input.reps}
|
||||
onChange={(e) => handleInputChange(setNum, 'reps', e.target.value)}
|
||||
className="reps-input"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className={`complete-btn ${input.completed ? 'done' : ''}`}
|
||||
onClick={() => handleComplete(setNum)}
|
||||
>
|
||||
{input.completed ? '✓' : '○'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
const API = '/api';
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) fetchProfile();
|
||||
else setLoading(false);
|
||||
}, [token]);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/user/profile`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (res.ok) setUser(await res.json());
|
||||
else logout();
|
||||
} catch { logout(); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const register = async (email, password) => {
|
||||
const res = await fetch(`${API}/auth/register`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
localStorage.setItem('token', data.token);
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
return data;
|
||||
};
|
||||
|
||||
const login = async (email, password) => {
|
||||
const res = await fetch(`${API}/auth/login`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
localStorage.setItem('token', data.token);
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
return data;
|
||||
};
|
||||
|
||||
const updateProfile = async (profile) => {
|
||||
const res = await fetch(`${API}/user/profile`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(profile)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setUser(data);
|
||||
return data;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, loading, register, login, logout, updateProfile }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
@@ -0,0 +1,109 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #0f0f1a;
|
||||
--bg-secondary: #1a1a2e;
|
||||
--bg-card: #16213e;
|
||||
--bg-card-hover: #1f3460;
|
||||
--text-primary: #eaeaea;
|
||||
--text-secondary: #a0a0a0;
|
||||
--accent: #e94560;
|
||||
--accent-hover: #ff6b6b;
|
||||
--success: #4ecca3;
|
||||
--warning: #ffd93d;
|
||||
--border: #2a2a4a;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input {
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Auth pages */
|
||||
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||
.auth-card { background: var(--bg-card); padding: 40px; border-radius: 16px; width: 100%; max-width: 400px; text-align: center; }
|
||||
.auth-card h1 { font-size: 2.5rem; margin-bottom: 8px; }
|
||||
.auth-card h2 { color: var(--text-secondary); font-weight: 400; margin-bottom: 24px; }
|
||||
.auth-card form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.auth-card input { padding: 14px 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 1rem; }
|
||||
.auth-card input:focus { border-color: var(--accent); }
|
||||
.auth-card button[type="submit"] { padding: 14px; background: var(--accent); color: white; border-radius: 8px; font-size: 1rem; font-weight: 600; transition: background 0.2s; }
|
||||
.auth-card button[type="submit"]:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.auth-card button:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.auth-card .error { background: rgba(233,69,96,0.15); color: var(--accent); padding: 12px; border-radius: 8px; margin-bottom: 16px; }
|
||||
.auth-link { margin-top: 20px; color: var(--text-secondary); }
|
||||
.auth-link a { color: var(--accent); text-decoration: none; }
|
||||
|
||||
/* Onboarding */
|
||||
.onboarding { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||
.onboarding-card { background: var(--bg-card); padding: 32px; border-radius: 16px; width: 100%; max-width: 480px; }
|
||||
.steps-indicator { display: flex; justify-content: center; gap: 12px; margin-bottom: 28px; }
|
||||
.steps-indicator span { width: 32px; height: 32px; border-radius: 50%; background: var(--bg-secondary); display: flex; align-items: center; justify-content: center; font-size: 0.875rem; color: var(--text-secondary); }
|
||||
.steps-indicator span.active { background: var(--accent); color: white; }
|
||||
.step h2 { margin-bottom: 20px; text-align: center; }
|
||||
.step .hint { color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 16px; text-align: center; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { display: block; margin-bottom: 8px; color: var(--text-secondary); font-size: 0.875rem; }
|
||||
.field input { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 1rem; }
|
||||
.field input:focus { border-color: var(--accent); }
|
||||
.btn-group { display: flex; gap: 8px; }
|
||||
.btn-group.vertical { flex-direction: column; }
|
||||
.btn-group button { flex: 1; padding: 12px; border-radius: 8px; background: var(--bg-secondary); color: var(--text-secondary); border: 1px solid var(--border); transition: all 0.2s; }
|
||||
.btn-group button:hover { border-color: var(--accent); }
|
||||
.btn-group button.active { background: var(--accent); color: white; border-color: var(--accent); }
|
||||
.rm-fields { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 8px; }
|
||||
.rm-fields .field { margin-bottom: 0; }
|
||||
.bodyfat-result { background: rgba(78,204,163,0.15); color: var(--success); padding: 16px; border-radius: 8px; text-align: center; margin: 16px 0; }
|
||||
.nav-btns { display: flex; gap: 12px; margin-top: 24px; }
|
||||
.nav-btns button { flex: 1; padding: 14px; border-radius: 8px; font-size: 1rem; }
|
||||
.nav-btns button:first-child { background: var(--bg-secondary); color: var(--text-secondary); }
|
||||
.next-btn, .finish-btn { background: var(--accent) !important; color: white !important; font-weight: 600; }
|
||||
.next-btn:hover:not(:disabled), .finish-btn:hover:not(:disabled) { background: var(--accent-hover) !important; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Header logout */
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.logout-btn { padding: 6px 12px; background: var(--bg-secondary); color: var(--text-secondary); border-radius: 6px; font-size: 0.75rem; }
|
||||
.logout-btn:hover { background: var(--border); }
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import App from './App.jsx'
|
||||
import RegisterPage from './pages/RegisterPage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import OnboardingWizard from './pages/OnboardingWizard'
|
||||
import './index.css'
|
||||
|
||||
function ProtectedRoute({ children, requireOnboarding = true }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <div className="app loading"><div className="spinner"></div></div>;
|
||||
if (!user) return <Navigate to="/login" />;
|
||||
if (requireOnboarding && !user.onboarding_complete) return <Navigate to="/onboarding" />;
|
||||
return children;
|
||||
}
|
||||
|
||||
function AuthRoute({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <div className="app loading"><div className="spinner"></div></div>;
|
||||
if (user?.onboarding_complete) return <Navigate to="/" />;
|
||||
if (user) return <Navigate to="/onboarding" />;
|
||||
return children;
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/register" element={<AuthRoute><RegisterPage /></AuthRoute>} />
|
||||
<Route path="/login" element={<AuthRoute><LoginPage /></AuthRoute>} />
|
||||
<Route path="/onboarding" element={<ProtectedRoute requireOnboarding={false}><OnboardingWizard /></ProtectedRoute>} />
|
||||
<Route path="/*" element={<ProtectedRoute><App /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const { user } = await login(email, password);
|
||||
navigate(user.onboarding_complete ? '/' : '/onboarding');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>🏋️ Gravl</h1>
|
||||
<h2>Logga in</h2>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input type="email" placeholder="E-post" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
<input type="password" placeholder="Lösenord" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
<button type="submit" disabled={loading}>{loading ? 'Loggar in...' : 'Logga in'}</button>
|
||||
</form>
|
||||
<p className="auth-link">Inget konto? <Link to="/register">Skapa konto</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const calcBodyFat = (gender, waist, neck, hip, height) => {
|
||||
if (!waist || !neck || !height) return null;
|
||||
if (gender === 'female' && !hip) return null;
|
||||
if (gender === 'male') return Math.max(0, 495 / (1.0324 - 0.19077 * Math.log10(waist - neck) + 0.15456 * Math.log10(height)) - 450).toFixed(1);
|
||||
return Math.max(0, 495 / (1.29579 - 0.35004 * Math.log10(waist + hip - neck) + 0.22100 * Math.log10(height)) - 450).toFixed(1);
|
||||
};
|
||||
|
||||
export default function OnboardingWizard() {
|
||||
const [step, setStep] = useState(1);
|
||||
const [data, setData] = useState({ gender: '', age: '', height_cm: '', weight: '', neck_cm: '', waist_cm: '', hip_cm: '', experience_level: '', bench_1rm: '', squat_1rm: '', deadlift_1rm: '', goal: '', workouts_per_week: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { updateProfile } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const update = (field, value) => setData(d => ({ ...d, [field]: value }));
|
||||
const bodyFat = calcBodyFat(data.gender, parseFloat(data.waist_cm), parseFloat(data.neck_cm), parseFloat(data.hip_cm), parseFloat(data.height_cm));
|
||||
|
||||
const finish = async () => {
|
||||
setSaving(true);
|
||||
await updateProfile({ ...data, onboarding_complete: true });
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="onboarding">
|
||||
<div className="onboarding-card">
|
||||
<div className="steps-indicator">
|
||||
{[1,2,3,4].map(s => <span key={s} className={step >= s ? 'active' : ''}>{s}</span>)}
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="step">
|
||||
<h2>Grundinfo</h2>
|
||||
<div className="field">
|
||||
<label>Kön</label>
|
||||
<div className="btn-group">
|
||||
<button className={data.gender === 'male' ? 'active' : ''} onClick={() => update('gender', 'male')}>Man</button>
|
||||
<button className={data.gender === 'female' ? 'active' : ''} onClick={() => update('gender', 'female')}>Kvinna</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Ålder</label>
|
||||
<input type="number" value={data.age} onChange={e => update('age', e.target.value)} placeholder="25" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Längd (cm)</label>
|
||||
<input type="number" value={data.height_cm} onChange={e => update('height_cm', e.target.value)} placeholder="175" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Vikt (kg)</label>
|
||||
<input type="number" step="0.1" value={data.weight} onChange={e => update('weight', e.target.value)} placeholder="75" />
|
||||
</div>
|
||||
<button className="next-btn" onClick={() => setStep(2)} disabled={!data.gender || !data.age || !data.height_cm || !data.weight}>Nästa →</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="step">
|
||||
<h2>Kroppsmått</h2>
|
||||
<p className="hint">För att beräkna kroppsfett (US Navy-metoden)</p>
|
||||
<div className="field">
|
||||
<label>Hals (cm)</label>
|
||||
<input type="number" step="0.1" value={data.neck_cm} onChange={e => update('neck_cm', e.target.value)} placeholder="38" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Mage/midja (cm)</label>
|
||||
<input type="number" step="0.1" value={data.waist_cm} onChange={e => update('waist_cm', e.target.value)} placeholder="85" />
|
||||
</div>
|
||||
{data.gender === 'female' && (
|
||||
<div className="field">
|
||||
<label>Höft (cm)</label>
|
||||
<input type="number" step="0.1" value={data.hip_cm} onChange={e => update('hip_cm', e.target.value)} placeholder="95" />
|
||||
</div>
|
||||
)}
|
||||
{bodyFat && <div className="bodyfat-result">Beräknat kroppsfett: <strong>{bodyFat}%</strong></div>}
|
||||
<div className="nav-btns">
|
||||
<button onClick={() => setStep(1)}>← Tillbaka</button>
|
||||
<button className="next-btn" onClick={() => setStep(3)}>Nästa →</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="step">
|
||||
<h2>Erfarenhet & styrka</h2>
|
||||
<div className="field">
|
||||
<label>Träningserfarenhet</label>
|
||||
<div className="btn-group vertical">
|
||||
{['beginner', 'intermediate', 'advanced'].map(l => (
|
||||
<button key={l} className={data.experience_level === l ? 'active' : ''} onClick={() => update('experience_level', l)}>
|
||||
{l === 'beginner' ? 'Nybörjare (<1 år)' : l === 'intermediate' ? 'Medel (1-3 år)' : 'Avancerad (3+ år)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="hint">1RM (valfritt)</p>
|
||||
<div className="rm-fields">
|
||||
<div className="field"><label>Bänk</label><input type="number" value={data.bench_1rm} onChange={e => update('bench_1rm', e.target.value)} placeholder="kg" /></div>
|
||||
<div className="field"><label>Knäböj</label><input type="number" value={data.squat_1rm} onChange={e => update('squat_1rm', e.target.value)} placeholder="kg" /></div>
|
||||
<div className="field"><label>Marklyft</label><input type="number" value={data.deadlift_1rm} onChange={e => update('deadlift_1rm', e.target.value)} placeholder="kg" /></div>
|
||||
</div>
|
||||
<div className="nav-btns">
|
||||
<button onClick={() => setStep(2)}>← Tillbaka</button>
|
||||
<button className="next-btn" onClick={() => setStep(4)} disabled={!data.experience_level}>Nästa →</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="step">
|
||||
<h2>Mål & schema</h2>
|
||||
<div className="field">
|
||||
<label>Mål</label>
|
||||
<div className="btn-group vertical">
|
||||
{['strength', 'muscle', 'fat_loss', 'general'].map(g => (
|
||||
<button key={g} className={data.goal === g ? 'active' : ''} onClick={() => update('goal', g)}>
|
||||
{g === 'strength' ? '💪 Styrka' : g === 'muscle' ? '🏋️ Muskelmassa' : g === 'fat_loss' ? '🔥 Fettförbränning' : '⚖️ Allmän fitness'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Pass per vecka</label>
|
||||
<div className="btn-group">
|
||||
{[3,4,5,6].map(n => (
|
||||
<button key={n} className={data.workouts_per_week == n ? 'active' : ''} onClick={() => update('workouts_per_week', n)}>{n}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="nav-btns">
|
||||
<button onClick={() => setStep(3)}>← Tillbaka</button>
|
||||
<button className="finish-btn" onClick={finish} disabled={!data.goal || !data.workouts_per_week || saving}>
|
||||
{saving ? 'Sparar...' : 'Starta träningen! 🚀'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(email, password);
|
||||
navigate('/onboarding');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>🏋️ Gravl</h1>
|
||||
<h2>Skapa konto</h2>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input type="email" placeholder="E-post" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
<input type="password" placeholder="Lösenord" value={password} onChange={e => setPassword(e.target.value)} required minLength={6} />
|
||||
<button type="submit" disabled={loading}>{loading ? 'Skapar...' : 'Skapa konto'}</button>
|
||||
</form>
|
||||
<p className="auth-link">Har redan konto? <Link to="/login">Logga in</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user