exercises-selection.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import { useState, useEffect, useCallback, useMemo } from "react";
  2. import { Loader2, Plus } from "lucide-react";
  3. import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
  4. import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
  5. import { DndContext, closestCenter, TouchSensor, MouseSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core";
  6. import { useI18n } from "locales/client";
  7. import { useWorkoutStepper } from "../model/use-workout-stepper";
  8. import { ExerciseListItem } from "./exercise-list-item";
  9. import type { ExerciseWithAttributes } from "../types";
  10. interface ExercisesSelectionProps {
  11. isLoading: boolean;
  12. exercisesByMuscle: { muscle: string; exercises: ExerciseWithAttributes[] }[];
  13. error: any;
  14. onShuffle: (exerciseId: string, muscle: string) => void;
  15. onPick: (exerciseId: string) => void;
  16. onDelete: (exerciseId: string, muscle: string) => void;
  17. onAdd: () => void;
  18. shufflingExerciseId?: string | null;
  19. }
  20. export const ExercisesSelection = ({
  21. isLoading,
  22. exercisesByMuscle,
  23. error,
  24. onShuffle,
  25. onPick,
  26. onDelete,
  27. onAdd,
  28. shufflingExerciseId,
  29. }: ExercisesSelectionProps) => {
  30. const t = useI18n();
  31. const [flatExercises, setFlatExercises] = useState<{ id: string; muscle: string; exercise: ExerciseWithAttributes }[]>([]);
  32. const { setExercisesOrder, exercisesOrder } = useWorkoutStepper();
  33. const sensors = useSensors(
  34. useSensor(MouseSensor, {
  35. activationConstraint: {
  36. distance: 5,
  37. },
  38. }),
  39. useSensor(TouchSensor, {
  40. activationConstraint: {
  41. delay: 100,
  42. tolerance: 5,
  43. },
  44. }),
  45. );
  46. const sortableItems = useMemo(() => flatExercises.map((item) => item.id), [flatExercises]);
  47. const flatExercisesComputed = useMemo(() => {
  48. if (exercisesByMuscle.length === 0) return [];
  49. const flat = exercisesByMuscle.flatMap((group) =>
  50. group.exercises.map((exercise) => ({
  51. id: exercise.id,
  52. muscle: group.muscle,
  53. exercise,
  54. })),
  55. );
  56. if (exercisesOrder.length === 0) return flat;
  57. const exerciseMap = new Map(flat.map((item) => [item.id, item]));
  58. const orderedFlat = exercisesOrder.map((id) => exerciseMap.get(id)).filter(Boolean) as typeof flat;
  59. const newExercises = flat.filter((item) => !exercisesOrder.includes(item.id));
  60. return [...orderedFlat, ...newExercises];
  61. }, [exercisesByMuscle, exercisesOrder]);
  62. useEffect(() => {
  63. setFlatExercises(flatExercisesComputed);
  64. }, [flatExercisesComputed]);
  65. const handleDragEnd = useCallback(
  66. (event: DragEndEvent) => {
  67. const { active, over } = event;
  68. if (active.id !== over?.id) {
  69. setFlatExercises((items) => {
  70. const oldIndex = items.findIndex((item) => item.id === active.id);
  71. const newIndex = items.findIndex((item) => item.id === over?.id);
  72. const newOrder = arrayMove(items, oldIndex, newIndex);
  73. setExercisesOrder(newOrder.map((item) => item.id));
  74. return newOrder;
  75. });
  76. }
  77. },
  78. [setExercisesOrder],
  79. );
  80. if (isLoading) {
  81. return (
  82. <div className="space-y-6">
  83. <div className="text-center">
  84. <Loader2 className="h-8 w-8 animate-spin mx-auto text-blue-600" />
  85. <p className="mt-4 text-slate-600 dark:text-slate-400">{t("workout_builder.loading.exercises")}</p>
  86. </div>
  87. </div>
  88. );
  89. }
  90. return (
  91. <div className="space-y-6">
  92. {flatExercises.length > 0 ? (
  93. <div className="max-w-4xl mx-auto">
  94. {/* Liste des exercices drag and drop */}
  95. <DndContext collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis]} onDragEnd={handleDragEnd} sensors={sensors}>
  96. <SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
  97. <div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden">
  98. {flatExercises.map((item) => (
  99. <ExerciseListItem
  100. exercise={item.exercise}
  101. isShuffling={shufflingExerciseId === item.exercise.id}
  102. key={item.id}
  103. muscle={item.muscle}
  104. onDelete={onDelete}
  105. onPick={onPick}
  106. onShuffle={onShuffle}
  107. />
  108. ))}
  109. <div className="border-t border-slate-200 dark:border-slate-800">
  110. <button
  111. className="w-full flex items-center gap-3 py-4 px-4 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-950 transition-colors"
  112. onClick={onAdd}
  113. >
  114. <div className="h-8 w-8 rounded-full bg-blue-600 flex items-center justify-center">
  115. <Plus className="h-4 w-4 text-white" />
  116. </div>
  117. <span className="font-medium">{t("commons.add")}</span>
  118. </button>
  119. </div>
  120. </div>
  121. </SortableContext>
  122. </DndContext>
  123. </div>
  124. ) : error ? (
  125. <div className="text-center py-20">
  126. <p className="text-red-600 dark:text-red-400">{t("workout_builder.error.loading_exercises")}</p>
  127. </div>
  128. ) : (
  129. <div className="text-center py-20">
  130. <p className="text-slate-600 dark:text-slate-400">{t("workout_builder.no_exercises_found")}</p>
  131. </div>
  132. )}
  133. </div>
  134. );
  135. };