"use client"; import { useState, useRef, useEffect, useMemo, useCallback } from "react"; import { Search, Plus, X, Dumbbell } from "lucide-react"; import { Exercise } from "@prisma/client"; import { scoreExercise } from "@/lib/exerciseSearch"; const MUSCLE_GROUPS = [ "Chest", "Back", "Shoulders", "Quads", "Hamstrings", "Glutes", "Biceps", "Triceps", "Forearms", "Core", "Calves", "Full Body", "Cardio", ]; const EXERCISE_TYPES = [ { value: "barbell", label: "Barbell" }, { value: "dumbbell", label: "Dumbbell" }, { value: "machine", label: "Machine" }, { value: "cable", label: "Cable" }, { value: "bodyweight", label: "Bodyweight" }, { value: "cardio", label: "Cardio" }, { value: "kettlebell", label: "Kettlebell" }, { value: "other", label: "Other" }, ]; interface ExercisePickerProps { exercises: Exercise[]; recentlyUsed?: string[]; onSelect: (exercise: Exercise) => void; onExerciseCreated?: (exercise: Exercise) => void; } // Search utilities imported from shared module export default function ExercisePicker({ exercises, recentlyUsed = [], onSelect, onExerciseCreated, }: ExercisePickerProps) { const [query, setQuery] = useState(""); const [isOpen, setIsOpen] = useState(false); const [highlightIndex, setHighlightIndex] = useState(0); const [showCreateForm, setShowCreateForm] = useState(false); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(""); const [newType, setNewType] = useState("barbell"); const [newMuscleGroups, setNewMuscleGroups] = useState([]); const [newInputFields, setNewInputFields] = useState(["sets", "reps", "weight"]); // Derive custom types/muscles/fields from existing exercises const knownTypeValues = EXERCISE_TYPES.map((t) => t.value); const knownMuscleValues = MUSCLE_GROUPS.map((g) => g.toLowerCase()); const knownFieldValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"]; const derivedCustomTypes = useMemo(() => { const set = new Set(); for (const ex of exercises) { const t = (ex.type || "").toLowerCase(); if (t && !knownTypeValues.includes(t)) set.add(t); } return Array.from(set).map((v) => ({ value: v, label: v.charAt(0).toUpperCase() + v.slice(1) })); }, [exercises]); const derivedCustomMuscles = useMemo(() => { const set = new Set(); for (const ex of exercises) { try { const groups = JSON.parse(ex.muscleGroups || "[]") as string[]; groups.forEach((g) => { const gl = g.toLowerCase(); if (!knownMuscleValues.includes(gl)) set.add(g.charAt(0).toUpperCase() + g.slice(1).toLowerCase()); }); } catch {} } return Array.from(set); }, [exercises]); const derivedCustomFields = useMemo(() => { const set = new Set(); for (const ex of exercises) { try { const fields = JSON.parse((ex as any).inputFields || "[]") as string[]; fields.forEach((f) => { if (!knownFieldValues.includes(f)) set.add(f); }); } catch {} } return Array.from(set).map((v) => ({ value: v, label: v.charAt(0).toUpperCase() + v.slice(1) })); }, [exercises]); // Custom "+" add state (session-only additions on top of derived) const [addingType, setAddingType] = useState(false); const [newTypeText, setNewTypeText] = useState(""); const [sessionCustomTypes, setSessionCustomTypes] = useState<{ value: string; label: string }[]>([]); const [addingMuscle, setAddingMuscle] = useState(false); const [newMuscleText, setNewMuscleText] = useState(""); const [sessionCustomMuscles, setSessionCustomMuscles] = useState([]); const [addingField, setAddingField] = useState(false); const [newFieldText, setNewFieldText] = useState(""); const [sessionCustomFields, setSessionCustomFields] = useState<{ value: string; label: string }[]>([]); // Merge derived + session-added const customTypes = useMemo(() => { const all = [...derivedCustomTypes]; for (const t of sessionCustomTypes) { if (!all.some((a) => a.value === t.value)) all.push(t); } return all; }, [derivedCustomTypes, sessionCustomTypes]); const customMuscles = useMemo(() => { const all = [...derivedCustomMuscles]; for (const m of sessionCustomMuscles) { if (!all.map((a) => a.toLowerCase()).includes(m.toLowerCase())) all.push(m); } return all; }, [derivedCustomMuscles, sessionCustomMuscles]); const customFieldOptions = useMemo(() => { const all = [...derivedCustomFields]; for (const f of sessionCustomFields) { if (!all.some((a) => a.value === f.value)) all.push(f); } return all; }, [derivedCustomFields, sessionCustomFields]); const inputRef = useRef(null); const dropdownRef = useRef(null); const containerRef = useRef(null); const filteredExercises = useMemo(() => { if (!query.trim()) { const recent = exercises.filter((ex) => recentlyUsed.includes(ex.id)); const others = exercises .filter((ex) => !recentlyUsed.includes(ex.id)) .sort((a, b) => a.name.localeCompare(b.name)); return [...recent, ...others].slice(0, 15); } const scored = exercises .map((ex) => ({ exercise: ex, score: scoreExercise(query, ex.name) })) .filter((item) => item.score >= 0) .sort((a, b) => a.score - b.score); return scored.map((s) => s.exercise).slice(0, 10); }, [exercises, query, recentlyUsed]); const exactMatch = exercises.some( (ex) => ex.name.toLowerCase() === query.trim().toLowerCase() ); const showCreateOption = query.trim().length > 0 && !exactMatch; const totalItems = filteredExercises.length + (showCreateOption ? 1 : 0); useEffect(() => { setHighlightIndex(0); }, [query]); useEffect(() => { function handleClickOutside(e: MouseEvent) { if ( containerRef.current && !containerRef.current.contains(e.target as Node) ) { setIsOpen(false); } } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); useEffect(() => { if (dropdownRef.current) { const highlighted = dropdownRef.current.querySelector( '[data-highlighted="true"]' ); if (highlighted) { highlighted.scrollIntoView({ block: "nearest" }); } } }, [highlightIndex]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (!isOpen) { if (e.key === "ArrowDown" || e.key === "Enter") { setIsOpen(true); e.preventDefault(); } return; } switch (e.key) { case "ArrowDown": e.preventDefault(); setHighlightIndex((prev) => Math.min(prev + 1, totalItems - 1)); break; case "ArrowUp": e.preventDefault(); setHighlightIndex((prev) => Math.max(prev - 1, 0)); break; case "Enter": e.preventDefault(); if (highlightIndex < filteredExercises.length) { handleSelect(filteredExercises[highlightIndex]); } else if (showCreateOption) { openCreateForm(); } break; case "Escape": setIsOpen(false); inputRef.current?.blur(); break; } }, [isOpen, highlightIndex, filteredExercises, totalItems, showCreateOption] ); const handleSelect = (exercise: Exercise) => { onSelect(exercise); setQuery(""); setIsOpen(false); inputRef.current?.blur(); }; const openCreateForm = () => { setNewName(query.trim()); setNewType("barbell"); setNewMuscleGroups([]); setNewInputFields(["sets", "reps", "weight"]); setShowCreateForm(true); setIsOpen(false); }; const handleCreate = async () => { if (!newName.trim()) return; setCreating(true); try { const response = await fetch("/api/exercises", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim(), type: newType, muscleGroups: newMuscleGroups, inputFields: newInputFields, }), }); if (!response.ok) { const err = await response.json(); alert(err.error || "Failed to create exercise"); return; } const exercise: Exercise = await response.json(); onExerciseCreated?.(exercise); onSelect(exercise); setShowCreateForm(false); setQuery(""); } catch (error) { console.error("Failed to create exercise:", error); alert("Failed to create exercise. Please try again."); } finally { setCreating(false); } }; const toggleMuscleGroup = (group: string) => { setNewMuscleGroups((prev) => prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group] ); }; const toggleInputField = (field: string) => { setNewInputFields((prev) => { // "sets" is always included if (field === "sets") return prev; return prev.includes(field) ? prev.filter((f) => f !== field) : [...prev, field]; }); }; const handleTypeChange = (type: string) => { setNewType(type); // Auto-set sensible input field defaults based on type if (type === "cardio") { setNewInputFields(["sets", "duration", "distance", "calories"]); } else { setNewInputFields(["sets", "reps", "weight"]); } }; const highlightMatch = (name: string) => { if (!query.trim()) return name; const q = query.toLowerCase(); const idx = name.toLowerCase().indexOf(q); if (idx >= 0) { return ( <> {name.slice(0, idx)} {name.slice(idx, idx + q.length)} {name.slice(idx + q.length)} ); } return name; }; const parseMuscleGroups = (json: string | null): string[] => { if (!json) return []; try { return JSON.parse(json); } catch { return []; } }; // --- Create Form --- if (showCreateForm) { return (

