exercise-list-item.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. import { useState } from "react";
  2. import Image from "next/image";
  3. import { Play, Shuffle, Star, Trash2, GripVertical } 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 type { ExerciseWithAttributes } from "../types";
  11. interface ExerciseListItemProps {
  12. exercise: ExerciseWithAttributes;
  13. muscle: string;
  14. onShuffle: (exerciseId: string, muscle: string) => void;
  15. onPick: (exerciseId: string) => void;
  16. onDelete: (exerciseId: string, muscle: string) => void;
  17. }
  18. export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete }: ExerciseListItemProps) {
  19. const t = useI18n();
  20. const [isHovered, setIsHovered] = useState(false);
  21. const locale = useCurrentLocale();
  22. const exerciseName = locale === "fr" ? exercise.name : exercise.nameEn;
  23. const [showVideo, setShowVideo] = useState(false);
  24. // dnd-kit sortable
  25. const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: exercise.id });
  26. const style = {
  27. transform: CSS.Transform.toString(transform),
  28. transition,
  29. zIndex: isDragging ? 50 : undefined,
  30. boxShadow: isDragging ? "0 4px 16px 0 rgba(0,0,0,0.10)" : undefined,
  31. };
  32. const handleOpenVideo = () => {
  33. setShowVideo(true);
  34. };
  35. // Déterminer la couleur du muscle
  36. const getMuscleConfig = (muscle: string) => {
  37. const configs: Record<string, { color: string; bg: string }> = {
  38. ABDOMINALS: { color: "text-red-600 dark:text-red-400", bg: "bg-red-50 dark:bg-red-950/50" },
  39. BICEPS: { color: "text-purple-600 dark:text-purple-400", bg: "bg-purple-50 dark:bg-purple-950/50" },
  40. BACK: { color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-50 dark:bg-blue-950/50" },
  41. CHEST: { color: "text-green-600 dark:text-green-400", bg: "bg-green-50 dark:bg-green-950/50" },
  42. SHOULDERS: { color: "text-orange-600 dark:text-orange-400", bg: "bg-orange-50 dark:bg-orange-950/50" },
  43. OBLIQUES: { color: "text-pink-600 dark:text-pink-400", bg: "bg-pink-50 dark:bg-pink-950/50" },
  44. };
  45. return configs[muscle] || { color: "text-slate-600 dark:text-slate-400", bg: "bg-slate-50 dark:bg-slate-950/50" };
  46. };
  47. const muscleConfig = getMuscleConfig(muscle);
  48. return (
  49. <div
  50. ref={setNodeRef}
  51. style={style}
  52. {...attributes}
  53. {...listeners}
  54. className={`
  55. group relative overflow-hidden transition-all duration-300 ease-out
  56. bg-white dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-800/70
  57. border-b border-slate-200 dark:border-slate-700/50
  58. ${isHovered ? "shadow-lg shadow-slate-200/50 dark:shadow-slate-900/50" : ""}
  59. ${isDragging ? "ring-2 ring-blue-400" : ""}
  60. `}
  61. onMouseEnter={() => setIsHovered(true)}
  62. onMouseLeave={() => setIsHovered(false)}
  63. >
  64. <div className="relative flex items-center justify-between py-2 px-2">
  65. {/* Section gauche - Infos principales */}
  66. <div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
  67. {/* Drag handle */}
  68. <GripVertical className="h-5 w-5 text-slate-400 dark:text-slate-500 cursor-grab active:cursor-grabbing" />
  69. {/* Image de l'exercice */}
  70. {exercise.fullVideoImageUrl && (
  71. <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">
  72. <Image
  73. alt={exerciseName ?? ""}
  74. className="w-full h-full object-cover scale-[1.5]"
  75. height={40}
  76. onError={(e) => {
  77. // Fallback si l'image ne charge pas
  78. e.currentTarget.style.display = "none";
  79. }}
  80. src={exercise.fullVideoImageUrl}
  81. width={40}
  82. />
  83. {/* Overlay play icon */}
  84. <div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
  85. <Play className="h-3 w-3 text-white fill-current" onClick={handleOpenVideo} />
  86. </div>
  87. </div>
  88. )}
  89. {/* Badge muscle avec animation */}
  90. <InlineTooltip className="cursor-pointer" title={t(("workout_builder.muscles." + muscle.toLowerCase()) as keyof typeof t)}>
  91. <div
  92. className={`
  93. relative flex items-center justify-center w-5 h-5 rounded-sm font-bold text-xs shrink-0
  94. ${muscleConfig.bg} ${muscleConfig.color}
  95. transition-all duration-200
  96. cursor-pointer
  97. `}
  98. >
  99. {muscle.charAt(0)}
  100. </div>
  101. </InlineTooltip>
  102. {/* Nom de l'exercice avec indicateurs */}
  103. <div className="flex-1 min-w-0">
  104. <div className="flex items-center gap-3 mb-1">
  105. <h3 className="font-semibold text-slate-900 dark:text-slate-200 truncate text-sm">{exerciseName}</h3>
  106. </div>
  107. </div>
  108. </div>
  109. {/* Section droite - Actions */}
  110. <div className="flex items-center gap-1 sm:gap-2 shrink-0">
  111. {/* Bouton shuffle */}
  112. <Button className="p-1 sm:p-2" onClick={() => onShuffle(exercise.id, muscle)} size="small" variant="outline">
  113. <Shuffle className="h-3.5 w-3.5" />
  114. <span className="hidden sm:inline">{t("workout_builder.exercise.shuffle")}</span>
  115. </Button>
  116. {/* Bouton pick */}
  117. <Button
  118. 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"
  119. onClick={() => onPick(exercise.id)}
  120. size="small"
  121. >
  122. <Star className="h-3.5 w-3.5" />
  123. <span className="hidden sm:inline">{t("workout_builder.exercise.pick")}</span>
  124. </Button>
  125. {/* Bouton delete */}
  126. <Button
  127. className="p-1 sm:p-2 bg-red-50 dark:bg-red-950/50 hover:bg-red-100 dark:hover:bg-red-950 text-red-600 dark:text-red-400 border-0 rounded-lg group-hover:opacity-100 transition-all duration-200 hover:scale-110"
  128. onClick={() => onDelete(exercise.id, muscle)}
  129. size="small"
  130. variant="ghost"
  131. >
  132. <Trash2 className="h-3.5 w-3.5" />
  133. </Button>
  134. </div>
  135. </div>
  136. {/* Video Modal */}
  137. {exercise.fullVideoUrl && <ExerciseVideoModal exercise={exercise} onOpenChange={setShowVideo} open={showVideo} />}
  138. </div>
  139. );
  140. }