Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
54
frontend/src/components/exercises/ExerciseCard.tsx
Executable file
54
frontend/src/components/exercises/ExerciseCard.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import type { Exercise } from '../../types';
|
||||
import { MUSCLE_GROUP_LABELS, MUSCLE_GROUP_COLORS } from '../../types';
|
||||
|
||||
interface ExerciseCardProps {
|
||||
exercise: Exercise;
|
||||
onEdit: (exercise: Exercise) => void;
|
||||
onDelete: (exercise: Exercise) => void;
|
||||
}
|
||||
|
||||
export function ExerciseCard({ exercise, onEdit, onDelete }: ExerciseCardProps) {
|
||||
const label = MUSCLE_GROUP_LABELS[exercise.muscle_group] || exercise.muscle_group;
|
||||
const color = MUSCLE_GROUP_COLORS[exercise.muscle_group] || 'bg-gray-600';
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-100 truncate">{exercise.name}</h3>
|
||||
{exercise.description && (
|
||||
<p className="text-sm text-gray-400 mt-1">{exercise.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`${color} text-white text-xs px-2 py-1 rounded-full`}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
Schritt: {exercise.weight_step_kg} kg
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => onEdit(exercise)}
|
||||
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-400 hover:text-blue-400 rounded-lg hover:bg-gray-800"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(exercise)}
|
||||
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-400 hover:text-red-400 rounded-lg hover:bg-gray-800"
|
||||
title="Löschen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
frontend/src/components/exercises/ExerciseForm.tsx
Executable file
116
frontend/src/components/exercises/ExerciseForm.tsx
Executable file
@@ -0,0 +1,116 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Exercise, CreateExerciseRequest, MuscleGroup } from '../../types';
|
||||
import { MUSCLE_GROUPS } from '../../types';
|
||||
|
||||
interface ExerciseFormProps {
|
||||
exercise?: Exercise | null;
|
||||
onSubmit: (data: CreateExerciseRequest) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [muscleGroup, setMuscleGroup] = useState<MuscleGroup>('brust');
|
||||
const [weightStep, setWeightStep] = useState(2.5);
|
||||
|
||||
useEffect(() => {
|
||||
if (exercise) {
|
||||
setName(exercise.name);
|
||||
setDescription(exercise.description);
|
||||
setMuscleGroup(exercise.muscle_group);
|
||||
setWeightStep(exercise.weight_step_kg);
|
||||
} else {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setMuscleGroup('brust');
|
||||
setWeightStep(2.5);
|
||||
}
|
||||
}, [exercise]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
muscle_group: muscleGroup,
|
||||
weight_step_kg: weightStep,
|
||||
});
|
||||
};
|
||||
|
||||
const isValid = name.trim().length > 0;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{exercise ? 'Übung bearbeiten' : 'Neue Übung'}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
placeholder="Übungsname"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[80px] resize-y"
|
||||
placeholder="Optionale Beschreibung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Muskelgruppe *</label>
|
||||
<select
|
||||
value={muscleGroup}
|
||||
onChange={(e) => setMuscleGroup(e.target.value as MuscleGroup)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
>
|
||||
{MUSCLE_GROUPS.map((mg) => (
|
||||
<option key={mg.value} value={mg.value}>
|
||||
{mg.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Gewichtsschritt (kg)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={weightStep}
|
||||
onChange={(e) => setWeightStep(parseFloat(e.target.value) || 0)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
min={0.25}
|
||||
step={0.25}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-200 min-h-[44px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white min-h-[44px] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{exercise ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/exercises/ExerciseList.tsx
Executable file
66
frontend/src/components/exercises/ExerciseList.tsx
Executable file
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useExerciseStore } from '../../stores/exerciseStore';
|
||||
import { ExerciseCard } from './ExerciseCard';
|
||||
import { MUSCLE_GROUPS } from '../../types';
|
||||
import type { Exercise, MuscleGroup } from '../../types';
|
||||
|
||||
interface ExerciseListProps {
|
||||
onEdit: (exercise: Exercise) => void;
|
||||
onDelete: (exercise: Exercise) => void;
|
||||
}
|
||||
|
||||
export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
|
||||
const { exercises, loading, filter, fetchExercises, setFilter } = useExerciseStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchExercises();
|
||||
}, [fetchExercises, filter.muscleGroup, filter.query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter-Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
value={filter.muscleGroup}
|
||||
onChange={(e) => setFilter({ muscleGroup: e.target.value as MuscleGroup | '' })}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
>
|
||||
<option value="">Alle Muskelgruppen</option>
|
||||
{MUSCLE_GROUPS.map((mg) => (
|
||||
<option key={mg.value} value={mg.value}>
|
||||
{mg.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={filter.query}
|
||||
onChange={(e) => setFilter({ query: e.target.value })}
|
||||
placeholder="Suche..."
|
||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 py-8">Laden...</div>
|
||||
) : exercises.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-12">
|
||||
<p className="text-lg">Noch keine Übungen angelegt</p>
|
||||
<p className="text-sm mt-1">Erstelle deine erste Übung mit dem Button oben.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{exercises.map((exercise) => (
|
||||
<ExerciseCard
|
||||
key={exercise.id}
|
||||
exercise={exercise}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
frontend/src/components/history/ExerciseChart.tsx
Executable file
113
frontend/src/components/history/ExerciseChart.tsx
Executable file
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { api } from '../../api/client';
|
||||
import { useExerciseStore } from '../../stores/exerciseStore';
|
||||
import type { SessionLog } from '../../types';
|
||||
|
||||
export function ExerciseChart() {
|
||||
const { exercises, fetchExercises } = useExerciseStore();
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [chartData, setChartData] = useState<{ date: string; weight: number }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExercises();
|
||||
}, [fetchExercises]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setChartData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
api.exercises
|
||||
.history(selectedId, 50)
|
||||
.then((logs: SessionLog[]) => {
|
||||
// Gruppiere nach Datum, nehme max Gewicht pro Tag
|
||||
const byDate = new Map<string, number>();
|
||||
for (const log of logs) {
|
||||
const date = new Date(log.logged_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
});
|
||||
const current = byDate.get(date) || 0;
|
||||
if (log.weight_kg > current) {
|
||||
byDate.set(date, log.weight_kg);
|
||||
}
|
||||
}
|
||||
const data = Array.from(byDate.entries())
|
||||
.map(([date, weight]) => ({ date, weight }))
|
||||
.reverse();
|
||||
setChartData(data);
|
||||
})
|
||||
.catch(() => setChartData([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedId]);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-3">Gewichtsverlauf</h3>
|
||||
|
||||
<select
|
||||
value={selectedId ?? ''}
|
||||
onChange={(e) =>
|
||||
setSelectedId(e.target.value ? parseInt(e.target.value) : null)
|
||||
}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px] mb-4"
|
||||
>
|
||||
<option value="">Übung auswählen...</option>
|
||||
{exercises.map((ex) => (
|
||||
<option key={ex.id} value={ex.id}>
|
||||
{ex.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 py-8">Laden...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
{selectedId
|
||||
? 'Keine Daten vorhanden'
|
||||
: 'Wähle eine Übung aus'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis dataKey="date" stroke="#9CA3AF" fontSize={12} />
|
||||
<YAxis stroke="#9CA3AF" fontSize={12} unit=" kg" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1F2937',
|
||||
border: '1px solid #374151',
|
||||
borderRadius: '8px',
|
||||
color: '#F3F4F6',
|
||||
}}
|
||||
formatter={(value) => [`${value} kg`, 'Max. Gewicht']}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="weight"
|
||||
stroke="#3B82F6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#3B82F6', r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/history/SessionDetail.tsx
Executable file
48
frontend/src/components/history/SessionDetail.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import type { SessionLog } from '../../types';
|
||||
|
||||
interface SessionDetailProps {
|
||||
logs: SessionLog[];
|
||||
}
|
||||
|
||||
export function SessionDetail({ logs }: SessionDetailProps) {
|
||||
if (!logs || logs.length === 0) {
|
||||
return <p className="text-sm text-gray-500 py-2">Keine Sätze aufgezeichnet.</p>;
|
||||
}
|
||||
|
||||
// Gruppiere nach Übung
|
||||
const grouped = new Map<number, { name: string; logs: SessionLog[] }>();
|
||||
for (const log of logs) {
|
||||
if (!grouped.has(log.exercise_id)) {
|
||||
grouped.set(log.exercise_id, { name: log.exercise_name, logs: [] });
|
||||
}
|
||||
grouped.get(log.exercise_id)!.logs.push(log);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from(grouped.entries()).map(([exerciseId, { name, logs: exLogs }]) => (
|
||||
<div key={exerciseId}>
|
||||
<h4 className="text-sm font-semibold text-gray-300 mb-1">{name}</h4>
|
||||
<div className="space-y-1">
|
||||
{exLogs
|
||||
.sort((a, b) => a.set_number - b.set_number)
|
||||
.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="flex items-center gap-2 text-sm text-gray-400 bg-gray-800 rounded px-3 py-1.5"
|
||||
>
|
||||
<span className="text-gray-500 w-14">Satz {log.set_number}</span>
|
||||
<span className="font-medium text-gray-200">
|
||||
{log.weight_kg} kg x {log.reps}
|
||||
</span>
|
||||
{log.note && (
|
||||
<span className="text-gray-500 text-xs ml-auto">({log.note})</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/history/SessionList.tsx
Executable file
107
frontend/src/components/history/SessionList.tsx
Executable file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useHistoryStore } from '../../stores/historyStore';
|
||||
import { SessionDetail } from './SessionDetail';
|
||||
import type { Session } from '../../types';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
export function SessionList() {
|
||||
const { sessions, loading, fetchSessions } = useHistoryStore();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [expandedSession, setExpandedSession] = useState<Session | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions(50);
|
||||
}, [fetchSessions]);
|
||||
|
||||
const toggleSession = async (id: number) => {
|
||||
if (expandedId === id) {
|
||||
setExpandedId(null);
|
||||
setExpandedSession(null);
|
||||
return;
|
||||
}
|
||||
setExpandedId(id);
|
||||
try {
|
||||
const session = await api.sessions.get(id);
|
||||
setExpandedSession(session);
|
||||
} catch {
|
||||
setExpandedSession(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDuration = (start: string, end?: string) => {
|
||||
if (!end) return 'laufend';
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime();
|
||||
const mins = Math.round(ms / 60000);
|
||||
if (mins < 60) return `${mins} Min.`;
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-500 py-8">Laden...</div>;
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-500 py-12">
|
||||
<p className="text-lg">Noch keine Trainings absolviert</p>
|
||||
<p className="text-sm mt-1">Starte dein erstes Training im Training-Tab.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleSession(session.id)}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-left hover:bg-gray-800/50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-100">{session.set_name}</div>
|
||||
<div className="text-sm text-gray-400 mt-0.5">
|
||||
{formatDate(session.started_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-400">
|
||||
{formatDuration(session.started_at, session.ended_at)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform ml-auto ${expandedId === session.id ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{expandedId === session.id && expandedSession && (
|
||||
<div className="px-4 pb-4 border-t border-gray-800 pt-3">
|
||||
<SessionDetail logs={expandedSession.logs || []} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/layout/BottomNav.tsx
Executable file
91
frontend/src/components/layout/BottomNav.tsx
Executable file
@@ -0,0 +1,91 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
to: '/',
|
||||
label: 'Übungen',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/sets',
|
||||
label: 'Sets',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/training',
|
||||
label: 'Training',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/history',
|
||||
label: 'Historie',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function BottomNav() {
|
||||
return (
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-800 z-40">
|
||||
<div className="flex justify-around">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center py-2 px-3 min-h-[44px] min-w-[44px] text-xs transition-colors ${
|
||||
isActive ? 'text-blue-500' : 'text-gray-400 hover:text-gray-200'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="mt-1">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<nav className="hidden md:flex flex-col w-56 bg-gray-900 border-r border-gray-800 min-h-screen p-4">
|
||||
<h1 className="text-xl font-bold text-blue-500 mb-8">Krafttrainer</h1>
|
||||
<div className="flex flex-col gap-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-3 rounded-lg min-h-[44px] transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/layout/ConfirmDialog.tsx
Executable file
40
frontend/src/components/layout/ConfirmDialog.tsx
Executable file
@@ -0,0 +1,40 @@
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 max-w-sm w-full mx-4 shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-2">{title}</h3>
|
||||
<p className="text-gray-300 mb-6">{message}</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-200 min-h-[44px] min-w-[44px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 rounded-lg bg-red-600 hover:bg-red-500 text-white min-h-[44px] min-w-[44px]"
|
||||
>
|
||||
Bestätigen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/layout/PageShell.tsx
Executable file
18
frontend/src/components/layout/PageShell.tsx
Executable file
@@ -0,0 +1,18 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { BottomNav, Sidebar } from './BottomNav';
|
||||
import { ToastContainer } from './Toast';
|
||||
|
||||
export function PageShell() {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 pb-20 md:pb-0">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<BottomNav />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/layout/Toast.tsx
Executable file
33
frontend/src/components/layout/Toast.tsx
Executable file
@@ -0,0 +1,33 @@
|
||||
import { useToastStore } from '../../stores/toastStore';
|
||||
|
||||
const colorMap = {
|
||||
success: 'bg-green-700 border-green-500',
|
||||
error: 'bg-red-700 border-red-500',
|
||||
info: 'bg-blue-700 border-blue-500',
|
||||
};
|
||||
|
||||
export function ToastContainer() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const removeToast = useToastStore((s) => s.removeToast);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${colorMap[toast.type]} border rounded-lg px-4 py-3 text-white shadow-lg flex items-center justify-between gap-2`}
|
||||
>
|
||||
<span className="text-sm">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="text-white/70 hover:text-white min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/sets/SetDetail.tsx
Executable file
33
frontend/src/components/sets/SetDetail.tsx
Executable file
@@ -0,0 +1,33 @@
|
||||
import type { TrainingSet } from '../../types';
|
||||
import { MUSCLE_GROUP_LABELS, MUSCLE_GROUP_COLORS } from '../../types';
|
||||
|
||||
interface SetDetailProps {
|
||||
trainingSet: TrainingSet;
|
||||
}
|
||||
|
||||
export function SetDetail({ trainingSet }: SetDetailProps) {
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<h3 className="font-semibold text-gray-100 text-lg mb-3">{trainingSet.name}</h3>
|
||||
{(!trainingSet.exercises || trainingSet.exercises.length === 0) ? (
|
||||
<p className="text-gray-500 text-sm">Keine Übungen in diesem Set.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{trainingSet.exercises.map((ex, index) => {
|
||||
const label = MUSCLE_GROUP_LABELS[ex.muscle_group] || ex.muscle_group;
|
||||
const color = MUSCLE_GROUP_COLORS[ex.muscle_group] || 'bg-gray-600';
|
||||
return (
|
||||
<div key={ex.id} className="flex items-center gap-3 bg-gray-800 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500 text-sm w-6">{index + 1}.</span>
|
||||
<span className="flex-1 text-gray-200">{ex.name}</span>
|
||||
<span className={`${color} text-white text-xs px-2 py-1 rounded-full`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
frontend/src/components/sets/SetForm.tsx
Executable file
193
frontend/src/components/sets/SetForm.tsx
Executable file
@@ -0,0 +1,193 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useExerciseStore } from '../../stores/exerciseStore';
|
||||
import type { TrainingSet, MuscleGroup } from '../../types';
|
||||
import { MUSCLE_GROUPS, MUSCLE_GROUP_LABELS } from '../../types';
|
||||
|
||||
interface SetFormProps {
|
||||
trainingSet?: TrainingSet | null;
|
||||
onSubmit: (name: string, exerciseIds: number[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
|
||||
const { exercises, fetchExercises } = useExerciseStore();
|
||||
const [name, setName] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [filterMg, setFilterMg] = useState<MuscleGroup | ''>('');
|
||||
|
||||
useEffect(() => {
|
||||
// Lade alle Übungen ohne Filter
|
||||
useExerciseStore.getState().setFilter({ muscleGroup: '', query: '' });
|
||||
fetchExercises();
|
||||
}, [fetchExercises]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trainingSet) {
|
||||
setName(trainingSet.name);
|
||||
setSelectedIds(trainingSet.exercises?.map((e) => e.id) || []);
|
||||
} else {
|
||||
setName('');
|
||||
setSelectedIds([]);
|
||||
}
|
||||
}, [trainingSet]);
|
||||
|
||||
const toggleExercise = (id: number) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const moveUp = (index: number) => {
|
||||
if (index === 0) return;
|
||||
setSelectedIds((prev) => {
|
||||
const next = [...prev];
|
||||
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const moveDown = (index: number) => {
|
||||
if (index >= selectedIds.length - 1) return;
|
||||
setSelectedIds((prev) => {
|
||||
const next = [...prev];
|
||||
[next[index], next[index + 1]] = [next[index + 1], next[index]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(name.trim(), selectedIds);
|
||||
};
|
||||
|
||||
const filteredExercises = filterMg
|
||||
? exercises.filter((e) => e.muscle_group === filterMg)
|
||||
: exercises;
|
||||
|
||||
const isValid = name.trim().length > 0 && selectedIds.length > 0;
|
||||
|
||||
const exerciseMap = new Map(exercises.map((e) => [e.id, e]));
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{trainingSet ? 'Set bearbeiten' : 'Neues Set'}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
placeholder="Set-Name"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Übungsauswahl */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Übungen auswählen *</label>
|
||||
<select
|
||||
value={filterMg}
|
||||
onChange={(e) => setFilterMg(e.target.value as MuscleGroup | '')}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px] mb-2"
|
||||
>
|
||||
<option value="">Alle Muskelgruppen</option>
|
||||
{MUSCLE_GROUPS.map((mg) => (
|
||||
<option key={mg.value} value={mg.value}>
|
||||
{mg.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto space-y-1 border border-gray-700 rounded-lg p-2">
|
||||
{filteredExercises.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 py-2 text-center">Keine Übungen gefunden</p>
|
||||
) : (
|
||||
filteredExercises.map((ex) => (
|
||||
<label
|
||||
key={ex.id}
|
||||
className="flex items-center gap-2 px-2 py-2 rounded hover:bg-gray-800 cursor-pointer min-h-[44px]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(ex.id)}
|
||||
onChange={() => toggleExercise(ex.id)}
|
||||
className="w-5 h-5 accent-blue-500"
|
||||
/>
|
||||
<span className="flex-1 text-gray-200">{ex.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{MUSCLE_GROUP_LABELS[ex.muscle_group]}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sortierbare ausgewählte Übungen */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Reihenfolge ({selectedIds.length} ausgewählt)
|
||||
</label>
|
||||
<div className="space-y-1 border border-gray-700 rounded-lg p-2">
|
||||
{selectedIds.map((id, index) => {
|
||||
const ex = exerciseMap.get(id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-center gap-2 bg-gray-800 rounded-lg px-3 py-2"
|
||||
>
|
||||
<span className="text-gray-500 text-sm w-6">{index + 1}.</span>
|
||||
<span className="flex-1 text-gray-200 text-sm">
|
||||
{ex?.name || `Übung #${id}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveUp(index)}
|
||||
disabled={index === 0}
|
||||
className="min-h-[36px] min-w-[36px] flex items-center justify-center text-gray-400 hover:text-gray-200 disabled:opacity-30"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveDown(index)}
|
||||
disabled={index === selectedIds.length - 1}
|
||||
className="min-h-[36px] min-w-[36px] flex items-center justify-center text-gray-400 hover:text-gray-200 disabled:opacity-30"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-200 min-h-[44px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white min-h-[44px] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{trainingSet ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/sets/SetList.tsx
Executable file
81
frontend/src/components/sets/SetList.tsx
Executable file
@@ -0,0 +1,81 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSetStore } from '../../stores/setStore';
|
||||
import type { TrainingSet } from '../../types';
|
||||
|
||||
interface SetListProps {
|
||||
onEdit: (set: TrainingSet) => void;
|
||||
onDelete: (set: TrainingSet) => void;
|
||||
}
|
||||
|
||||
export function SetList({ onEdit, onDelete }: SetListProps) {
|
||||
const { sets, loading, fetchSets } = useSetStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSets();
|
||||
}, [fetchSets]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-500 py-8">Laden...</div>;
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-500 py-12">
|
||||
<p className="text-lg">Noch keine Sets angelegt</p>
|
||||
<p className="text-sm mt-1">Erstelle dein erstes Set mit dem Button oben.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{sets.map((set) => (
|
||||
<div
|
||||
key={set.id}
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-100">{set.name}</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{set.exercises?.length || 0} Übungen
|
||||
</p>
|
||||
{set.exercises && set.exercises.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{set.exercises.map((ex) => (
|
||||
<span
|
||||
key={ex.id}
|
||||
className="text-xs bg-gray-800 text-gray-300 px-2 py-1 rounded"
|
||||
>
|
||||
{ex.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => onEdit(set)}
|
||||
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-400 hover:text-blue-400 rounded-lg hover:bg-gray-800"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(set)}
|
||||
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-400 hover:text-red-400 rounded-lg hover:bg-gray-800"
|
||||
title="Löschen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
224
frontend/src/components/training/ActiveSession.tsx
Executable file
224
frontend/src/components/training/ActiveSession.tsx
Executable file
@@ -0,0 +1,224 @@
|
||||
import { useState } from 'react';
|
||||
import { useActiveSessionStore } from '../../stores/activeSessionStore';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { ConfirmDialog } from '../layout/ConfirmDialog';
|
||||
import { LogEntryForm } from './LogEntryForm';
|
||||
import { RestTimer } from './RestTimer';
|
||||
import type { Exercise, SessionLog } from '../../types';
|
||||
|
||||
interface ActiveSessionProps {
|
||||
onEnd: () => void;
|
||||
}
|
||||
|
||||
export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
||||
const { session, exercises, lastLogs, addLog, updateLog, deleteLog, endSession } =
|
||||
useActiveSessionStore();
|
||||
const [expandedExercise, setExpandedExercise] = useState<number | null>(null);
|
||||
const [editingLog, setEditingLog] = useState<SessionLog | null>(null);
|
||||
const { isOpen, title, message, confirm, handleConfirm, handleCancel } = useConfirm();
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const logs = session.logs || [];
|
||||
|
||||
const getExerciseLogs = (exerciseId: number) =>
|
||||
logs
|
||||
.filter((l) => l.exercise_id === exerciseId)
|
||||
.sort((a, b) => a.set_number - b.set_number);
|
||||
|
||||
const getNextSetNumber = (exerciseId: number) => {
|
||||
const exLogs = getExerciseLogs(exerciseId);
|
||||
return exLogs.length > 0 ? Math.max(...exLogs.map((l) => l.set_number)) + 1 : 1;
|
||||
};
|
||||
|
||||
const handleAddLog = async (
|
||||
exercise: Exercise,
|
||||
weight: number,
|
||||
reps: number,
|
||||
note: string,
|
||||
) => {
|
||||
await addLog({
|
||||
exercise_id: exercise.id,
|
||||
set_number: getNextSetNumber(exercise.id),
|
||||
weight_kg: weight,
|
||||
reps,
|
||||
note,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateLog = async (
|
||||
log: SessionLog,
|
||||
weight: number,
|
||||
reps: number,
|
||||
note: string,
|
||||
) => {
|
||||
await updateLog(log.id, { weight_kg: weight, reps, note });
|
||||
setEditingLog(null);
|
||||
};
|
||||
|
||||
const handleDeleteLog = async (log: SessionLog) => {
|
||||
const ok = await confirm('Satz löschen', 'Möchtest du diesen Satz wirklich löschen?');
|
||||
if (ok) {
|
||||
await deleteLog(log.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndSession = async () => {
|
||||
const ok = await confirm(
|
||||
'Training beenden',
|
||||
'Möchtest du das Training wirklich beenden?',
|
||||
);
|
||||
if (ok) {
|
||||
const success = await endSession();
|
||||
if (success) onEnd();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExercise = (id: number) => {
|
||||
setExpandedExercise((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-100">{session.set_name}</h2>
|
||||
<span className="text-sm text-gray-400">
|
||||
{new Date(session.started_at).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RestTimer />
|
||||
|
||||
{/* Übungen als Accordion */}
|
||||
{exercises.map((exercise) => {
|
||||
const exLogs = getExerciseLogs(exercise.id);
|
||||
const isExpanded = expandedExercise === exercise.id;
|
||||
const lastLog = lastLogs.get(exercise.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={exercise.id}
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => toggleExercise(exercise.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 min-h-[44px] text-left hover:bg-gray-800/50"
|
||||
>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-100">{exercise.name}</span>
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
({exLogs.length} {exLogs.length === 1 ? 'Satz' : 'Sätze'})
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{/* Vorherige Werte */}
|
||||
{lastLog && (
|
||||
<div className="text-sm text-gray-500 bg-gray-800 rounded-lg px-3 py-2">
|
||||
Letztes Training: {lastLog.weight_kg} kg x {lastLog.reps} Wdh.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bisherige Sätze */}
|
||||
{exLogs.map((log) => (
|
||||
<div key={log.id}>
|
||||
{editingLog?.id === log.id ? (
|
||||
<LogEntryForm
|
||||
exercise={exercise}
|
||||
setNumber={log.set_number}
|
||||
initialWeight={log.weight_kg}
|
||||
initialReps={log.reps}
|
||||
onSubmit={(w, r, n) => handleUpdateLog(log, w, r, n)}
|
||||
submitLabel="Aktualisieren"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-between bg-gray-800 rounded-lg px-3 py-2">
|
||||
<div>
|
||||
<span className="text-gray-500 text-sm mr-2">
|
||||
Satz {log.set_number}:
|
||||
</span>
|
||||
<span className="font-semibold text-gray-100">
|
||||
{log.weight_kg} kg
|
||||
</span>
|
||||
<span className="text-gray-400 mx-1">x</span>
|
||||
<span className="font-semibold text-gray-100">
|
||||
{log.reps} Wdh.
|
||||
</span>
|
||||
{log.note && (
|
||||
<span className="text-xs text-gray-500 ml-2">
|
||||
({log.note})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setEditingLog(log)}
|
||||
className="min-h-[36px] min-w-[36px] flex items-center justify-center text-gray-400 hover:text-blue-400"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteLog(log)}
|
||||
className="min-h-[36px] min-w-[36px] flex items-center justify-center text-gray-400 hover:text-red-400"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Neuer Satz — Werte vom letzten Satz dieser Session, sonst vom letzten Training */}
|
||||
<LogEntryForm
|
||||
key={`new-${exercise.id}-${exLogs.length}`}
|
||||
exercise={exercise}
|
||||
setNumber={getNextSetNumber(exercise.id)}
|
||||
initialWeight={exLogs.length > 0 ? exLogs[exLogs.length - 1].weight_kg : (lastLog?.weight_kg ?? 0)}
|
||||
initialReps={exLogs.length > 0 ? exLogs[exLogs.length - 1].reps : (lastLog?.reps ?? 0)}
|
||||
onSubmit={(w, r, n) => handleAddLog(exercise, w, r, n)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Training beenden */}
|
||||
<button
|
||||
onClick={handleEndSession}
|
||||
className="w-full bg-red-600 hover:bg-red-500 text-white font-semibold rounded-xl py-4 min-h-[44px]"
|
||||
>
|
||||
Training beenden
|
||||
</button>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={isOpen}
|
||||
title={title}
|
||||
message={message}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/training/LogEntryForm.tsx
Executable file
135
frontend/src/components/training/LogEntryForm.tsx
Executable file
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react';
|
||||
import type { Exercise } from '../../types';
|
||||
|
||||
interface LogEntryFormProps {
|
||||
exercise: Exercise;
|
||||
setNumber: number;
|
||||
initialWeight?: number;
|
||||
initialReps?: number;
|
||||
onSubmit: (weight: number, reps: number, note: string) => void;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export function LogEntryForm({
|
||||
exercise,
|
||||
setNumber,
|
||||
initialWeight = 0,
|
||||
initialReps = 0,
|
||||
onSubmit,
|
||||
submitLabel = 'Satz speichern',
|
||||
}: LogEntryFormProps) {
|
||||
const [weight, setWeight] = useState(initialWeight);
|
||||
const [reps, setReps] = useState(initialReps);
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const step = exercise.weight_step_kg;
|
||||
|
||||
const adjustWeight = (delta: number) => {
|
||||
setWeight((w) => Math.max(0, Math.round((w + delta) * 100) / 100));
|
||||
};
|
||||
|
||||
const adjustReps = (delta: number) => {
|
||||
setReps((r) => Math.max(0, r + delta));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit(weight, reps, note);
|
||||
setNote('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 bg-gray-800 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-400">Satz {setNumber}</div>
|
||||
|
||||
{/* Gewicht */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Gewicht (kg)</label>
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustWeight(-2 * step)}
|
||||
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold"
|
||||
>
|
||||
-{2 * step}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustWeight(-step)}
|
||||
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold"
|
||||
>
|
||||
-{step}
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(parseFloat(e.target.value) || 0)}
|
||||
className="w-28 text-center text-3xl font-bold bg-gray-900 border border-gray-600 rounded-lg py-2 text-gray-100 focus:outline-none focus:border-blue-500"
|
||||
min={0}
|
||||
step={step}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustWeight(step)}
|
||||
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold"
|
||||
>
|
||||
+{step}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustWeight(2 * step)}
|
||||
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold"
|
||||
>
|
||||
+{2 * step}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wiederholungen */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Wiederholungen</label>
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustReps(-1)}
|
||||
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold text-xl"
|
||||
>
|
||||
-1
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value={reps}
|
||||
onChange={(e) => setReps(parseInt(e.target.value) || 0)}
|
||||
className="w-28 text-center text-3xl font-bold bg-gray-900 border border-gray-600 rounded-lg py-2 text-gray-100 focus:outline-none focus:border-blue-500"
|
||||
min={0}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustReps(1)}
|
||||
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold text-xl"
|
||||
>
|
||||
+1
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notiz */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Notiz (optional)"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 text-sm focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-lg py-3 min-h-[44px]"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/training/RestTimer.tsx
Executable file
54
frontend/src/components/training/RestTimer.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import { useActiveSessionStore } from '../../stores/activeSessionStore';
|
||||
|
||||
const PRESETS = [60, 90, 120, 180];
|
||||
|
||||
export function RestTimer() {
|
||||
const { timerSeconds, timerTarget, startTimer, stopTimer } = useActiveSessionStore();
|
||||
const isRunning = timerSeconds > 0;
|
||||
|
||||
const progress = timerTarget > 0 ? ((timerTarget - timerSeconds) / timerTarget) * 100 : 0;
|
||||
|
||||
const formatTime = (s: number) => {
|
||||
const mins = Math.floor(s / 60);
|
||||
const secs = s % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-400 mb-3">Pause-Timer</h3>
|
||||
|
||||
{isRunning ? (
|
||||
<div className="text-center space-y-3">
|
||||
<div className="text-4xl font-bold text-blue-400 font-mono">
|
||||
{formatTime(timerSeconds)}
|
||||
</div>
|
||||
<div className="w-full bg-gray-800 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-1000"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={stopTimer}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 min-h-[44px]"
|
||||
>
|
||||
Stopp
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{PRESETS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => startTimer(s)}
|
||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-200 min-h-[44px] min-w-[44px] font-mono"
|
||||
>
|
||||
{formatTime(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user