New Exercise

{/* Name */}
setNewName(e.target.value)} className="w-full px-3 py-2 border border-zinc-700 rounded-lg bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20" autoFocus />
{/* Equipment */}
{[...EXERCISE_TYPES, ...customTypes].map((t) => ( ))} {addingType ? (
{ e.preventDefault(); const val = newTypeText.trim().toLowerCase(); if (val && !EXERCISE_TYPES.some((t) => t.value === val) && !customTypes.some((t) => t.value === val)) { setSessionCustomTypes((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]); handleTypeChange(val); } setNewTypeText(""); setAddingType(false); }} className="flex items-center gap-1" > setNewTypeText(e.target.value)} onBlur={() => { const val = newTypeText.trim().toLowerCase(); if (val && !EXERCISE_TYPES.some((t) => t.value === val) && !customTypes.some((t) => t.value === val)) { setSessionCustomTypes((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]); handleTypeChange(val); } setNewTypeText(""); setAddingType(false); }} placeholder="New type" className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30" />
) : ( )}
{/* Muscle Groups */}
{[...MUSCLE_GROUPS, ...customMuscles].map((group) => ( ))} {addingMuscle ? (
{ e.preventDefault(); const val = newMuscleText.trim(); const valLower = val.toLowerCase(); if (val && !MUSCLE_GROUPS.map((g) => g.toLowerCase()).includes(valLower) && !customMuscles.map((g) => g.toLowerCase()).includes(valLower)) { const display = val.charAt(0).toUpperCase() + val.slice(1); setSessionCustomMuscles((p) => [...p, display]); } if (valLower && !newMuscleGroups.includes(valLower)) { setNewMuscleGroups((p) => [...p, valLower]); } setNewMuscleText(""); setAddingMuscle(false); }} className="flex items-center gap-1" > setNewMuscleText(e.target.value)} onBlur={() => { const val = newMuscleText.trim(); const valLower = val.toLowerCase(); if (val && !MUSCLE_GROUPS.map((g) => g.toLowerCase()).includes(valLower) && !customMuscles.map((g) => g.toLowerCase()).includes(valLower)) { const display = val.charAt(0).toUpperCase() + val.slice(1); setSessionCustomMuscles((p) => [...p, display]); } if (valLower && !newMuscleGroups.includes(valLower)) { setNewMuscleGroups((p) => [...p, valLower]); } setNewMuscleText(""); setAddingMuscle(false); }} placeholder="New group" className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30" />
) : ( )}
{/* Input Fields */}
{[ { value: "reps", label: "Reps" }, { value: "weight", label: "Weight" }, { value: "duration", label: "Time" }, { value: "distance", label: "Distance" }, { value: "calories", label: "Calories" }, { value: "notes", label: "Notes" }, ...customFieldOptions, ].map((field) => ( ))} {addingField ? (
{ e.preventDefault(); const val = newFieldText.trim().toLowerCase(); const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"]; if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) { setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]); } if (val && !newInputFields.includes(val)) { setNewInputFields((p) => [...p, val]); } setNewFieldText(""); setAddingField(false); }} className="flex items-center gap-1" > setNewFieldText(e.target.value)} onBlur={() => { const val = newFieldText.trim().toLowerCase(); const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"]; if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) { setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]); } if (val && !newInputFields.includes(val)) { setNewInputFields((p) => [...p, val]); } setNewFieldText(""); setAddingField(false); }} placeholder="New field" className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30" />
) : ( )}
{/* Actions */}
); } // --- Main Autocomplete Input --- return (
{ setQuery(e.target.value); setIsOpen(true); }} onFocus={() => setIsOpen(true)} onKeyDown={handleKeyDown} placeholder="Type to search or add an exercise..." className="w-full pl-10 pr-10 py-3 border border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-white/20 text-base bg-zinc-800 text-white placeholder:text-zinc-500" autoComplete="off" /> {query && ( )}
{/* Dropdown */} {isOpen && (query.trim() || filteredExercises.length > 0) && (
{/* Recently used label */} {!query.trim() && recentlyUsed.length > 0 && filteredExercises.some((ex) => recentlyUsed.includes(ex.id) ) && (
Recently Used
)} {filteredExercises.map((exercise, index) => { const isRecent = recentlyUsed.includes(exercise.id); const muscles = parseMuscleGroups(exercise.muscleGroups); const isFirstNonRecent = !query.trim() && !isRecent && index > 0 && recentlyUsed.includes(filteredExercises[index - 1].id); return (
{isFirstNonRecent && (
All Exercises
)}
); })} {/* Create new exercise option */} {showCreateOption && ( )} {/* No results */} {filteredExercises.length === 0 && !showCreateOption && (
No exercises found
)}
)}
); }