Initial commit for Start9 packaging
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user