417 lines
18 KiB
TypeScript
417 lines
18 KiB
TypeScript
|
|
import React, { useState, useEffect } from '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, ExerciseType } from '../types';
|
|
import { getPlans, savePlan, deletePlan, getExercises, saveExercise } from '../services/storage';
|
|
import { t } from '../services/i18n';
|
|
import { generateId } from '../utils/uuid';
|
|
|
|
import FilledInput from './FilledInput';
|
|
import { toTitleCase } from '../utils/text';
|
|
|
|
interface PlansProps {
|
|
userId: string;
|
|
onStartPlan: (plan: WorkoutPlan) => void;
|
|
lang: Language;
|
|
}
|
|
|
|
const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
|
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
|
|
const [editId, setEditId] = useState<string | null>(null);
|
|
const [name, setName] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [steps, setSteps] = useState<PlannedSet[]>([]);
|
|
|
|
const [availableExercises, setAvailableExercises] = useState<ExerciseDef[]>([]);
|
|
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(() => {
|
|
const loadData = async () => {
|
|
const fetchedPlans = await getPlans(userId);
|
|
setPlans(fetchedPlans);
|
|
|
|
const fetchedExercises = await getExercises(userId);
|
|
// Filter out archived exercises
|
|
if (Array.isArray(fetchedExercises)) {
|
|
setAvailableExercises(fetchedExercises.filter(e => !e.isArchived));
|
|
} else {
|
|
setAvailableExercises([]);
|
|
}
|
|
};
|
|
loadData();
|
|
}, [userId]);
|
|
|
|
const handleCreateNew = () => {
|
|
setEditId(generateId());
|
|
setName('');
|
|
setDescription('');
|
|
setSteps([]);
|
|
setIsEditing(true);
|
|
};
|
|
|
|
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;
|
|
const newPlan: WorkoutPlan = { id: editId, name, description, steps };
|
|
await savePlan(userId, newPlan);
|
|
const updated = await getPlans(userId);
|
|
setPlans(updated);
|
|
setIsEditing(false);
|
|
};
|
|
|
|
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (confirm(t('delete_confirm', lang))) {
|
|
await deletePlan(userId, id);
|
|
const updated = await getPlans(userId);
|
|
setPlans(updated);
|
|
}
|
|
};
|
|
|
|
const addStep = (ex: ExerciseDef) => {
|
|
const newStep: PlannedSet = {
|
|
id: generateId(),
|
|
exerciseId: ex.id,
|
|
exerciseName: ex.name,
|
|
exerciseType: ex.type,
|
|
isWeighted: false
|
|
};
|
|
setSteps([...steps, newStep]);
|
|
setShowExerciseSelector(false);
|
|
};
|
|
|
|
const handleCreateExercise = async () => {
|
|
if (!newExName.trim()) return;
|
|
const newEx: ExerciseDef = {
|
|
id: generateId(),
|
|
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) => {
|
|
setSteps(steps.map(s => s.id === stepId ? { ...s, isWeighted: !s.isWeighted } : s));
|
|
};
|
|
|
|
const removeStep = (stepId: string) => {
|
|
setSteps(steps.filter(s => s.id !== stepId));
|
|
};
|
|
|
|
const onDragStart = (index: number) => {
|
|
dragItem.current = index;
|
|
setDraggingIndex(index);
|
|
};
|
|
|
|
const onDragEnter = (index: number) => {
|
|
if (dragItem.current === null) return;
|
|
if (dragItem.current === index) return;
|
|
|
|
const newSteps = [...steps];
|
|
const draggedItemContent = newSteps.splice(dragItem.current, 1)[0];
|
|
newSteps.splice(index, 0, draggedItemContent);
|
|
|
|
setSteps(newSteps);
|
|
dragItem.current = index;
|
|
setDraggingIndex(index);
|
|
};
|
|
|
|
const onDragEnd = () => {
|
|
dragItem.current = null;
|
|
setDraggingIndex(null);
|
|
};
|
|
|
|
if (isEditing) {
|
|
return (
|
|
<div className="h-full flex flex-col bg-surface">
|
|
<div className="px-4 py-3 bg-surface-container border-b border-outline-variant flex justify-between items-center">
|
|
<button onClick={() => setIsEditing(false)} className="p-2 text-on-surface-variant hover:bg-white/5 rounded-full"><X /></button>
|
|
<h2 className="text-title-medium font-medium text-on-surface">{t('plan_editor', lang)}</h2>
|
|
<button onClick={handleSave} className="p-2 text-primary font-medium">
|
|
{t('save', lang)}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
|
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2">
|
|
<label className="text-[10px] text-on-surface-variant font-medium">{t('ex_name', lang)}</label>
|
|
<input
|
|
className="w-full bg-transparent text-xl text-on-surface focus:outline-none pt-1 pb-2"
|
|
placeholder={t('plan_name_ph', lang)}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
autoCapitalize="words"
|
|
onBlur={() => setName(toTitleCase(name))}
|
|
/>
|
|
</div>
|
|
|
|
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2">
|
|
<label className="text-[10px] text-on-surface-variant font-medium">{t('prep_title', lang)}</label>
|
|
<textarea
|
|
className="w-full bg-transparent text-base text-on-surface focus:outline-none pt-1 pb-2 min-h-[80px]"
|
|
placeholder={t('plan_desc_ph', lang)}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between items-center px-2">
|
|
<label className="text-sm text-primary font-medium">{t('exercises_list', lang)}</label>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{steps.map((step, idx) => (
|
|
<div
|
|
key={step.id}
|
|
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' : ''}`}
|
|
draggable
|
|
onDragStart={() => onDragStart(idx)}
|
|
onDragEnter={() => onDragEnter(idx)}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDragEnd={onDragEnd}
|
|
>
|
|
<div className={`text-on-surface-variant p-1 ${draggingIndex === idx ? 'cursor-grabbing' : 'cursor-grab'}`}>
|
|
<GripVertical size={20} />
|
|
</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">
|
|
{idx + 1}
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<div className="text-base font-medium text-on-surface">{step.exerciseName}</div>
|
|
<label className="flex items-center gap-2 mt-1 cursor-pointer w-fit">
|
|
<div className={`w-4 h-4 border rounded flex items-center justify-center ${step.isWeighted ? 'bg-primary border-primary' : 'border-outline'}`}>
|
|
{step.isWeighted && <Scale size={10} className="text-on-primary" />}
|
|
</div>
|
|
<input
|
|
type="checkbox"
|
|
checked={step.isWeighted}
|
|
onChange={() => toggleWeighted(step.id)}
|
|
className="hidden"
|
|
/>
|
|
<span className="text-xs text-on-surface-variant">{t('weighted', lang)}</span>
|
|
</label>
|
|
</div>
|
|
<button onClick={() => removeStep(step.id)} className="text-on-surface-variant hover:text-error p-2">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setShowExerciseSelector(true)}
|
|
className="w-full py-4 rounded-full border border-outline text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary-container/10 transition-all"
|
|
>
|
|
<Plus size={20} />
|
|
{t('add_exercise', lang)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{showExerciseSelector && (
|
|
<div className="fixed 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">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-2">
|
|
{availableExercises
|
|
.slice()
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
.map(ex => (
|
|
<button
|
|
key={ex.id}
|
|
onClick={() => addStep(ex)}
|
|
className="w-full text-left p-4 border-b border-outline-variant hover:bg-surface-container-high text-on-surface flex justify-between"
|
|
>
|
|
<span>{ex.name}</span>
|
|
<span className="text-xs bg-secondary-container text-on-secondary-container px-2 py-1 rounded-full">{ex.type}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{isCreatingExercise && (
|
|
<div className="fixed 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
|
|
autocapitalize="words"
|
|
onBlur={() => setNewExName(toTitleCase(newExName))}
|
|
/>
|
|
|
|
<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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-surface relative">
|
|
<div className="p-4 bg-surface-container shadow-elevation-1 z-10">
|
|
<h2 className="text-2xl font-normal text-on-surface">{t('my_plans', lang)}</h2>
|
|
</div>
|
|
|
|
<div className="flex-1 p-4 overflow-y-auto space-y-4 pb-24">
|
|
{plans.length === 0 ? (
|
|
<div className="text-center text-on-surface-variant mt-20 flex flex-col items-center">
|
|
<div className="w-16 h-16 bg-surface-container-high rounded-full flex items-center justify-center mb-4">
|
|
<List size={32} />
|
|
</div>
|
|
<p className="text-lg">{t('plans_empty', lang)}</p>
|
|
</div>
|
|
) : (
|
|
plans.map(plan => (
|
|
<div key={plan.id} className="bg-surface-container rounded-xl p-4 shadow-elevation-1 border border-outline-variant/20 relative overflow-hidden">
|
|
<div className="flex justify-between items-start mb-2">
|
|
<h3 className="text-xl font-normal text-on-surface">{plan.name}</h3>
|
|
<button
|
|
onClick={(e) => handleDelete(plan.id, e)}
|
|
className="text-on-surface-variant hover:text-error p-2 rounded-full hover:bg-white/5"
|
|
>
|
|
<Trash2 size={20} />
|
|
</button>
|
|
</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]">
|
|
{plan.description || t('prep_no_instructions', lang)}
|
|
</p>
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-xs font-medium text-primary bg-primary-container/20 px-3 py-1 rounded-full">
|
|
{plan.steps.length} {t('exercises_count', lang)}
|
|
</div>
|
|
<button
|
|
onClick={() => onStartPlan(plan)}
|
|
className="flex items-center gap-2 bg-primary text-on-primary px-5 py-2 rounded-full text-sm font-medium hover:shadow-elevation-2 transition-all"
|
|
>
|
|
<PlayCircle size={18} />
|
|
{t('start', lang)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* FAB */}
|
|
<button
|
|
onClick={handleCreateNew}
|
|
className="absolute bottom-6 right-6 w-14 h-14 bg-primary-container text-on-primary-container rounded-[16px] shadow-elevation-3 flex items-center justify-center hover:bg-primary hover:text-on-primary transition-colors z-20"
|
|
>
|
|
<Plus size={28} />
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Plans; |