Timer is persistent with Local Storage. Drag&Drop of planned sets fixed on mobile.

This commit is contained in:
AG
2025-12-12 20:59:51 +02:00
parent e1253f4100
commit 7d82444e94
5 changed files with 343 additions and 120 deletions

View File

@@ -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)}

View File

@@ -180,7 +180,7 @@ const SetLogger: React.FC<SetLoggerProps> = ({ tracker, lang, onLogSet, isSporad
: 'bg-primary-container text-on-primary-container'
}`}
>
{isSporadic && sporadicSuccess ? <CheckCircle size={24} /> : (isSporadic ? <Plus size={24} /> : <CheckCircle size={24} />)}
{isSporadic && sporadicSuccess ? <CheckCircle size={24} /> : <Plus size={24} />}
<span>{isSporadic && sporadicSuccess ? t('saved', lang) : t('log_set', lang)}</span>
</button>
</div>