Add exercise numbers, image uploads, version display, session resume, and training sparklines
- Exercise number (UF#): optional field on exercises, displayed in cards, training, and sets - Import training plan numbers via migration 005 (UPDATE by name) - Exercise images: JPG upload with multi-image support per exercise (migration 006) - Version endpoint (GET /api/v1/version) with ldflags injection in Makefile and Dockerfile - Version displayed on settings page - Session resume: GET /api/v1/sessions/active endpoint, auto-resume on training page load - Block new session while one is active (409 Conflict) - e1RM sparkline chart per exercise during training (Epley formula) - Fix CORS: add X-User-ID to allowed headers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,12 @@ export function ExerciseCard({ exercise, onEdit, onDelete }: ExerciseCardProps)
|
||||
<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>
|
||||
<h3 className="font-semibold text-gray-100 truncate">
|
||||
{exercise.exercise_number != null && (
|
||||
<span className="text-blue-400 mr-1.5">#{exercise.exercise_number}</span>
|
||||
)}
|
||||
{exercise.name}
|
||||
</h3>
|
||||
{exercise.description && (
|
||||
<p className="text-sm text-gray-400 mt-1">{exercise.description}</p>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Exercise, CreateExerciseRequest, MuscleGroup } from '../../types';
|
||||
import { MUSCLE_GROUPS } from '../../types';
|
||||
import { ImageGallery } from './ImageGallery';
|
||||
|
||||
interface ExerciseFormProps {
|
||||
exercise?: Exercise | null;
|
||||
@@ -13,6 +14,7 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
|
||||
const [description, setDescription] = useState('');
|
||||
const [muscleGroup, setMuscleGroup] = useState<MuscleGroup>('brust');
|
||||
const [weightStep, setWeightStep] = useState(2.5);
|
||||
const [exerciseNumber, setExerciseNumber] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (exercise) {
|
||||
@@ -20,21 +22,25 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
|
||||
setDescription(exercise.description);
|
||||
setMuscleGroup(exercise.muscle_group);
|
||||
setWeightStep(exercise.weight_step_kg);
|
||||
setExerciseNumber(exercise.exercise_number != null ? String(exercise.exercise_number) : '');
|
||||
} else {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setMuscleGroup('brust');
|
||||
setWeightStep(2.5);
|
||||
setExerciseNumber('');
|
||||
}
|
||||
}, [exercise]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const num = exerciseNumber.trim() ? parseInt(exerciseNumber.trim(), 10) : undefined;
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
muscle_group: muscleGroup,
|
||||
weight_step_kg: weightStep,
|
||||
exercise_number: Number.isFinite(num) ? num : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -58,6 +64,18 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Übungsnummer (UF#)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={exerciseNumber}
|
||||
onChange={(e) => setExerciseNumber(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="Optional"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
@@ -95,6 +113,10 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
|
||||
/>
|
||||
</div>
|
||||
|
||||
{exercise && (
|
||||
<ImageGallery exerciseId={exercise.id} />
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
127
frontend/src/components/exercises/ImageGallery.tsx
Normal file
127
frontend/src/components/exercises/ImageGallery.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { useToastStore } from '../../stores/toastStore';
|
||||
import type { ExerciseImage } from '../../types';
|
||||
|
||||
interface ImageGalleryProps {
|
||||
exerciseId: number;
|
||||
}
|
||||
|
||||
export function ImageGallery({ exerciseId }: ImageGalleryProps) {
|
||||
const [images, setImages] = useState<ExerciseImage[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [viewImage, setViewImage] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadImages();
|
||||
}, [exerciseId]);
|
||||
|
||||
async function loadImages() {
|
||||
try {
|
||||
const imgs = await api.exercises.listImages(exerciseId);
|
||||
setImages(imgs || []);
|
||||
} catch {
|
||||
// Fehler ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.type !== 'image/jpeg') {
|
||||
useToastStore.getState().addToast('error', 'Nur JPG-Bilder erlaubt');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
useToastStore.getState().addToast('error', 'Datei zu groß (max 5 MB)');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
await api.exercises.uploadImage(exerciseId, file);
|
||||
useToastStore.getState().addToast('success', 'Bild hochgeladen');
|
||||
await loadImages();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(imageId: number) {
|
||||
try {
|
||||
await api.exercises.deleteImage(exerciseId, imageId);
|
||||
useToastStore.getState().addToast('success', 'Bild gelöscht');
|
||||
setImages((prev) => prev.filter((img) => img.id !== imageId));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Löschen fehlgeschlagen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm text-gray-400">Bilder</label>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{images.map((img) => (
|
||||
<div key={img.id} className="relative group">
|
||||
<img
|
||||
src={`/uploads/${img.filename}`}
|
||||
alt="Übungsbild"
|
||||
className="w-full h-24 object-cover rounded-lg cursor-pointer"
|
||||
onClick={() => setViewImage(img.filename)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(img.id)}
|
||||
className="absolute top-1 right-1 w-6 h-6 bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Löschen"
|
||||
>
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="w-full py-2 border border-dashed border-gray-600 rounded-lg text-sm text-gray-400 hover:text-gray-200 hover:border-gray-400 transition-colors min-h-[44px] disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Wird hochgeladen...' : '+ Bild hinzufügen (JPG)'}
|
||||
</button>
|
||||
|
||||
{viewImage && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setViewImage(null)}
|
||||
>
|
||||
<img
|
||||
src={`/uploads/${viewImage}`}
|
||||
alt="Übungsbild"
|
||||
className="max-w-full max-h-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,12 @@ export function SetDetail({ trainingSet }: SetDetailProps) {
|
||||
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="flex-1 text-gray-200">
|
||||
{ex.exercise_number != null && (
|
||||
<span className="text-blue-400 mr-1.5">#{ex.exercise_number}</span>
|
||||
)}
|
||||
{ex.name}
|
||||
</span>
|
||||
<span className={`${color} text-white text-xs px-2 py-1 rounded-full`}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { ConfirmDialog } from '../layout/ConfirmDialog';
|
||||
import { LogEntryForm } from './LogEntryForm';
|
||||
import { RestTimer } from './RestTimer';
|
||||
import { ExerciseSparkline } from './ExerciseSparkline';
|
||||
import type { Exercise, SessionLog } from '../../types';
|
||||
|
||||
interface ActiveSessionProps {
|
||||
@@ -109,7 +110,12 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
||||
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="font-semibold text-gray-100">
|
||||
{exercise.exercise_number != null && (
|
||||
<span className="text-blue-400 mr-1.5">#{exercise.exercise_number}</span>
|
||||
)}
|
||||
{exercise.name}
|
||||
</span>
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
({exLogs.length} {exLogs.length === 1 ? 'Satz' : 'Sätze'})
|
||||
</span>
|
||||
@@ -128,6 +134,9 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{/* Fortschritts-Sparkline */}
|
||||
<ExerciseSparkline exerciseId={exercise.id} />
|
||||
|
||||
{/* Vorherige Werte */}
|
||||
{lastLog && (
|
||||
<div className="text-sm text-gray-500 bg-gray-800 rounded-lg px-3 py-2">
|
||||
|
||||
95
frontend/src/components/training/ExerciseSparkline.tsx
Normal file
95
frontend/src/components/training/ExerciseSparkline.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { api } from '../../api/client';
|
||||
import type { SessionLog } from '../../types';
|
||||
|
||||
interface ExerciseSparklineProps {
|
||||
exerciseId: number;
|
||||
}
|
||||
|
||||
interface DataPoint {
|
||||
date: string;
|
||||
e1rm: number;
|
||||
}
|
||||
|
||||
// Epley-Formel: e1RM = Gewicht × (1 + Wdh / 30)
|
||||
function calcE1RM(weight: number, reps: number): number {
|
||||
if (reps <= 0 || weight <= 0) return 0;
|
||||
return Math.round(weight * (1 + reps / 30) * 10) / 10;
|
||||
}
|
||||
|
||||
export function ExerciseSparkline({ exerciseId }: ExerciseSparklineProps) {
|
||||
const [data, setData] = useState<DataPoint[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.exercises
|
||||
.history(exerciseId, 100)
|
||||
.then((logs: SessionLog[]) => {
|
||||
// Gruppiere nach Session (= Trainingstag), nimm bestes e1RM pro Session
|
||||
const bySession = new Map<number, { date: string; e1rm: number }>();
|
||||
for (const log of logs) {
|
||||
const e1rm = calcE1RM(log.weight_kg, log.reps);
|
||||
const current = bySession.get(log.session_id);
|
||||
if (!current || e1rm > current.e1rm) {
|
||||
bySession.set(log.session_id, {
|
||||
date: new Date(log.logged_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
}),
|
||||
e1rm,
|
||||
});
|
||||
}
|
||||
}
|
||||
const points = Array.from(bySession.values()).reverse();
|
||||
setData(points);
|
||||
})
|
||||
.catch(() => setData([]));
|
||||
}, [exerciseId]);
|
||||
|
||||
if (data.length < 2) return null;
|
||||
|
||||
const trend = data[data.length - 1].e1rm - data[0].e1rm;
|
||||
const trendColor = trend >= 0 ? '#22C55E' : '#EF4444';
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-lg px-3 py-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-gray-500">Fortschritt (e1RM)</span>
|
||||
<span className="text-xs font-medium" style={{ color: trendColor }}>
|
||||
{trend >= 0 ? '+' : ''}{trend.toFixed(1)} kg
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-16">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<YAxis hide domain={['dataMin - 2', 'dataMax + 2']} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1F2937',
|
||||
border: '1px solid #374151',
|
||||
borderRadius: '8px',
|
||||
color: '#F3F4F6',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value) => [`${value} kg`, 'e1RM']}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="e1rm"
|
||||
stroke="#3B82F6"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#3B82F6' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user