浏览代码

feat(workout-builder): implement exercise picking functionality with modal and action handling

- Add `pickExerciseAction` to handle exercise selection logic.
- Create `ExercisePickModal` for user confirmation when picking an exercise.
- Integrate picking functionality into `useWorkoutBuilderStore` and `WorkoutStepper`.
- Update `ExerciseListItem` to trigger the pick modal and handle exercise selection.
Mathias 1 月之前
父节点
当前提交
96a636e204

+ 28 - 0
src/features/workout-builder/actions/pick-exercise.action.ts

@@ -0,0 +1,28 @@
+"use server";
+
+import { z } from "zod";
+
+import { actionClient } from "@/shared/api/safe-actions";
+
+const pickExerciseSchema = z.object({
+  exerciseId: z.string(),
+});
+
+export const pickExerciseAction = actionClient.schema(pickExerciseSchema).action(async ({ parsedInput }) => {
+  try {
+    const { exerciseId } = parsedInput;
+
+    // Pour l'instant, on retourne juste l'ID de l'exercice
+    // Plus tard, on pourra ajouter de la logique pour marquer l'exercice comme "picked"
+    // dans une base de données ou un système de préférences utilisateur
+
+    return {
+      success: true,
+      exerciseId,
+      message: "Exercise picked successfully",
+    };
+  } catch (error) {
+    console.error("Error picking exercise:", error);
+    return { serverError: "Failed to pick exercise" };
+  }
+});

+ 4 - 0
src/features/workout-builder/model/use-workout-stepper.ts

@@ -21,6 +21,7 @@ export function useWorkoutStepper() {
     exercisesOrder,
     setExercisesOrder,
     shuffleExercise,
+    pickExercise,
     isShuffling,
   } = useWorkoutBuilderStore();
 
@@ -68,5 +69,8 @@ export function useWorkoutStepper() {
 
     // Additional
     isShuffling,
+
+    // Pick
+    pickExercise,
   };
 }

+ 24 - 0
src/features/workout-builder/model/workout-builder.store.ts

@@ -3,6 +3,7 @@ import { ExerciseAttributeValueEnum, WorkoutSessionExercise } from "@prisma/clie
 
 import { WorkoutBuilderStep } from "../types";
 import { shuffleExerciseAction } from "../actions/shuffle-exercise.action";
