Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
22
frontend/src/App.tsx
Executable file
22
frontend/src/App.tsx
Executable file
@@ -0,0 +1,22 @@
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import { PageShell } from './components/layout/PageShell';
|
||||
import { ExercisesPage } from './pages/ExercisesPage';
|
||||
import { SetsPage } from './pages/SetsPage';
|
||||
import { TrainingPage } from './pages/TrainingPage';
|
||||
import { HistoryPage } from './pages/HistoryPage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <PageShell />,
|
||||
children: [
|
||||
{ path: '/', element: <ExercisesPage /> },
|
||||
{ path: '/sets', element: <SetsPage /> },
|
||||
{ path: '/training', element: <TrainingPage /> },
|
||||
{ path: '/history', element: <HistoryPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
178
frontend/src/api/client.ts
Executable file
178
frontend/src/api/client.ts
Executable file
@@ -0,0 +1,178 @@
|
||||
import type {
|
||||
Exercise,
|
||||
TrainingSet,
|
||||
Session,
|
||||
SessionLog,
|
||||
LastLogResponse,
|
||||
ExerciseStats,
|
||||
CreateExerciseRequest,
|
||||
CreateSetRequest,
|
||||
UpdateSetRequest,
|
||||
CreateSessionRequest,
|
||||
CreateLogRequest,
|
||||
UpdateLogRequest,
|
||||
} from '../types';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, data.error || 'Unbekannter Fehler');
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
exercises: {
|
||||
list(muscleGroup?: string, q?: string): Promise<Exercise[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (muscleGroup) params.set('muscle_group', muscleGroup);
|
||||
if (q) params.set('q', q);
|
||||
const qs = params.toString();
|
||||
return request<Exercise[]>(`/api/v1/exercises${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
|
||||
create(data: CreateExerciseRequest): Promise<Exercise> {
|
||||
return request<Exercise>('/api/v1/exercises', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
update(id: number, data: CreateExerciseRequest): Promise<Exercise> {
|
||||
return request<Exercise>(`/api/v1/exercises/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
delete(id: number): Promise<void> {
|
||||
return request<void>(`/api/v1/exercises/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
lastLog(id: number): Promise<LastLogResponse> {
|
||||
return request<LastLogResponse>(`/api/v1/exercises/${id}/last-log`);
|
||||
},
|
||||
|
||||
history(id: number, limit?: number): Promise<SessionLog[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit) params.set('limit', String(limit));
|
||||
const qs = params.toString();
|
||||
return request<SessionLog[]>(
|
||||
`/api/v1/exercises/${id}/history${qs ? '?' + qs : ''}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
sets: {
|
||||
list(): Promise<TrainingSet[]> {
|
||||
return request<TrainingSet[]>('/api/v1/sets');
|
||||
},
|
||||
|
||||
create(data: CreateSetRequest): Promise<TrainingSet> {
|
||||
return request<TrainingSet>('/api/v1/sets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
update(id: number, data: UpdateSetRequest): Promise<TrainingSet> {
|
||||
return request<TrainingSet>(`/api/v1/sets/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
delete(id: number): Promise<void> {
|
||||
return request<void>(`/api/v1/sets/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
sessions: {
|
||||
create(data: CreateSessionRequest): Promise<Session> {
|
||||
return request<Session>('/api/v1/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
list(limit?: number, offset?: number): Promise<Session[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit) params.set('limit', String(limit));
|
||||
if (offset) params.set('offset', String(offset));
|
||||
const qs = params.toString();
|
||||
return request<Session[]>(`/api/v1/sessions${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
|
||||
get(id: number): Promise<Session> {
|
||||
return request<Session>(`/api/v1/sessions/${id}`);
|
||||
},
|
||||
|
||||
end(id: number, note?: string): Promise<Session> {
|
||||
return request<Session>(`/api/v1/sessions/${id}/end`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ note: note || '' }),
|
||||
});
|
||||
},
|
||||
|
||||
createLog(sessionId: number, data: CreateLogRequest): Promise<SessionLog> {
|
||||
return request<SessionLog>(`/api/v1/sessions/${sessionId}/logs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
updateLog(
|
||||
sessionId: number,
|
||||
logId: number,
|
||||
data: UpdateLogRequest,
|
||||
): Promise<SessionLog> {
|
||||
return request<SessionLog>(
|
||||
`/api/v1/sessions/${sessionId}/logs/${logId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
deleteLog(sessionId: number, logId: number): Promise<void> {
|
||||
return request<void>(`/api/v1/sessions/${sessionId}/logs/${logId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
stats: {
|
||||
overview(): Promise<ExerciseStats[]> {
|
||||
return request<ExerciseStats[]>('/api/v1/stats/overview');
|
||||
},
|
||||
},
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
43
frontend/src/hooks/useConfirm.ts
Executable file
43
frontend/src/hooks/useConfirm.ts
Executable file
@@ -0,0 +1,43 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
interface ConfirmState {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const [state, setState] = useState<ConfirmState>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
const resolveRef = useRef<((value: boolean) => void) | null>(null);
|
||||
|
||||
const confirm = useCallback((title: string, message: string): Promise<boolean> => {
|
||||
setState({ isOpen: true, title, message });
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setState({ isOpen: false, title: '', message: '' });
|
||||
resolveRef.current?.(true);
|
||||
resolveRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setState({ isOpen: false, title: '', message: '' });
|
||||
resolveRef.current?.(false);
|
||||
resolveRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
confirm,
|
||||
handleConfirm,
|
||||
handleCancel,
|
||||
};
|
||||
}
|
||||
26
frontend/src/hooks/useNavigationGuard.ts
Executable file
26
frontend/src/hooks/useNavigationGuard.ts
Executable file
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useBlocker } from 'react-router-dom';
|
||||
import { useActiveSessionStore } from '../stores/activeSessionStore';
|
||||
|
||||
export function useNavigationGuard() {
|
||||
const session = useActiveSessionStore((s) => s.session);
|
||||
const hasActiveSession = session !== null && !session.ended_at;
|
||||
|
||||
const blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
hasActiveSession && currentLocation.pathname !== nextLocation.pathname,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveSession) return;
|
||||
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [hasActiveSession]);
|
||||
|
||||
return blocker;
|
||||
}
|
||||
1
frontend/src/index.css
Executable file
1
frontend/src/index.css
Executable file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
10
frontend/src/main.tsx
Executable file
10
frontend/src/main.tsx
Executable file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
87
frontend/src/pages/ExercisesPage.tsx
Executable file
87
frontend/src/pages/ExercisesPage.tsx
Executable file
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import { ExerciseList } from '../components/exercises/ExerciseList';
|
||||
import { ExerciseForm } from '../components/exercises/ExerciseForm';
|
||||
import { ConfirmDialog } from '../components/layout/ConfirmDialog';
|
||||
import { useExerciseStore } from '../stores/exerciseStore';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import type { Exercise, CreateExerciseRequest } from '../types';
|
||||
|
||||
export function ExercisesPage() {
|
||||
const { createExercise, updateExercise, deleteExercise } = useExerciseStore();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingExercise, setEditingExercise] = useState<Exercise | null>(null);
|
||||
const { isOpen, title, message, confirm, handleConfirm, handleCancel } = useConfirm();
|
||||
|
||||
const handleCreate = async (data: CreateExerciseRequest) => {
|
||||
const result = await createExercise(data);
|
||||
if (result) {
|
||||
setShowForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (data: CreateExerciseRequest) => {
|
||||
if (!editingExercise) return;
|
||||
const result = await updateExercise(editingExercise.id, data);
|
||||
if (result) {
|
||||
setEditingExercise(null);
|
||||
setShowForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (exercise: Exercise) => {
|
||||
setEditingExercise(exercise);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (exercise: Exercise) => {
|
||||
const ok = await confirm(
|
||||
'Übung löschen',
|
||||
`Möchtest du "${exercise.name}" wirklich löschen?`,
|
||||
);
|
||||
if (ok) {
|
||||
await deleteExercise(exercise.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingExercise(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Übungen</h1>
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingExercise(null);
|
||||
setShowForm(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg min-h-[44px] font-medium"
|
||||
>
|
||||
+ Neue Übung
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<ExerciseForm
|
||||
exercise={editingExercise}
|
||||
onSubmit={editingExercise ? handleUpdate : handleCreate}
|
||||
onCancel={handleCancelForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ExerciseList onEdit={handleEdit} onDelete={handleDelete} />
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={isOpen}
|
||||
title={title}
|
||||
message={message}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
frontend/src/pages/HistoryPage.tsx
Executable file
41
frontend/src/pages/HistoryPage.tsx
Executable file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { SessionList } from '../components/history/SessionList';
|
||||
import { ExerciseChart } from '../components/history/ExerciseChart';
|
||||
|
||||
type Tab = 'history' | 'stats';
|
||||
|
||||
export function HistoryPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('history');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Historie</h1>
|
||||
|
||||
{/* Tab Toggle */}
|
||||
<div className="flex bg-gray-900 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={`flex-1 py-2 rounded-md text-sm font-medium min-h-[44px] transition-colors ${
|
||||
activeTab === 'history'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Trainings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('stats')}
|
||||
className={`flex-1 py-2 rounded-md text-sm font-medium min-h-[44px] transition-colors ${
|
||||
activeTab === 'stats'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Statistiken
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'history' ? <SessionList /> : <ExerciseChart />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/pages/SetsPage.tsx
Executable file
90
frontend/src/pages/SetsPage.tsx
Executable file
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { SetList } from '../components/sets/SetList';
|
||||
import { SetForm } from '../components/sets/SetForm';
|
||||
import { ConfirmDialog } from '../components/layout/ConfirmDialog';
|
||||
import { useSetStore } from '../stores/setStore';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import type { TrainingSet } from '../types';
|
||||
|
||||
export function SetsPage() {
|
||||
const { createSet, updateSet, deleteSet } = useSetStore();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingSet, setEditingSet] = useState<TrainingSet | null>(null);
|
||||
const { isOpen, title, message, confirm, handleConfirm, handleCancel } = useConfirm();
|
||||
|
||||
const handleCreate = async (name: string, exerciseIds: number[]) => {
|
||||
const result = await createSet({ name, exercise_ids: exerciseIds });
|
||||
if (result) {
|
||||
setShowForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (name: string, exerciseIds: number[]) => {
|
||||
if (!editingSet) return;
|
||||
const result = await updateSet(editingSet.id, {
|
||||
name,
|
||||
exercise_ids: exerciseIds,
|
||||
});
|
||||
if (result) {
|
||||
setEditingSet(null);
|
||||
setShowForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (set: TrainingSet) => {
|
||||
setEditingSet(set);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (set: TrainingSet) => {
|
||||
const ok = await confirm(
|
||||
'Set löschen',
|
||||
`Möchtest du "${set.name}" wirklich löschen?`,
|
||||
);
|
||||
if (ok) {
|
||||
await deleteSet(set.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingSet(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Training-Sets</h1>
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingSet(null);
|
||||
setShowForm(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg min-h-[44px] font-medium"
|
||||
>
|
||||
+ Neues Set
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<SetForm
|
||||
trainingSet={editingSet}
|
||||
onSubmit={editingSet ? handleUpdate : handleCreate}
|
||||
onCancel={handleCancelForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SetList onEdit={handleEdit} onDelete={handleDelete} />
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={isOpen}
|
||||
title={title}
|
||||
message={message}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/pages/TrainingPage.tsx
Executable file
107
frontend/src/pages/TrainingPage.tsx
Executable file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSetStore } from '../stores/setStore';
|
||||
import { useActiveSessionStore } from '../stores/activeSessionStore';
|
||||
import { useNavigationGuard } from '../hooks/useNavigationGuard';
|
||||
import { ActiveSession } from '../components/training/ActiveSession';
|
||||
import { ConfirmDialog } from '../components/layout/ConfirmDialog';
|
||||
|
||||
export function TrainingPage() {
|
||||
const navigate = useNavigate();
|
||||
const { sets, fetchSets, loading } = useSetStore();
|
||||
const { session, startSession } = useActiveSessionStore();
|
||||
const [starting, setStarting] = useState(false);
|
||||
const blocker = useNavigationGuard();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSets();
|
||||
}, [fetchSets]);
|
||||
|
||||
const handleStart = async (setId: number) => {
|
||||
const trainingSet = sets.find((s) => s.id === setId);
|
||||
if (!trainingSet) return;
|
||||
|
||||
setStarting(true);
|
||||
const success = await startSession(setId, trainingSet.exercises || []);
|
||||
setStarting(false);
|
||||
|
||||
if (!success) return;
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
navigate('/history');
|
||||
};
|
||||
|
||||
// Aktive Session anzeigen
|
||||
if (session) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ActiveSession onEnd={handleEnd} />
|
||||
|
||||
{/* Navigation Blocker Dialog */}
|
||||
{blocker.state === 'blocked' && (
|
||||
<ConfirmDialog
|
||||
isOpen={true}
|
||||
title="Training verlassen?"
|
||||
message="Du hast ein aktives Training. Wenn du die Seite verlässt, kannst du später fortfahren. Möchtest du trotzdem navigieren?"
|
||||
onConfirm={() => blocker.proceed?.()}
|
||||
onCancel={() => blocker.reset?.()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Set-Auswahl
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Training starten</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 py-8">Laden...</div>
|
||||
) : sets.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-12">
|
||||
<p className="text-lg">Keine Sets vorhanden</p>
|
||||
<p className="text-sm mt-1">Erstelle zuerst ein Set unter "Sets".</p>
|
||||
</div>
|
||||
) : (
|
||||
<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-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-100">{set.name}</h3>
|
||||
<p className="text-sm text-gray-400 mt-0.5">
|
||||
{set.exercises?.length || 0} Übungen
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleStart(set.id)}
|
||||
disabled={starting}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg min-h-[44px] font-medium disabled:opacity-50"
|
||||
>
|
||||
Starten
|
||||
</button>
|
||||
</div>
|
||||
{set.exercises && set.exercises.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{set.exercises.map((ex) => (
|
||||
<span
|
||||
key={ex.id}
|
||||
className="text-xs bg-gray-800 text-gray-400 px-2 py-1 rounded"
|
||||
>
|
||||
{ex.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
frontend/src/stores/activeSessionStore.ts
Executable file
181
frontend/src/stores/activeSessionStore.ts
Executable file
@@ -0,0 +1,181 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
import type {
|
||||
Session,
|
||||
Exercise,
|
||||
SessionLog,
|
||||
LastLogResponse,
|
||||
CreateLogRequest,
|
||||
UpdateLogRequest,
|
||||
} from '../types';
|
||||
import { useToastStore } from './toastStore';
|
||||
|
||||
interface ActiveSessionState {
|
||||
session: Session | null;
|
||||
exercises: Exercise[];
|
||||
lastLogs: Map<number, LastLogResponse>;
|
||||
timerSeconds: number;
|
||||
timerTarget: number;
|
||||
timerInterval: ReturnType<typeof setInterval> | null;
|
||||
|
||||
startSession: (setId: number, exercises: Exercise[]) => Promise<boolean>;
|
||||
loadSession: (sessionId: number) => Promise<boolean>;
|
||||
addLog: (data: CreateLogRequest) => Promise<SessionLog | null>;
|
||||
updateLog: (logId: number, data: UpdateLogRequest) => Promise<SessionLog | null>;
|
||||
deleteLog: (logId: number) => Promise<boolean>;
|
||||
endSession: (note?: string) => Promise<boolean>;
|
||||
fetchLastLog: (exerciseId: number) => Promise<LastLogResponse | null>;
|
||||
copyLastValues: (exerciseId: number) => LastLogResponse | null;
|
||||
startTimer: (seconds: number) => void;
|
||||
stopTimer: () => void;
|
||||
clearSession: () => void;
|
||||
}
|
||||
|
||||
export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
|
||||
session: null,
|
||||
exercises: [],
|
||||
lastLogs: new Map(),
|
||||
timerSeconds: 0,
|
||||
timerTarget: 0,
|
||||
timerInterval: null,
|
||||
|
||||
startSession: async (setId, exercises) => {
|
||||
try {
|
||||
const session = await api.sessions.create({ set_id: setId });
|
||||
set({ session, exercises });
|
||||
|
||||
// Lade letzte Logs für alle Übungen
|
||||
for (const ex of exercises) {
|
||||
await get().fetchLastLog(ex.id);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Starten';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
loadSession: async (sessionId) => {
|
||||
try {
|
||||
const session = await api.sessions.get(sessionId);
|
||||
set({ session });
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Session nicht gefunden';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
addLog: async (data) => {
|
||||
const { session } = get();
|
||||
if (!session) return null;
|
||||
try {
|
||||
const log = await api.sessions.createLog(session.id, data);
|
||||
// Reload session to get updated logs
|
||||
const updated = await api.sessions.get(session.id);
|
||||
set({ session: updated });
|
||||
return log;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Speichern';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
updateLog: async (logId, data) => {
|
||||
const { session } = get();
|
||||
if (!session) return null;
|
||||
try {
|
||||
const log = await api.sessions.updateLog(session.id, logId, data);
|
||||
const updated = await api.sessions.get(session.id);
|
||||
set({ session: updated });
|
||||
return log;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Aktualisieren';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteLog: async (logId) => {
|
||||
const { session } = get();
|
||||
if (!session) return false;
|
||||
try {
|
||||
await api.sessions.deleteLog(session.id, logId);
|
||||
const updated = await api.sessions.get(session.id);
|
||||
set({ session: updated });
|
||||
useToastStore.getState().addToast('success', 'Satz gelöscht');
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Löschen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
endSession: async (note) => {
|
||||
const { session } = get();
|
||||
if (!session) return false;
|
||||
try {
|
||||
await api.sessions.end(session.id, note);
|
||||
get().stopTimer();
|
||||
set({ session: null, exercises: [], lastLogs: new Map() });
|
||||
useToastStore.getState().addToast('success', 'Training beendet');
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Beenden';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
fetchLastLog: async (exerciseId) => {
|
||||
try {
|
||||
const lastLog = await api.exercises.lastLog(exerciseId);
|
||||
set((state) => {
|
||||
const newMap = new Map(state.lastLogs);
|
||||
newMap.set(exerciseId, lastLog);
|
||||
return { lastLogs: newMap };
|
||||
});
|
||||
return lastLog;
|
||||
} catch {
|
||||
// 404 = noch kein Log vorhanden
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
copyLastValues: (exerciseId) => {
|
||||
return get().lastLogs.get(exerciseId) || null;
|
||||
},
|
||||
|
||||
startTimer: (seconds) => {
|
||||
const { timerInterval } = get();
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
|
||||
set({ timerTarget: seconds, timerSeconds: seconds });
|
||||
const interval = setInterval(() => {
|
||||
const current = get().timerSeconds;
|
||||
if (current <= 1) {
|
||||
clearInterval(interval);
|
||||
set({ timerSeconds: 0, timerInterval: null });
|
||||
} else {
|
||||
set({ timerSeconds: current - 1 });
|
||||
}
|
||||
}, 1000);
|
||||
set({ timerInterval: interval });
|
||||
},
|
||||
|
||||
stopTimer: () => {
|
||||
const { timerInterval } = get();
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
set({ timerSeconds: 0, timerTarget: 0, timerInterval: null });
|
||||
},
|
||||
|
||||
clearSession: () => {
|
||||
get().stopTimer();
|
||||
set({ session: null, exercises: [], lastLogs: new Map() });
|
||||
},
|
||||
}));
|
||||
85
frontend/src/stores/exerciseStore.ts
Executable file
85
frontend/src/stores/exerciseStore.ts
Executable file
@@ -0,0 +1,85 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
import type { Exercise, MuscleGroup, CreateExerciseRequest } from '../types';
|
||||
import { useToastStore } from './toastStore';
|
||||
|
||||
interface ExerciseFilter {
|
||||
muscleGroup: MuscleGroup | '';
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface ExerciseState {
|
||||
exercises: Exercise[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
filter: ExerciseFilter;
|
||||
fetchExercises: () => Promise<void>;
|
||||
createExercise: (data: CreateExerciseRequest) => Promise<Exercise | null>;
|
||||
updateExercise: (id: number, data: CreateExerciseRequest) => Promise<Exercise | null>;
|
||||
deleteExercise: (id: number) => Promise<boolean>;
|
||||
setFilter: (filter: Partial<ExerciseFilter>) => void;
|
||||
}
|
||||
|
||||
export const useExerciseStore = create<ExerciseState>((set, get) => ({
|
||||
exercises: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
filter: { muscleGroup: '', query: '' },
|
||||
|
||||
fetchExercises: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { muscleGroup, query } = get().filter;
|
||||
const exercises = await api.exercises.list(muscleGroup || undefined, query || undefined);
|
||||
set({ exercises: exercises || [], loading: false });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Laden';
|
||||
set({ error: message, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createExercise: async (data) => {
|
||||
try {
|
||||
const exercise = await api.exercises.create(data);
|
||||
useToastStore.getState().addToast('success', 'Übung erstellt');
|
||||
await get().fetchExercises();
|
||||
return exercise;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Erstellen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
updateExercise: async (id, data) => {
|
||||
try {
|
||||
const exercise = await api.exercises.update(id, data);
|
||||
useToastStore.getState().addToast('success', 'Übung aktualisiert');
|
||||
await get().fetchExercises();
|
||||
return exercise;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Aktualisieren';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteExercise: async (id) => {
|
||||
try {
|
||||
await api.exercises.delete(id);
|
||||
useToastStore.getState().addToast('success', 'Übung gelöscht');
|
||||
await get().fetchExercises();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Löschen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setFilter: (filter) => {
|
||||
set((state) => ({
|
||||
filter: { ...state.filter, ...filter },
|
||||
}));
|
||||
},
|
||||
}));
|
||||
51
frontend/src/stores/historyStore.ts
Executable file
51
frontend/src/stores/historyStore.ts
Executable file
@@ -0,0 +1,51 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
import type { Session, ExerciseStats } from '../types';
|
||||
import { useToastStore } from './toastStore';
|
||||
|
||||
interface HistoryState {
|
||||
sessions: Session[];
|
||||
loading: boolean;
|
||||
stats: ExerciseStats[];
|
||||
fetchSessions: (limit?: number, offset?: number) => Promise<void>;
|
||||
fetchSession: (id: number) => Promise<Session | null>;
|
||||
fetchStats: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useHistoryStore = create<HistoryState>((set) => ({
|
||||
sessions: [],
|
||||
loading: false,
|
||||
stats: [],
|
||||
|
||||
fetchSessions: async (limit, offset) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const sessions = await api.sessions.list(limit, offset);
|
||||
set({ sessions: sessions || [], loading: false });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Laden';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchSession: async (id) => {
|
||||
try {
|
||||
return await api.sessions.get(id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Session nicht gefunden';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
fetchStats: async () => {
|
||||
try {
|
||||
const stats = await api.stats.overview();
|
||||
set({ stats: stats || [] });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Laden';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
70
frontend/src/stores/setStore.ts
Executable file
70
frontend/src/stores/setStore.ts
Executable file
@@ -0,0 +1,70 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
import type { TrainingSet, CreateSetRequest, UpdateSetRequest } from '../types';
|
||||
import { useToastStore } from './toastStore';
|
||||
|
||||
interface SetState {
|
||||
sets: TrainingSet[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
fetchSets: () => Promise<void>;
|
||||
createSet: (data: CreateSetRequest) => Promise<TrainingSet | null>;
|
||||
updateSet: (id: number, data: UpdateSetRequest) => Promise<TrainingSet | null>;
|
||||
deleteSet: (id: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useSetStore = create<SetState>((set, get) => ({
|
||||
sets: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchSets: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const sets = await api.sets.list();
|
||||
set({ sets: sets || [], loading: false });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Laden';
|
||||
set({ error: message, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createSet: async (data) => {
|
||||
try {
|
||||
const newSet = await api.sets.create(data);
|
||||
useToastStore.getState().addToast('success', 'Set erstellt');
|
||||
await get().fetchSets();
|
||||
return newSet;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Erstellen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
updateSet: async (id, data) => {
|
||||
try {
|
||||
const updated = await api.sets.update(id, data);
|
||||
useToastStore.getState().addToast('success', 'Set aktualisiert');
|
||||
await get().fetchSets();
|
||||
return updated;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Aktualisieren';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteSet: async (id) => {
|
||||
try {
|
||||
await api.sets.delete(id);
|
||||
useToastStore.getState().addToast('success', 'Set gelöscht');
|
||||
await get().fetchSets();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Löschen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
35
frontend/src/stores/toastStore.ts
Executable file
35
frontend/src/stores/toastStore.ts
Executable file
@@ -0,0 +1,35 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: Toast[];
|
||||
addToast: (type: Toast['type'], message: string) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
|
||||
addToast: (type, message) => {
|
||||
const id = crypto.randomUUID();
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, { id, type, message }],
|
||||
}));
|
||||
setTimeout(() => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
removeToast: (id) => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
136
frontend/src/types/index.ts
Executable file
136
frontend/src/types/index.ts
Executable file
@@ -0,0 +1,136 @@
|
||||
export type MuscleGroup =
|
||||
| 'brust'
|
||||
| 'ruecken'
|
||||
| 'schultern'
|
||||
| 'bizeps'
|
||||
| 'trizeps'
|
||||
| 'beine'
|
||||
| 'bauch'
|
||||
| 'ganzkoerper'
|
||||
| 'sonstiges';
|
||||
|
||||
export const MUSCLE_GROUPS: { value: MuscleGroup; label: string }[] = [
|
||||
{ value: 'brust', label: 'Brust' },
|
||||
{ value: 'ruecken', label: 'Rücken' },
|
||||
{ value: 'schultern', label: 'Schultern' },
|
||||
{ value: 'bizeps', label: 'Bizeps' },
|
||||
{ value: 'trizeps', label: 'Trizeps' },
|
||||
{ value: 'beine', label: 'Beine' },
|
||||
{ value: 'bauch', label: 'Bauch' },
|
||||
{ value: 'ganzkoerper', label: 'Ganzkörper' },
|
||||
{ value: 'sonstiges', label: 'Sonstiges' },
|
||||
];
|
||||
|
||||
export const MUSCLE_GROUP_LABELS: Record<MuscleGroup, string> = {
|
||||
brust: 'Brust',
|
||||
ruecken: 'Rücken',
|
||||
schultern: 'Schultern',
|
||||
bizeps: 'Bizeps',
|
||||
trizeps: 'Trizeps',
|
||||
beine: 'Beine',
|
||||
bauch: 'Bauch',
|
||||
ganzkoerper: 'Ganzkörper',
|
||||
sonstiges: 'Sonstiges',
|
||||
};
|
||||
|
||||
export const MUSCLE_GROUP_COLORS: Record<MuscleGroup, string> = {
|
||||
brust: 'bg-red-600',
|
||||
ruecken: 'bg-blue-600',
|
||||
schultern: 'bg-yellow-600',
|
||||
bizeps: 'bg-purple-600',
|
||||
trizeps: 'bg-pink-600',
|
||||
beine: 'bg-green-600',
|
||||
bauch: 'bg-orange-600',
|
||||
ganzkoerper: 'bg-teal-600',
|
||||
sonstiges: 'bg-gray-600',
|
||||
};
|
||||
|
||||
export interface Exercise {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
muscle_group: MuscleGroup;
|
||||
weight_step_kg: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string;
|
||||
}
|
||||
|
||||
export interface TrainingSet {
|
||||
id: number;
|
||||
name: string;
|
||||
exercises: Exercise[];
|
||||
created_at: string;
|
||||
deleted_at?: string;
|
||||
}
|
||||
|
||||
export interface SessionLog {
|
||||
id: number;
|
||||
session_id: number;
|
||||
exercise_id: number;
|
||||
exercise_name: string;
|
||||
set_number: number;
|
||||
weight_kg: number;
|
||||
reps: number;
|
||||
note: string;
|
||||
logged_at: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: number;
|
||||
set_id: number;
|
||||
set_name: string;
|
||||
started_at: string;
|
||||
ended_at?: string;
|
||||
note: string;
|
||||
logs?: SessionLog[];
|
||||
}
|
||||
|
||||
export interface LastLogResponse {
|
||||
weight_kg: number;
|
||||
reps: number;
|
||||
}
|
||||
|
||||
export interface ExerciseStats {
|
||||
exercise_id: number;
|
||||
exercise_name: string;
|
||||
max_weight_kg: number;
|
||||
total_volume_kg: number;
|
||||
total_sets: number;
|
||||
last_trained: string;
|
||||
}
|
||||
|
||||
export interface CreateExerciseRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
muscle_group: MuscleGroup;
|
||||
weight_step_kg?: number;
|
||||
}
|
||||
|
||||
export interface CreateSetRequest {
|
||||
name: string;
|
||||
exercise_ids: number[];
|
||||
}
|
||||
|
||||
export interface UpdateSetRequest {
|
||||
name: string;
|
||||
exercise_ids: number[];
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
set_id: number;
|
||||
}
|
||||
|
||||
export interface CreateLogRequest {
|
||||
exercise_id: number;
|
||||
set_number: number;
|
||||
weight_kg: number;
|
||||
reps: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface UpdateLogRequest {
|
||||
weight_kg?: number;
|
||||
reps?: number;
|
||||
note?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user