Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user