exercise-list-item.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. import { useState, useMemo, useCallback } from "react";
  2. import Image from "next/image";
  3. import { Play, Shuffle, Trash2, GripVertical, Loader2 } from "lucide-react";
  4. import { CSS } from "@dnd-kit/utilities";
  5. import { useSortable } from "@dnd-kit/sortable";
  6. import { useCurrentLocale, useI18n } from "locales/client";
  7. import { InlineTooltip } from "@/components/ui/tooltip";
  8. import { Button } from "@/components/ui/button";
  9. import { ExerciseVideoModal } from "./exercise-video-modal";
  10. import { ExercisePickModal } from "./exercise-pick-modal";
  11. import type { ExerciseWithAttributes } from "../types";
  12. interface ExerciseListItemProps {
  13. exercise: ExerciseWithAttributes;
  14. muscle: string;
  15. onShuffle: (exerciseId: string, muscle: string) => void;
  16. onPick: (exerciseId: string) => void;
  17. onDelete: (exerciseId: string, muscle: string) => void;
  18. isShuffling?: boolean;
  19. }
  20. const MUSCLE_CONFIGS: Record<string, { color: string; bg: string }> = {
  21. ABDOMINALS: { color: "text-red-600 dark:text-red-400", bg: "bg-red-50 dark:bg-red-950/50" },
  22. BICEPS: { color: "text-purple-600 dark:text-purple-400", bg: "bg-purple-50 dark:bg-purple-950/50" },
  23. BACK: { color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-50 dark:bg-blue-950/50" },
  24. CHEST: { color: "text-green-600 dark:text-green-400", bg: "bg-green-50 dark:bg-green-950/50" },
  25. SHOULDERS: { color: "text-orange-600 dark:text-orange-400", bg: "bg-orange-50 dark:bg-orange-950/50" },
  26. OBLIQUES: { color: "text-pink-600 dark:text-pink-400", bg: "bg-pink-50 dark:bg-pink-950/50" },
  27. };
  28. const DEFAULT_MUSCLE_CONFIG = { color: "text-slate-600 dark:text-slate-400", bg: "bg-slate-50 dark:bg-slate-950/50" };
  29. export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete, isShuffling }: ExerciseListItemProps) {
  30. const t = useI18n();
  31. const locale = useCurrentLocale();
  32. const [showVideo, setShowVideo] = useState(false);
  33. const [showPickModal, setShowPickModal] = useState(false);
  34. const exerciseName = useMemo(() => (locale === "fr" ? exercise.name : exercise.nameEn), [locale, exercise.name, exercise.nameEn]);
  35. const muscleConfig = useMemo(() => MUSCLE_CONFIGS[muscle] || DEFAULT_MUSCLE_CONFIG, [muscle]);
  36. const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: exercise.id });
  37. const style = useMemo(
  38. () => ({
  39. transform: CSS.Transform.toString(transform),
  40. transition,
  41. zIndex: isDragging ? 50 : 1,
  42. boxShadow: isDragging ? "0 4px 16px 0 rgba(0,0,0,0.10)" : undefined,
  43. }),
  44. [transform, transition, isDragging],
  45. );
  46. // Mémoriser les handlers
  47. const handleOpenVideo = useCallback(() => {
  48. setShowVideo(true);
  49. }, []);
  50. const handleClosePickModal = useCallback(() => {
  51. setShowPickModal(false);
  52. }, []);
  53. const handleConfirmPick = useCallback(() => {
  54. onPick(exercise.id);
  55. }, [onPick, exercise.id]);
  56. const handleShuffle = useCallback(() => {
  57. onShuffle(exercise.id, muscle);
  58. }, [onShuffle, exercise.id, muscle]);
  59. const handleDelete = useCallback(() => {
  60. onDelete(exercise.id, muscle);
  61. }, [onDelete, exercise.id, muscle]);
  62. const muscleTitle = useMemo(() => t(("workout_builder.muscles." + muscle.toLowerCase()) as keyof typeof t), [t, muscle]);
  63. return (
  64. <div
  65. className={`
  66. group relative overflow-hidden transition-all duration-300 ease-out
  67. bg-white dark:bg-slate-900 sm:hover:bg-slate-50 dark:sm:hover:bg-slate-800/70
  68. border-b border-slate-200 dark:border-slate-700/50
  69. sm:hover:shadow-lg sm:hover:shadow-slate-200/50 dark:sm:hover:shadow-slate-900/50
  70. ${isDragging ? "ring-2 ring-blue-400" : ""}
  71. `}
  72. ref={setNodeRef}
  73. style={style}
  74. >
  75. <div className="relative flex items-center justify-between py-2 px-2">
  76. <div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
  77. {/* Drag handle */}
  78. <div
  79. className="flex items-center justify-center p-2 -m-2 touch-manipulation cursor-grab active:cursor-grabbing"
  80. {...attributes}
  81. {...listeners}
  82. >
  83. <GripVertical className="h-6 w-6 sm:h-5 sm:w-5 text-slate-400 dark:text-slate-500" />
  84. </div>
  85. {exercise.fullVideoImageUrl && (
  86. <div className="relative w-10 h-10 rounded-lg overflow-hidden shrink-0 bg-slate-200 dark:bg-slate-800 cursor-pointer border border-slate-200 dark:border-slate-700/50">
  87. <Image
  88. alt={exerciseName ?? ""}
  89. className="w-full h-full object-cover scale-[1.5]"
  90. height={40}
  91. loading="lazy"
  92. onError={(e) => {
  93. e.currentTarget.style.display = "none";
  94. }}
  95. priority={false}
  96. src={exercise.fullVideoImageUrl}
  97. width={40}
  98. />
  99. <div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 sm:group-hover:opacity-100 transition-opacity duration-200">
  100. <Play className="h-3 w-3 text-white fill-current" onClick={handleOpenVideo} />
  101. </div>
  102. </div>
  103. )}
  104. {/* Badge muscle avec animation */}
  105. <InlineTooltip className="cursor-pointer" title={muscleTitle}>
  106. <div
  107. className={`
  108. relative flex items-center justify-center w-5 h-5 rounded-sm font-bold text-xs shrink-0
  109. ${muscleConfig.bg} ${muscleConfig.color}
  110. transition-all duration-200
  111. cursor-pointer
  112. `}
  113. >
  114. {muscle.charAt(0)}
  115. </div>
  116. </InlineTooltip>
  117. {/* Nom de l'exercice avec indicateurs */}
  118. <InlineTooltip className="tooltip tooltip-bottom z-50 max-w-[300px]" title={exerciseName || ""}>
  119. <div className="flex-1 min-w-0 items">
  120. <div className="flex items-center gap-3 mb-1">
  121. <h3 className="font-semibold text-slate-900 dark:text-slate-200 md:truncate text-sm">{exerciseName}</h3>
  122. </div>
  123. </div>
  124. </InlineTooltip>
  125. </div>
  126. <div className="flex items-center gap-1 sm:gap-2 shrink-0 ml-1">
  127. {/* Bouton shuffle */}
  128. <Button
  129. className="p-2 sm:p-2 min-h-[44px] min-w-[44px] sm:min-h-min sm:min-w-min touch-manipulation"
  130. disabled={isShuffling}
  131. onClick={handleShuffle}
  132. size="small"
  133. variant="outline"
  134. >
  135. {isShuffling ? (
  136. <Loader2 className="h-4 w-4 sm:h-3.5 sm:w-3.5 animate-spin" />
  137. ) : (
  138. <Shuffle className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
  139. )}
  140. <span className="hidden sm:inline ml-1">{t("workout_builder.exercise.shuffle")}</span>
  141. </Button>
  142. {/* Bouton delete */}
  143. <Button
  144. className="p-2 sm:p-2 min-h-[44px] min-w-[44px] sm:min-h-min sm:min-w-min bg-red-50 dark:bg-red-950/50 sm:hover:bg-red-100 dark:sm:hover:bg-red-950 text-red-600 dark:text-red-400 border-0 rounded-lg opacity-100 sm:group-hover:opacity-100 transition-all duration-200 sm:hover:scale-110 touch-manipulation"
  145. onClick={handleDelete}
  146. size="small"
  147. variant="ghost"
  148. >
  149. <Trash2 className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
  150. </Button>
  151. </div>
  152. </div>
  153. {/* Video Modal */}
  154. {exercise.fullVideoUrl && <ExerciseVideoModal exercise={exercise} onOpenChange={setShowVideo} open={showVideo} />}
  155. {/* Pick Modal */}
  156. <ExercisePickModal
  157. exercise={exercise}
  158. isOpen={showPickModal}
  159. muscle={muscle}
  160. onClose={handleClosePickModal}
  161. onConfirmPick={handleConfirmPick}
  162. />
  163. </div>
  164. );
  165. }