Files
proof-of-work/workout-planner/components/workouts/ExercisePicker.tsx
T
2026-02-28 09:27:26 -06:00

732 lines
26 KiB
TypeScript

"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<string[]>([]);
const [newInputFields, setNewInputFields] = useState<string[]>(["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<string>();
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<string>();
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<string>();
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<string[]>([]);
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<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(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)}
<span className="font-bold text-white">
{name.slice(idx, idx + q.length)}
</span>
{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 (
<div className="border border-zinc-700 rounded-lg bg-zinc-900 p-4 space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-white flex items-center gap-2">
<Plus className="w-4 h-4 text-zinc-400" />
New Exercise
</h4>
<button
type="button"
onClick={() => setShowCreateForm(false)}
className="p-1 hover:bg-zinc-800 rounded"
>
<X className="w-4 h-4 text-zinc-500" />
</button>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Name
</label>
<input
type="text"
value={newName}
onChange={(e) => 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
/>
</div>
{/* Equipment */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Equipment
</label>
<div className="flex flex-wrap gap-2">
{[...EXERCISE_TYPES, ...customTypes].map((t) => (
<button
key={t.value}
type="button"
onClick={() => handleTypeChange(t.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
newType === t.value
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white border border-zinc-700"
}`}
>
{t.label}
</button>
))}
{addingType ? (
<form
onSubmit={(e) => {
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"
>
<input
autoFocus
value={newTypeText}
onChange={(e) => 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"
/>
</form>
) : (
<button
type="button"
onClick={() => setAddingType(true)}
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
>
+
</button>
)}
</div>
</div>
{/* Muscle Groups */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Muscle Groups
</label>
<div className="flex flex-wrap gap-2">
{[...MUSCLE_GROUPS, ...customMuscles].map((group) => (
<button
key={group}
type="button"
onClick={() => toggleMuscleGroup(group.toLowerCase())}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
newMuscleGroups.includes(group.toLowerCase())
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white border border-zinc-700"
}`}
>
{group}
</button>
))}
{addingMuscle ? (
<form
onSubmit={(e) => {
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"
>
<input
autoFocus
value={newMuscleText}
onChange={(e) => 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"
/>
</form>
) : (
<button
type="button"
onClick={() => setAddingMuscle(true)}
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
>
+
</button>
)}
</div>
</div>
{/* Input Fields */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Tracking Fields
</label>
<div className="flex flex-wrap gap-2">
{[
{ 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) => (
<button
key={field.value}
type="button"
onClick={() => toggleInputField(field.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
newInputFields.includes(field.value)
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white border border-zinc-700"
}`}
>
{field.label}
</button>
))}
{addingField ? (
<form
onSubmit={(e) => {
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"
>
<input
autoFocus
value={newFieldText}
onChange={(e) => 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"
/>
</form>
) : (
<button
type="button"
onClick={() => setAddingField(true)}
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
>
+
</button>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={handleCreate}
disabled={!newName.trim() || creating}
className="flex-1 py-2.5 bg-white text-black font-semibold rounded-lg hover:bg-zinc-200 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed transition"
>
{creating ? "Creating..." : "Create & Add"}
</button>
<button
type="button"
onClick={() => setShowCreateForm(false)}
className="px-4 py-2.5 border border-zinc-700 rounded-lg text-zinc-400 hover:text-white hover:border-zinc-600 transition"
>
Cancel
</button>
</div>
</div>
);
}
// --- Main Autocomplete Input ---
return (
<div ref={containerRef} className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500 pointer-events-none" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
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 && (
<button
type="button"
onClick={() => {
setQuery("");
setIsOpen(false);
inputRef.current?.focus();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded text-zinc-500 hover:text-white transition-colors"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Dropdown */}
{isOpen && (query.trim() || filteredExercises.length > 0) && (
<div
ref={dropdownRef}
className="absolute z-50 w-full mt-1 bg-zinc-900 border border-zinc-700 rounded-lg shadow-2xl max-h-72 overflow-y-auto"
>
{/* Recently used label */}
{!query.trim() &&
recentlyUsed.length > 0 &&
filteredExercises.some((ex) =>
recentlyUsed.includes(ex.id)
) && (
<div className="px-3 py-1.5 text-xs font-semibold text-zinc-500 uppercase tracking-wide bg-zinc-800/50 border-b border-zinc-800">
Recently Used
</div>
)}
{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 (
<div key={exercise.id}>
{isFirstNonRecent && (
<div className="px-3 py-1.5 text-xs font-semibold text-zinc-500 uppercase tracking-wide bg-zinc-800/50 border-t border-b border-zinc-800">
All Exercises
</div>
)}
<button
type="button"
data-highlighted={highlightIndex === index}
onClick={() => handleSelect(exercise)}
onMouseEnter={() => setHighlightIndex(index)}
className={`w-full text-left px-3 py-2.5 flex items-center gap-3 transition-colors ${
highlightIndex === index
? "bg-zinc-800"
: "hover:bg-zinc-800/50"
}`}
>
<Dumbbell className="w-4 h-4 text-zinc-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-zinc-300">
{highlightMatch(exercise.name)}
</div>
{muscles.length > 0 && (
<div className="text-xs text-zinc-600 mt-0.5">
{muscles.join(" · ")} · {exercise.type}
</div>
)}
</div>
</button>
</div>
);
})}
{/* Create new exercise option */}
{showCreateOption && (
<button
type="button"
data-highlighted={highlightIndex === filteredExercises.length}
onClick={openCreateForm}
onMouseEnter={() =>
setHighlightIndex(filteredExercises.length)
}
className={`w-full text-left px-3 py-3 flex items-center gap-3 border-t border-zinc-800 transition-colors ${
highlightIndex === filteredExercises.length
? "bg-zinc-800"
: "hover:bg-zinc-800/50"
}`}
>
<div className="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
<Plus className="w-4 h-4 text-white" />
</div>
<div>
<div className="text-sm font-medium text-white">
Create &ldquo;{query.trim()}&rdquo;
</div>
<div className="text-xs text-zinc-500">
Add as a new exercise
</div>
</div>
</button>
)}
{/* No results */}
{filteredExercises.length === 0 && !showCreateOption && (
<div className="px-3 py-6 text-center text-sm text-zinc-500">
No exercises found
</div>
)}
</div>
)}
</div>
);
}