Initial commit for Start9 packaging
This commit is contained in:
@@ -0,0 +1,731 @@
|
||||
"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 “{query.trim()}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user