1. Plan creation, editing and deletion fixed. 2. Planned sets drag&drop implemented.
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Scale } from 'lucide-react';
|
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Scale, Edit2, User, Flame, Timer as TimerIcon, Ruler, Footprints, Activity, Percent, CheckCircle, GripVertical } from 'lucide-react';
|
||||||
import { WorkoutPlan, ExerciseDef, PlannedSet, Language } from '../types';
|
import { WorkoutPlan, ExerciseDef, PlannedSet, Language, ExerciseType } from '../types';
|
||||||
import { getPlans, savePlan, deletePlan, getExercises } from '../services/storage';
|
import { getPlans, savePlan, deletePlan, getExercises, saveExercise } from '../services/storage';
|
||||||
import { t } from '../services/i18n';
|
import { t } from '../services/i18n';
|
||||||
|
|
||||||
interface PlansProps {
|
interface PlansProps {
|
||||||
@@ -11,6 +11,24 @@ interface PlansProps {
|
|||||||
lang: Language;
|
lang: Language;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FilledInput = ({ label, value, onChange, type = "number", icon, autoFocus, step }: any) => (
|
||||||
|
<div className="relative group bg-surface-container-high rounded-t-lg border-b border-outline-variant hover:bg-white/5 focus-within:border-primary transition-colors">
|
||||||
|
<label className="absolute top-2 left-4 text-[10px] font-medium text-on-surface-variant flex items-center gap-1">
|
||||||
|
{icon} {label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
step={step}
|
||||||
|
inputMode={type === 'number' ? 'decimal' : 'text'}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className="w-full pt-6 pb-2 px-4 bg-transparent text-2xl text-on-surface focus:outline-none placeholder-transparent"
|
||||||
|
placeholder=" "
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
||||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
@@ -23,6 +41,16 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
|||||||
const [availableExercises, setAvailableExercises] = useState<ExerciseDef[]>([]);
|
const [availableExercises, setAvailableExercises] = useState<ExerciseDef[]>([]);
|
||||||
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
|
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
|
||||||
|
|
||||||
|
// Drag and Drop Refs
|
||||||
|
const dragItem = React.useRef<number | null>(null);
|
||||||
|
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Create Exercise State
|
||||||
|
const [isCreatingExercise, setIsCreatingExercise] = useState(false);
|
||||||
|
const [newExName, setNewExName] = useState('');
|
||||||
|
const [newExType, setNewExType] = useState<ExerciseType>(ExerciseType.STRENGTH);
|
||||||
|
const [newExBwPercentage, setNewExBwPercentage] = useState<string>('100');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
const fetchedPlans = await getPlans(userId);
|
const fetchedPlans = await getPlans(userId);
|
||||||
@@ -47,19 +75,29 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
|||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleEdit = (plan: WorkoutPlan) => {
|
||||||
|
setEditId(plan.id);
|
||||||
|
setName(plan.name);
|
||||||
|
setDescription(plan.description || '');
|
||||||
|
setSteps(plan.steps);
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
if (!name.trim() || !editId) return;
|
if (!name.trim() || !editId) return;
|
||||||
const newPlan: WorkoutPlan = { id: editId, name, description, steps };
|
const newPlan: WorkoutPlan = { id: editId, name, description, steps };
|
||||||
savePlan(userId, newPlan);
|
await savePlan(userId, newPlan);
|
||||||
setPlans(getPlans(userId));
|
const updated = await getPlans(userId);
|
||||||
|
setPlans(updated);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string, e: React.MouseEvent) => {
|
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (confirm(t('delete_confirm', lang))) {
|
if (confirm(t('delete_confirm', lang))) {
|
||||||
deletePlan(userId, id);
|
await deletePlan(userId, id);
|
||||||
setPlans(getPlans(userId));
|
const updated = await getPlans(userId);
|
||||||
|
setPlans(updated);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,6 +113,37 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
|||||||
setShowExerciseSelector(false);
|
setShowExerciseSelector(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateExercise = async () => {
|
||||||
|
if (!newExName.trim()) return;
|
||||||
|
const newEx: ExerciseDef = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: newExName.trim(),
|
||||||
|
type: newExType,
|
||||||
|
...(newExType === ExerciseType.BODYWEIGHT && { bodyWeightPercentage: parseFloat(newExBwPercentage) || 100 })
|
||||||
|
};
|
||||||
|
await saveExercise(userId, newEx);
|
||||||
|
const exList = await getExercises(userId);
|
||||||
|
setAvailableExercises(exList.filter(e => !e.isArchived));
|
||||||
|
|
||||||
|
// Automatically add the new exercise to the plan
|
||||||
|
addStep(newEx);
|
||||||
|
|
||||||
|
setNewExName('');
|
||||||
|
setNewExType(ExerciseType.STRENGTH);
|
||||||
|
setNewExBwPercentage('100');
|
||||||
|
setIsCreatingExercise(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exerciseTypeLabels: Record<ExerciseType, string> = {
|
||||||
|
[ExerciseType.STRENGTH]: t('type_strength', lang),
|
||||||
|
[ExerciseType.BODYWEIGHT]: t('type_bodyweight', lang),
|
||||||
|
[ExerciseType.CARDIO]: t('type_cardio', lang),
|
||||||
|
[ExerciseType.STATIC]: t('type_static', lang),
|
||||||
|
[ExerciseType.HIGH_JUMP]: t('type_height', lang),
|
||||||
|
[ExerciseType.LONG_JUMP]: t('type_dist', lang),
|
||||||
|
[ExerciseType.PLYOMETRIC]: t('type_jump', lang),
|
||||||
|
};
|
||||||
|
|
||||||
const toggleWeighted = (stepId: string) => {
|
const toggleWeighted = (stepId: string) => {
|
||||||
setSteps(steps.map(s => s.id === stepId ? { ...s, isWeighted: !s.isWeighted } : s));
|
setSteps(steps.map(s => s.id === stepId ? { ...s, isWeighted: !s.isWeighted } : s));
|
||||||
};
|
};
|
||||||
@@ -83,13 +152,27 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
|||||||
setSteps(steps.filter(s => s.id !== stepId));
|
setSteps(steps.filter(s => s.id !== stepId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveStep = (index: number, direction: 'up' | 'down') => {
|
const onDragStart = (index: number) => {
|
||||||
if (direction === 'up' && index === 0) return;
|
dragItem.current = index;
|
||||||
if (direction === 'down' && index === steps.length - 1) return;
|
setDraggingIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnter = (index: number) => {
|
||||||
|
if (dragItem.current === null) return;
|
||||||
|
if (dragItem.current === index) return;
|
||||||
|
|
||||||
const newSteps = [...steps];
|
const newSteps = [...steps];
|
||||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
const draggedItemContent = newSteps.splice(dragItem.current, 1)[0];
|
||||||
[newSteps[index], newSteps[targetIndex]] = [newSteps[targetIndex], newSteps[index]];
|
newSteps.splice(index, 0, draggedItemContent);
|
||||||
|
|
||||||
setSteps(newSteps);
|
setSteps(newSteps);
|
||||||
|
dragItem.current = index;
|
||||||
|
setDraggingIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnd = () => {
|
||||||
|
dragItem.current = null;
|
||||||
|
setDraggingIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
@@ -131,18 +214,17 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{steps.map((step, idx) => (
|
{steps.map((step, idx) => (
|
||||||
<div key={step.id} className="bg-surface-container rounded-xl p-3 flex items-center gap-3 shadow-elevation-1">
|
<div
|
||||||
<div className="flex flex-col gap-1">
|
key={step.id}
|
||||||
{idx > 0 && (
|
className={`bg-surface-container rounded-xl p-3 flex items-center gap-3 shadow-elevation-1 cursor-move transition-all hover:bg-surface-container-high ${draggingIndex === idx ? 'opacity-50 ring-2 ring-primary bg-surface-container-high' : ''}`}
|
||||||
<button onClick={() => moveStep(idx, 'up')} className="p-1 text-on-surface-variant hover:bg-white/5 rounded">
|
draggable
|
||||||
<ArrowUp size={16} />
|
onDragStart={() => onDragStart(idx)}
|
||||||
</button>
|
onDragEnter={() => onDragEnter(idx)}
|
||||||
)}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
{idx < steps.length - 1 && (
|
onDragEnd={onDragEnd}
|
||||||
<button onClick={() => moveStep(idx, 'down')} className="p-1 text-on-surface-variant hover:bg-white/5 rounded">
|
>
|
||||||
<ArrowDown size={16} />
|
<div className={`text-on-surface-variant p-1 ${draggingIndex === idx ? 'cursor-grabbing' : 'cursor-grab'}`}>
|
||||||
</button>
|
<GripVertical size={20} />
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-8 h-8 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xs font-bold shrink-0">
|
<div className="w-8 h-8 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xs font-bold shrink-0">
|
||||||
@@ -185,8 +267,13 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
|||||||
<div className="absolute inset-0 bg-surface z-50 flex flex-col animate-in slide-in-from-bottom-full duration-200">
|
<div className="absolute inset-0 bg-surface z-50 flex flex-col animate-in slide-in-from-bottom-full duration-200">
|
||||||
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container">
|
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container">
|
||||||
<span className="font-medium text-on-surface">{t('select_exercise', lang)}</span>
|
<span className="font-medium text-on-surface">{t('select_exercise', lang)}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setIsCreatingExercise(true)} className="p-2 text-primary hover:bg-primary-container/20 rounded-full">
|
||||||
|
<Plus size={20} />
|
||||||
|
</button>
|
||||||
<button onClick={() => setShowExerciseSelector(false)}><X /></button>
|
<button onClick={() => setShowExerciseSelector(false)}><X /></button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
{availableExercises.map(ex => (
|
{availableExercises.map(ex => (
|
||||||
<button
|
<button
|
||||||
@@ -199,6 +286,70 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isCreatingExercise && (
|
||||||
|
<div className="absolute inset-0 bg-surface z-[60] flex flex-col animate-in slide-in-from-bottom-full duration-200">
|
||||||
|
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container">
|
||||||
|
<h3 className="text-title-medium font-medium text-on-surface">{t('create_exercise', lang)}</h3>
|
||||||
|
<button onClick={() => setIsCreatingExercise(false)} className="p-2 text-on-surface-variant hover:bg-white/5 rounded-full"><X /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-6 overflow-y-auto flex-1">
|
||||||
|
<FilledInput
|
||||||
|
label={t('ex_name', lang)}
|
||||||
|
value={newExName}
|
||||||
|
onChange={(e: any) => setNewExName(e.target.value)}
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
{ id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell },
|
||||||
|
{ id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User },
|
||||||
|
{ id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame },
|
||||||
|
{ id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon },
|
||||||
|
{ id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp },
|
||||||
|
{ id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler },
|
||||||
|
{ id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints },
|
||||||
|
].map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.id}
|
||||||
|
onClick={() => setNewExType(type.id)}
|
||||||
|
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${newExType === type.id
|
||||||
|
? 'bg-secondary-container text-on-secondary-container border-transparent'
|
||||||
|
: 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<type.icon size={14} /> {type.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newExType === ExerciseType.BODYWEIGHT && (
|
||||||
|
<FilledInput
|
||||||
|
label={t('body_weight_percent', lang)}
|
||||||
|
value={newExBwPercentage}
|
||||||
|
onChange={(e: any) => setNewExBwPercentage(e.target.value)}
|
||||||
|
icon={<Percent size={12} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleCreateExercise}
|
||||||
|
className="w-full h-14 bg-primary text-on-primary rounded-full font-medium shadow-elevation-1 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<CheckCircle size={20} />
|
||||||
|
{t('create_btn', lang)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -231,6 +382,14 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
|||||||
<Trash2 size={20} />
|
<Trash2 size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="absolute top-4 right-14">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleEdit(plan); }}
|
||||||
|
className="text-on-surface-variant hover:text-primary p-2 rounded-full hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<Edit2 size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p className="text-on-surface-variant text-sm line-clamp-2 mb-4 min-h-[1.25rem]">
|
<p className="text-on-surface-variant text-sm line-clamp-2 mb-4 min-h-[1.25rem]">
|
||||||
{plan.description || t('prep_no_instructions', lang)}
|
{plan.description || t('prep_no_instructions', lang)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Binary file not shown.
@@ -28,8 +28,15 @@ router.get('/', async (req: any, res) => {
|
|||||||
const plans = await prisma.workoutPlan.findMany({
|
const plans = await prisma.workoutPlan.findMany({
|
||||||
where: { userId }
|
where: { userId }
|
||||||
});
|
});
|
||||||
res.json(plans);
|
|
||||||
|
const mappedPlans = plans.map((p: any) => ({
|
||||||
|
...p,
|
||||||
|
steps: p.exercises ? JSON.parse(p.exercises) : []
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(mappedPlans);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error fetching plans:', error);
|
||||||
res.status(500).json({ error: 'Server error' });
|
res.status(500).json({ error: 'Server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -38,16 +45,18 @@ router.get('/', async (req: any, res) => {
|
|||||||
router.post('/', async (req: any, res) => {
|
router.post('/', async (req: any, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
const { id, name, description, exercises } = req.body;
|
const { id, name, description, steps } = req.body;
|
||||||
|
|
||||||
|
const exercisesJson = JSON.stringify(steps || []);
|
||||||
|
|
||||||
const existing = await prisma.workoutPlan.findUnique({ where: { id } });
|
const existing = await prisma.workoutPlan.findUnique({ where: { id } });
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const updated = await prisma.workoutPlan.update({
|
const updated = await prisma.workoutPlan.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { name, description, exercises }
|
data: { name, description, exercises: exercisesJson }
|
||||||
});
|
});
|
||||||
return res.json(updated);
|
res.json({ ...updated, steps: steps || [] });
|
||||||
} else {
|
} else {
|
||||||
const created = await prisma.workoutPlan.create({
|
const created = await prisma.workoutPlan.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -55,12 +64,13 @@ router.post('/', async (req: any, res) => {
|
|||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
exercises
|
exercises: exercisesJson
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return res.json(created);
|
res.json({ ...created, steps: steps || [] });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error saving plan:', error);
|
||||||
res.status(500).json({ error: 'Server error' });
|
res.status(500).json({ error: 'Server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user