407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { Trash2, Check, Pencil, CornerDownLeft } from "lucide-react";
|
|
import { useState, useCallback } from "react";
|
|
|
|
export type InputField = "sets" | "reps" | "weight" | "duration" | "distance" | "calories" | "notes";
|
|
|
|
export interface SetRowProps {
|
|
setNumber: number;
|
|
inputFields?: InputField[];
|
|
weightUnit?: string;
|
|
initialReps?: number;
|
|
initialWeight?: number;
|
|
initialRpe?: number;
|
|
initialNotes?: string;
|
|
initialDuration?: number;
|
|
initialDistance?: number;
|
|
initialCalories?: number;
|
|
initialLocked?: boolean;
|
|
autoFocus?: boolean;
|
|
onUpdate: (data: {
|
|
reps?: number;
|
|
weight?: number;
|
|
rpe?: number;
|
|
notes?: string;
|
|
durationSeconds?: number;
|
|
distance?: number;
|
|
calories?: number;
|
|
}) => void;
|
|
onConfirm?: () => void;
|
|
onNextSet?: (currentValues: {
|
|
weight?: string;
|
|
reps?: string;
|
|
rpe?: string;
|
|
notes?: string;
|
|
duration?: string;
|
|
distance?: string;
|
|
calories?: string;
|
|
}) => void;
|
|
onDelete: () => void;
|
|
}
|
|
|
|
export default function SetRow({
|
|
setNumber,
|
|
inputFields = ["sets", "reps", "weight"],
|
|
weightUnit = "lbs",
|
|
initialReps,
|
|
initialWeight,
|
|
initialRpe,
|
|
initialNotes,
|
|
initialDuration,
|
|
initialDistance,
|
|
initialCalories,
|
|
initialLocked = false,
|
|
autoFocus = false,
|
|
onUpdate,
|
|
onConfirm,
|
|
onNextSet,
|
|
onDelete,
|
|
}: SetRowProps) {
|
|
const [reps, setReps] = useState(initialReps?.toString() || "");
|
|
const [weight, setWeight] = useState(initialWeight?.toString() || "");
|
|
const [rpe, setRpe] = useState(initialRpe?.toString() || "");
|
|
const [notes, setNotes] = useState(initialNotes || "");
|
|
const [duration, setDuration] = useState(initialDuration?.toString() || "");
|
|
const [distance, setDistance] = useState(initialDistance?.toString() || "");
|
|
const [calories, setCalories] = useState(initialCalories?.toString() || "");
|
|
const [showNotes, setShowNotes] = useState(!!initialNotes);
|
|
const [locked, setLocked] = useState(initialLocked);
|
|
|
|
const showReps = inputFields.includes("reps");
|
|
const showWeight = inputFields.includes("weight");
|
|
const showDuration = inputFields.includes("duration");
|
|
const showDistance = inputFields.includes("distance");
|
|
const showCalories = inputFields.includes("calories");
|
|
const showNotesField = inputFields.includes("notes");
|
|
|
|
const emitUpdate = useCallback(
|
|
(overrides: {
|
|
reps?: string;
|
|
weight?: string;
|
|
rpe?: string;
|
|
notes?: string;
|
|
duration?: string;
|
|
distance?: string;
|
|
calories?: string;
|
|
}) => {
|
|
const r = overrides.reps ?? reps;
|
|
const w = overrides.weight ?? weight;
|
|
const p = overrides.rpe ?? rpe;
|
|
const n = overrides.notes ?? notes;
|
|
const dur = overrides.duration ?? duration;
|
|
const dist = overrides.distance ?? distance;
|
|
const cal = overrides.calories ?? calories;
|
|
|
|
onUpdate({
|
|
reps: r ? parseInt(r) : undefined,
|
|
weight: w ? parseFloat(w) : undefined,
|
|
rpe: p ? parseInt(p) : undefined,
|
|
notes: n || undefined,
|
|
durationSeconds: dur ? parseInt(dur) : undefined,
|
|
distance: dist ? parseFloat(dist) : undefined,
|
|
calories: cal ? parseInt(cal) : undefined,
|
|
});
|
|
},
|
|
[reps, weight, rpe, notes, duration, distance, calories, onUpdate]
|
|
);
|
|
|
|
const handleConfirm = () => {
|
|
emitUpdate({});
|
|
setLocked(true);
|
|
onConfirm?.();
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleConfirm();
|
|
}
|
|
};
|
|
|
|
const handleUnlock = () => {
|
|
setLocked(false);
|
|
};
|
|
|
|
const handleNextSet = () => {
|
|
emitUpdate({});
|
|
setLocked(true);
|
|
onNextSet?.({ weight, reps, rpe, notes, duration, distance, calories });
|
|
};
|
|
|
|
// Build a summary string for the locked view
|
|
const buildSummary = () => {
|
|
const parts: string[] = [];
|
|
if (showWeight && weight) parts.push(`${weight} ${weightUnit}`);
|
|
if (showReps && reps) parts.push(`${reps} reps`);
|
|
if (showDuration && duration) parts.push(`${duration}s`);
|
|
if (showDistance && distance) parts.push(`${distance} mi`);
|
|
if (showCalories && calories) parts.push(`${calories} cal`);
|
|
if (rpe) parts.push(`RPE ${rpe}`);
|
|
if (showNotesField && notes) parts.push(notes);
|
|
return parts.length > 0 ? parts.join(" · ") : "No data";
|
|
};
|
|
|
|
// ---------- LOCKED VIEW ----------
|
|
if (locked) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
{/* Set number badge */}
|
|
<div className="bg-white text-black rounded-full w-6 h-6 flex items-center justify-center font-semibold text-xs flex-shrink-0">
|
|
{setNumber}
|
|
</div>
|
|
|
|
{/* Summary text */}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm text-zinc-300 truncate">{buildSummary()}</p>
|
|
{!showNotesField && notes && (
|
|
<p className="text-[10px] text-zinc-500 truncate mt-0.5">{notes}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Edit (unlock) button */}
|
|
<button
|
|
type="button"
|
|
onClick={handleUnlock}
|
|
className="p-1.5 rounded-md flex-shrink-0 transition-colors text-zinc-400 hover:text-white hover:bg-zinc-800"
|
|
aria-label="Edit set"
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
|
|
{/* Delete button */}
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
className="p-1.5 text-red-500 hover:bg-red-950/30 rounded-md flex-shrink-0 active:bg-red-900/30"
|
|
aria-label="Delete set"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Determine which field gets autoFocus
|
|
const firstField = showWeight ? "weight" : showReps ? "reps" : showDuration ? "duration" : showDistance ? "distance" : showCalories ? "calories" : null;
|
|
|
|
// ---------- EDIT VIEW ----------
|
|
return (
|
|
<div className="space-y-1.5" onKeyDown={handleKeyDown}>
|
|
<div className="flex items-end gap-1.5">
|
|
{/* Set number badge */}
|
|
<div className="bg-white text-black rounded-full w-6 h-6 flex items-center justify-center font-semibold text-xs flex-shrink-0 mb-0.5">
|
|
{setNumber}
|
|
</div>
|
|
|
|
{/* Weight input */}
|
|
{showWeight && (
|
|
<div className="flex-1 min-w-[55px]">
|
|
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
|
Weight
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.5"
|
|
autoFocus={autoFocus && firstField === "weight"}
|
|
value={weight}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setWeight(val);
|
|
emitUpdate({ weight: val });
|
|
}}
|
|
placeholder="0"
|
|
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reps input */}
|
|
{showReps && (
|
|
<div className="flex-1 min-w-[55px]">
|
|
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
|
Reps
|
|
</label>
|
|
<input
|
|
type="number"
|
|
autoFocus={autoFocus && firstField === "reps"}
|
|
value={reps}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setReps(val);
|
|
emitUpdate({ reps: val });
|
|
}}
|
|
placeholder="0"
|
|
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Duration input (seconds) */}
|
|
{showDuration && (
|
|
<div className="flex-1 min-w-[55px]">
|
|
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
|
Time (s)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
autoFocus={autoFocus && firstField === "duration"}
|
|
value={duration}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setDuration(val);
|
|
emitUpdate({ duration: val });
|
|
}}
|
|
placeholder="0"
|
|
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Distance input */}
|
|
{showDistance && (
|
|
<div className="flex-1 min-w-[55px]">
|
|
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
|
Dist (mi)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
autoFocus={autoFocus && firstField === "distance"}
|
|
value={distance}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setDistance(val);
|
|
emitUpdate({ distance: val });
|
|
}}
|
|
placeholder="0"
|
|
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Calories input */}
|
|
{showCalories && (
|
|
<div className="flex-1 min-w-[55px]">
|
|
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
|
Cals
|
|
</label>
|
|
<input
|
|
type="number"
|
|
autoFocus={autoFocus && firstField === "calories"}
|
|
value={calories}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setCalories(val);
|
|
emitUpdate({ calories: val });
|
|
}}
|
|
placeholder="0"
|
|
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* RPE select — always shown */}
|
|
<div className="flex-1 min-w-[50px] max-w-[60px]">
|
|
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
|
RPE
|
|
</label>
|
|
<select
|
|
value={rpe}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setRpe(val);
|
|
emitUpdate({ rpe: val });
|
|
}}
|
|
className="w-full px-1.5 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
|
|
>
|
|
<option value="">-</option>
|
|
<option value="6">6</option>
|
|
<option value="7">7</option>
|
|
<option value="8">8</option>
|
|
<option value="9">9</option>
|
|
<option value="10">10</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Next set button — confirm + add new pre-filled set */}
|
|
{onNextSet && (
|
|
<button
|
|
type="button"
|
|
onClick={handleNextSet}
|
|
className="p-1.5 rounded-md flex-shrink-0 transition-colors text-zinc-400 hover:text-blue-400 hover:bg-blue-950/30"
|
|
aria-label="Save and start next set"
|
|
>
|
|
<CornerDownLeft className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Confirm button */}
|
|
<button
|
|
type="button"
|
|
onClick={handleConfirm}
|
|
className="p-1.5 rounded-md flex-shrink-0 transition-colors text-zinc-400 hover:text-green-400 hover:bg-green-950/30"
|
|
aria-label="Confirm set"
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
</button>
|
|
|
|
{/* Delete button */}
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
className="p-1.5 text-red-500 hover:bg-red-950/30 rounded-md flex-shrink-0 active:bg-red-900/30"
|
|
aria-label="Delete set"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Notes — always visible when configured as input field */}
|
|
{showNotesField && (
|
|
<div className="ml-8">
|
|
<input
|
|
type="text"
|
|
value={notes}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setNotes(val);
|
|
emitUpdate({ notes: val });
|
|
}}
|
|
placeholder="e.g. weighted vest, ankle weights..."
|
|
className="w-full px-2 py-1 border border-zinc-700 rounded-md text-xs bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Toggle notes button (fallback when notes is not a configured input field) */}
|
|
{!showNotesField && showNotes && (
|
|
<div className="ml-8">
|
|
<input
|
|
type="text"
|
|
value={notes}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setNotes(val);
|
|
emitUpdate({ notes: val });
|
|
}}
|
|
placeholder="Add notes (optional)"
|
|
className="w-full px-2 py-1 border border-zinc-700 rounded-md text-xs bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
|
|
/>
|
|
</div>
|
|
)}
|
|
{!showNotesField && !showNotes && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowNotes(true)}
|
|
className="text-[10px] text-zinc-500 hover:text-zinc-300 ml-8"
|
|
>
|
|
+ Add notes
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|