+import { pickExerciseAction } from "../actions/pick-exercise.action";
 import { getExercisesAction } from "./get-exercises.action";
 
 interface WorkoutBuilderState {
@@ -27,6 +28,7 @@ interface WorkoutBuilderState {
   fetchExercises: () => Promise<void>;
   setExercisesOrder: (order: string[]) => void;
   shuffleExercise: (exerciseId: string, muscle: ExerciseAttributeValueEnum) => Promise<void>;
+  pickExercise: (exerciseId: string) => Promise<void>;
   loadFromSession: (params: {
     equipment: ExerciseAttributeValueEnum[];
     muscles: ExerciseAttributeValueEnum[];
@@ -129,6 +131,28 @@ export const useWorkoutBuilderStore = create<WorkoutBuilderState>((set, get) =>
     }
   },
 
+  pickExercise: async (exerciseId) => {
+    try {
+      const result = await pickExerciseAction({ exerciseId });
+
+      if (result?.serverError) {
+        throw new Error(result.serverError);
+      }
+
+      if (result?.data?.success) {
+        // Pour l'instant, on affiche juste un message de succès
+        // Plus tard, on pourra ajouter de la logique pour marquer visuellement l'exercice
+        console.log("Exercise picked successfully:", exerciseId);
+
+        // Optionnel: on pourrait ajouter une propriété "picked" aux exercices
+        // ou maintenir une liste des exercices "picked"
+      }
+    } catch (error) {
+      console.error("Error picking exercise:", error);
+      throw error;
+    }
+  },
+
   loadFromSession: ({ equipment, muscles, exercisesByMuscle, exercisesOrder }) => {
     set({
       selectedEquipment: equipment,

+ 24 - 1
src/features/workout-builder/ui/exercise-list-item.tsx

@@ -9,6 +9,7 @@ import { InlineTooltip } from "@/components/ui/tooltip";
 import { Button } from "@/components/ui/button";
 
 import { ExerciseVideoModal } from "./exercise-video-modal";
+import { ExercisePickModal } from "./exercise-pick-modal";
 
 import type { ExerciseWithAttributes } from "../types";
 
@@ -27,6 +28,7 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
   const locale = useCurrentLocale();
   const exerciseName = locale === "fr" ? exercise.name : exercise.nameEn;
   const [showVideo, setShowVideo] = useState(false);
+  const [showPickModal, setShowPickModal] = useState(false);
 
   // dnd-kit sortable
   const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: exercise.id });
@@ -42,6 +44,18 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
     setShowVideo(true);
   };
 
+  const handleOpenPickModal = () => {
+    setShowPickModal(true);
+  };
+
+  const handleClosePickModal = () => {
+    setShowPickModal(false);
+  };
+
+  const handleConfirmPick = () => {
+    onPick(exercise.id);
+  };
+
   // Déterminer la couleur du muscle
   const getMuscleConfig = (muscle: string) => {
     const configs: Record<string, { color: string; bg: string }> = {
@@ -136,7 +150,7 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
           {/* Bouton pick */}
           <Button
             className="p-1 sm:p-2 bg-blue-50 dark:bg-blue-950/50 hover:bg-blue-100 dark:hover:bg-blue-950 text-blue-600 dark:text-blue-400 border-2 border-blue-200 dark:border-blue-800"
-            onClick={() => onPick(exercise.id)}
+            onClick={handleOpenPickModal}
             size="small"
           >
             <Star className="h-3.5 w-3.5" />
@@ -157,6 +171,15 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
 
       {/* Video Modal */}
       {exercise.fullVideoUrl && <ExerciseVideoModal exercise={exercise} onOpenChange={setShowVideo} open={showVideo} />}
+
+      {/* Pick Modal */}
+      <ExercisePickModal
+        exercise={exercise}
+        isOpen={showPickModal}
+        muscle={muscle}
+        onClose={handleClosePickModal}
+        onConfirmPick={handleConfirmPick}
+      />
     </div>
   );
 }

+ 170 - 0
src/features/workout-builder/ui/exercise-pick-modal.tsx

@@ -0,0 +1,170 @@
+import { useEffect, useRef } from "react";
+import Image from "next/image";
+import { X, Play } from "lucide-react";
+
+import { useCurrentLocale, useI18n } from "locales/client";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+
+import type { ExerciseWithAttributes } from "../types";
+
+interface ExercisePickModalProps {
+  exercise: ExerciseWithAttributes | null;
+  muscle: string;
+  isOpen: boolean;
+  onClose: () => void;
+  onConfirmPick: () => void;
+}
+
+export function ExercisePickModal({ exercise, muscle, isOpen, onClose, onConfirmPick }: ExercisePickModalProps) {
+  const t = useI18n();
+  const locale = useCurrentLocale();
+  const modalRef = useRef<HTMLDialogElement>(null);
+
+  useEffect(() => {
+    const modal = modalRef.current;
+    if (!modal) return;
+
+    if (isOpen) {
+      modal.showModal();
+    } else {
+      modal.close();
+    }
+  }, [isOpen]);
+
+  useEffect(() => {
+    const modal = modalRef.current;
+    if (!modal) return;
+
+    const handleClose = () => {
+      onClose();
+    };
+
+    modal.addEventListener("close", handleClose);
+    return () => modal.removeEventListener("close", handleClose);
+  }, [onClose]);
+
+  if (!exercise) return null;
+
+  const exerciseName = locale === "fr" ? exercise.name : exercise.nameEn;
+  const exerciseDescription = locale === "fr" ? exercise.description : exercise.descriptionEn;
+
+  // Extraire les attributs utiles
+  const equipmentAttributes =
+    exercise.attributes?.filter((attr) => attr.attributeName.name === "EQUIPMENT").map((attr) => attr.attributeValue.value) || [];
+
+  const typeAttributes =
+    exercise.attributes?.filter((attr) => attr.attributeName.name === "TYPE").map((attr) => attr.attributeValue.value) || [];
+
+  const mechanicsType = exercise.attributes?.find((attr) => attr.attributeName.name === "MECHANICS_TYPE")?.attributeValue.value;
+
+  const handleConfirm = (e: React.MouseEvent) => {
+    e.preventDefault();
+    onConfirmPick();
+    onClose();
+  };
+
+  return (
+    <dialog className="modal modal-bottom sm:modal-middle" ref={modalRef}>
+      <div className="modal-box max-w-2xl">
+        {/* Header */}
+        <div className="flex items-start justify-between mb-4">
+          <div className="flex-1">
+            <h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">{exerciseName}</h3>
+            <div className="flex items-center gap-2 mt-2">
+              <Badge className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100" variant="outline">
+                {t(`workout_builder.muscles.${muscle.toLowerCase()}` as keyof typeof t)}
+              </Badge>
+              {mechanicsType && (
+                <Badge className="text-xs" variant="outline">
+                  {mechanicsType}
+                </Badge>
+              )}
+            </div>
+          </div>
+          <form method="dialog">
+            <Button className="p-1" size="small" variant="ghost">
+              <X className="h-4 w-4" />
+            </Button>
+          </form>
+        </div>
+
+        {/* Image/Video */}
+        {exercise.fullVideoImageUrl && (
+          <div className="relative h-48 bg-gradient-to-br from-slate-200 to-slate-200 dark:from-slate-700 dark:to-slate-800 rounded-lg overflow-hidden mb-4">
+            <Image
+              alt={exerciseName || "Exercise"}
+              className="object-cover"
+              fill
+              sizes="(max-width: 768px) 100vw, 50vw"
+              src={exercise.fullVideoImageUrl}
+            />
+            {exercise.fullVideoUrl && (
+              <div className="absolute inset-0 bg-black/20 flex items-center justify-center">
+                <Button className="bg-white/90 text-slate-900" size="small" variant="secondary">
+                  <Play className="h-4 w-4 mr-2" />
+                  {t("workout_builder.exercise.watch_video")}
+                </Button>
+              </div>
+            )}
+          </div>
+        )}
+
+        {/* Description */}
+        {exerciseDescription && (
+          <div className="mb-4">
+            <h4 className="font-semibold text-sm text-slate-900 dark:text-slate-100 mb-2">Description</h4>
+            <p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">{exerciseDescription}</p>
+          </div>
+        )}
+
+        {/* Attributes */}
+        <div className="space-y-3 mb-6">
+          {/* Equipment */}
+          {equipmentAttributes.length > 0 && (
+            <div>
+              <h4 className="font-semibold text-sm text-slate-900 dark:text-slate-100 mb-2">Equipment</h4>
+              <div className="flex flex-wrap gap-1">
+                {equipmentAttributes.map((equipment, index) => (
+                  <Badge className="text-xs" key={index} variant="outline">
+                    {equipment.replace("_", " ")}
+                  </Badge>
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Exercise Types */}
+          {typeAttributes.length > 0 && (
+            <div>
+              <h4 className="font-semibold text-sm text-slate-900 dark:text-slate-100 mb-2">Exercise Types</h4>
+              <div className="flex flex-wrap gap-1">
+                {typeAttributes.map((type, index) => (
+                  <Badge
+                    className="text-xs bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100"
+                    key={index}
+                    variant="default"
+                  >
+                    {type}
+                  </Badge>
+                ))}
+              </div>
+            </div>
+          )}
+        </div>
+
+        {/* Actions */}
+        <div className="modal-action">
+          <form className="flex gap-2" method="dialog">
+            <Button size="small" variant="outline">
+              Cancel
+            </Button>
+            <Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleConfirm} size="small">
+              ⭐ Confirm Pick
+            </Button>
+          </form>
+        </div>
+      </div>
+    </dialog>
+  );
+}

+ 16 - 8
src/features/workout-builder/ui/workout-stepper.tsx

@@ -32,20 +32,23 @@ export function WorkoutStepper() {
   const {
     currentStep,
     selectedEquipment,
+    selectedMuscles,
+    exercisesByMuscle,
+    isLoadingExercises,
+    exercisesError,
+    goToStep,
     nextStep,
     prevStep,
     toggleEquipment,
     clearEquipment,
-    selectedMuscles,
     toggleMuscle,
     canProceedToStep2,
     canProceedToStep3,
-    isLoadingExercises,
-    exercisesByMuscle,
-    exercisesError,
     fetchExercises,
     exercisesOrder,
+    setExercisesOrder,
     shuffleExercise,
+    pickExercise,
     isShuffling,
   } = useWorkoutStepper();
 
@@ -99,10 +102,15 @@ export function WorkoutStepper() {
     }
   };
 
-  const handlePickExercise = (exerciseId: string) => {
-    // later
-    alert("TODO : Pick exercise");
-    console.log("Pick exercise:", exerciseId);
+  const handlePickExercise = async (exerciseId: string) => {
+    try {
+      await pickExercise(exerciseId);
+      // Optionnel: afficher un toast de succès
+      console.log("Exercise picked successfully!");
+    } catch (error) {
+      console.error("Error picking exercise:", error);
+      alert("Error picking exercise. Please try again.");
+    }
   };
 
   const handleDeleteExercise = (exerciseId: string, muscle: string) => {