Timer is persistent with Local Storage. Drag&Drop of planned sets fixed on mobile.
This commit is contained in:
@@ -1,5 +1,24 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Pencil, User, Flame, Timer as TimerIcon, Ruler, Footprints, Activity, Percent, CheckCircle, GripVertical, Scale, Search } from 'lucide-react';
|
||||
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, List, ArrowUp, Pencil, User, Flame, Timer as TimerIcon, Ruler, Footprints, Percent, CheckCircle, GripVertical } from 'lucide-react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
TouchSensor,
|
||||
MouseSensor
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { WorkoutPlan, ExerciseDef, PlannedSet, Language, ExerciseType } from '../types';
|
||||
import { getExercises, saveExercise } from '../services/storage';
|
||||
import { t } from '../services/i18n';
|
||||
@@ -21,6 +40,87 @@ interface PlansProps {
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
// Sortable Item Component
|
||||
interface SortablePlanStepProps {
|
||||
step: PlannedSet;
|
||||
index: number;
|
||||
toggleWeighted: (id: string) => void;
|
||||
updateRestTime: (id: string, val: number | undefined) => void;
|
||||
removeStep: (id: string) => void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const SortablePlanStep = ({ step, index, toggleWeighted, updateRestTime, removeStep, lang }: SortablePlanStepProps) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id: step.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 1000 : 1,
|
||||
position: 'relative' as 'relative',
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<Card
|
||||
className={`flex items-center gap-3 transition-all hover:bg-surface-container-high ${isDragging ? 'bg-surface-container-high shadow-elevation-3' : ''}`}
|
||||
>
|
||||
<div className="text-on-surface-variant p-1 cursor-grab touch-none" {...listeners}>
|
||||
<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">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium text-on-surface">{step.exerciseName}</div>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<label className="flex items-center gap-2 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 && <Dumbbell 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 className="flex items-center gap-2">
|
||||
<TimerIcon size={14} className="text-on-surface-variant" />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Rest (s)"
|
||||
className="w-16 bg-transparent border-b border-outline-variant text-xs text-on-surface focus:border-primary focus:outline-none text-center"
|
||||
value={step.restTimeSeconds || ''}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
updateRestTime(step.id, isNaN(val) ? undefined : val);
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-on-surface-variant">s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => removeStep(step.id)} variant="ghost" size="icon" className="text-on-surface-variant hover:text-error hover:bg-error/10">
|
||||
<Trash2 size={20} />
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
const { currentUser } = useAuth();
|
||||
const userId = currentUser?.id || '';
|
||||
@@ -34,14 +134,25 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
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);
|
||||
// Dnd Sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor), // Handle mouse and basic pointer events
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
// Small delay or tolerance can help distinguish scrolling from dragging,
|
||||
// but usually for a handle drag, instant is fine or defaults work.
|
||||
// Let's add a small activation constraint to prevent accidental drags while scrolling if picking by handle
|
||||
activationConstraint: {
|
||||
distance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Create Exercise State
|
||||
const [availableExercises, setAvailableExercises] = useState<ExerciseDef[]>([]);
|
||||
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
|
||||
const [isCreatingExercise, setIsCreatingExercise] = useState(false);
|
||||
|
||||
// Preparation Modal State
|
||||
@@ -143,29 +254,16 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
setSteps(steps.filter(s => s.id !== stepId));
|
||||
};
|
||||
|
||||
const onDragStart = (index: number) => {
|
||||
console.log('Drag Start:', index);
|
||||
dragItem.current = index;
|
||||
setDraggingIndex(index);
|
||||
};
|
||||
const onDragEnter = (index: number) => {
|
||||
console.log('Drag Enter:', index);
|
||||
if (dragItem.current === null) return;
|
||||
if (dragItem.current === index) return;
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
const newSteps = [...steps];
|
||||
const draggedItemContent = newSteps.splice(dragItem.current, 1)[0];
|
||||
newSteps.splice(index, 0, draggedItemContent);
|
||||
|
||||
setSteps(newSteps);
|
||||
dragItem.current = index;
|
||||
setDraggingIndex(index);
|
||||
console.log(`Swapped ${draggedItemContent.id} to ${index}`);
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragItem.current = null;
|
||||
setDraggingIndex(null);
|
||||
if (active.id !== over?.id) {
|
||||
setSteps((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over?.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
@@ -206,61 +304,28 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => (
|
||||
<Card
|
||||
key={step.id}
|
||||
className={`flex items-center gap-3 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}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={steps.map(s => s.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<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>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<label className="flex items-center gap-2 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 && <Dumbbell 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 className="flex items-center gap-2">
|
||||
<TimerIcon size={14} className="text-on-surface-variant" />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Rest (s)"
|
||||
className="w-16 bg-transparent border-b border-outline-variant text-xs text-on-surface focus:border-primary focus:outline-none text-center"
|
||||
value={step.restTimeSeconds || ''}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
updateRestTime(step.id, isNaN(val) ? undefined : val);
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-on-surface-variant">s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => removeStep(step.id)} variant="ghost" size="icon" className="text-on-surface-variant hover:text-error hover:bg-error/10">
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
{steps.map((step, idx) => (
|
||||
<SortablePlanStep
|
||||
key={step.id}
|
||||
step={step}
|
||||
index={idx}
|
||||
toggleWeighted={toggleWeighted}
|
||||
updateRestTime={updateRestTime}
|
||||
removeStep={removeStep}
|
||||
lang={lang}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -275,6 +340,37 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SideSheet
|
||||
isOpen={showExerciseSelector}
|
||||
onClose={() => setShowExerciseSelector(false)}
|
||||
title={t('select_exercise', lang)}
|
||||
width="lg"
|
||||
>
|
||||
<div className="flex flex-col h-[60vh]">
|
||||
<div className="flex justify-end mb-2">
|
||||
<Button onClick={() => setIsCreatingExercise(true)} variant="ghost" className="text-primary hover:bg-primary-container/20 flex gap-2">
|
||||
<Plus size={18} /> {t('create_exercise', lang)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto -mx-6 px-6">
|
||||
{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 group"
|
||||
>
|
||||
<span className="group-hover:text-primary transition-colors">{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>
|
||||
</div>
|
||||
</SideSheet>
|
||||
|
||||
|
||||
<SideSheet
|
||||
isOpen={showExerciseSelector}
|
||||
onClose={() => setShowExerciseSelector(false)}
|
||||
|
||||
Reference in New Issue
Block a user