Initial commit for Start9 packaging

This commit is contained in:
MacPro
2026-02-28 09:27:26 -06:00
commit 1b64c45c52
124 changed files with 15671 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
'use server';
import { cookies } from 'next/headers';
export async function logoutAction() {
const cookieStore = await cookies();
cookieStore.delete('sessionToken');
}
+194
View File
@@ -0,0 +1,194 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { getCurrentUser } from "@/lib/auth";
import {
getWeeklyWorkoutCount,
getMonthlyWorkoutCount,
getYearlyWorkoutCount,
getWeeklyVolume,
} from "@/lib/db/stats";
import { getRecentWorkouts } from "@/lib/db/workouts";
import { ActivitySquare, Calendar, CalendarDays, History, Plus } from "lucide-react";
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user) {
redirect("/auth/login");
}
const [weeklyCount, monthlyCount, yearlyCount, _weeklyVolume, recentWorkouts] =
await Promise.all([
getWeeklyWorkoutCount(user.id),
getMonthlyWorkoutCount(user.id),
getYearlyWorkoutCount(user.id),
getWeeklyVolume(user.id),
getRecentWorkouts(user.id, 5),
]);
return (
<div className="min-h-screen bg-[#0A0A0A]">
{/* Header with greeting */}
<div className="px-4 py-6 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<h1 className="text-4xl font-bold text-white">
Welcome back, {user.name || "Trainer"}!
</h1>
<p className="text-zinc-400 mt-2">
Keep pushing your limits and achieving your goals.
</p>
</div>
</div>
{/* Main content */}
<div className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-3 sm:gap-4 mb-8">
{/* This Week */}
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 sm:p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-zinc-400 text-xs sm:text-sm font-medium">
This Week
</p>
<p className="text-3xl sm:text-4xl font-bold text-white mt-1 sm:mt-2">
{weeklyCount}
</p>
<p className="text-zinc-500 text-[10px] sm:text-xs mt-1 sm:mt-2">
workouts
</p>
</div>
<div className="bg-zinc-800 p-2 sm:p-3 rounded-lg hidden sm:block">
<ActivitySquare className="text-white w-5 h-5 sm:w-6 sm:h-6" />
</div>
</div>
</div>
{/* This Month */}
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 sm:p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-zinc-400 text-xs sm:text-sm font-medium">
This Month
</p>
<p className="text-3xl sm:text-4xl font-bold text-white mt-1 sm:mt-2">
{monthlyCount}
</p>
<p className="text-zinc-500 text-[10px] sm:text-xs mt-1 sm:mt-2">
workouts
</p>
</div>
<div className="bg-zinc-800 p-2 sm:p-3 rounded-lg hidden sm:block">
<Calendar className="text-white w-5 h-5 sm:w-6 sm:h-6" />
</div>
</div>
</div>
{/* This Year */}
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 sm:p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-zinc-400 text-xs sm:text-sm font-medium">
This Year
</p>
<p className="text-3xl sm:text-4xl font-bold text-white mt-1 sm:mt-2">
{yearlyCount}
</p>
<p className="text-zinc-500 text-[10px] sm:text-xs mt-1 sm:mt-2">
workouts
</p>
</div>
<div className="bg-zinc-800 p-2 sm:p-3 rounded-lg hidden sm:block">
<CalendarDays className="text-white w-5 h-5 sm:w-6 sm:h-6" />
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<Link
href="/main/workouts/new"
className="flex-1 bg-white hover:bg-gray-100 text-black font-medium py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition"
>
<Plus className="w-5 h-5" />
Log Workout
</Link>
<Link
href="/main/workouts"
className="flex-1 bg-zinc-800 hover:bg-zinc-700 text-white font-medium py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition"
>
<History className="w-5 h-5" />
View History
</Link>
</div>
{/* Recent Workouts */}
<div className="bg-zinc-900 border border-zinc-800 rounded-lg">
<div className="px-6 py-4 border-b border-zinc-800">
<h2 className="text-lg font-bold text-white">
Recent Workouts
</h2>
</div>
{recentWorkouts.length === 0 ? (
<div className="px-6 py-12 text-center">
<ActivitySquare className="w-12 h-12 text-zinc-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">
No workouts yet
</h3>
<p className="text-zinc-400 mb-6">
Start your fitness journey by logging your first workout!
</p>
<Link
href="/main/workouts/new"
className="inline-block bg-white hover:bg-gray-100 text-black font-medium py-2 px-4 rounded-lg transition"
>
Log First Workout
</Link>
</div>
) : (
<div className="divide-y divide-zinc-800">
{recentWorkouts.map((workout) => (
<Link
key={workout.id}
href={`/main/workouts/${workout.id}`}
className="px-6 py-4 hover:bg-zinc-800 transition block"
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-white">
{workout.name || "Unnamed Workout"}
</h3>
<p className="text-sm text-zinc-400 mt-1">
{new Date(workout.date).toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
<p className="text-sm text-zinc-500 mt-1">
{(workout as any).setLogs.length} sets
{workout.durationMinutes &&
` · ${workout.durationMinutes} min`}
</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white">
{(workout as any).setLogs.length}
</div>
<p className="text-xs text-zinc-400 mt-1">
{(workout as any).setLogs.length === 1 ? "set" : "sets"}
</p>
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,659 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { formatSetsSummary } from "@/lib/formatSets";
import {
ChevronLeft,
Loader,
Pencil,
Trash2,
Check,
X,
Dumbbell,
TrendingUp,
Calendar,
} from "lucide-react";
import { Exercise as PrismaExercise } from "@prisma/client";
// Extended type until Prisma client is regenerated with new fields
type Exercise = PrismaExercise & {
inputFields?: string;
defaultWeightUnit?: string | null;
};
const EXERCISE_TYPES = [
"barbell",
"dumbbell",
"machine",
"cable",
"bodyweight",
"cardio",
"kettlebell",
"other",
];
const MUSCLE_GROUPS = [
"chest",
"back",
"shoulders",
"quads",
"hamstrings",
"glutes",
"biceps",
"triceps",
"forearms",
"core",
"calves",
"full body",
"cardio",
];
const INPUT_FIELD_OPTIONS = [
{ value: "sets", label: "Sets" },
{ value: "reps", label: "Reps" },
{ value: "weight", label: "Weight" },
{ value: "duration", label: "Duration" },
{ value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" },
{ value: "notes", label: "Notes" },
];
interface WorkoutHistory {
workout: { id: string; date: string; name: string | null };
sets: Array<{
id: string;
setNumber: number;
reps: number | null;
weight: number | null;
weightUnit: string;
rpe: number | null;
durationSeconds: number | null;
distance: number | null;
calories: number | null;
notes: string | null;
}>;
}
export default function ExerciseDetailPage() {
const params = useParams();
const router = useRouter();
const exerciseId = params.id as string;
const [exercise, setExercise] = useState<Exercise | null>(null);
const [history, setHistory] = useState<WorkoutHistory[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
// Edit form state
const [editName, setEditName] = useState("");
const [editType, setEditType] = useState("");
const [editMuscleGroups, setEditMuscleGroups] = useState<string[]>([]);
const [editInputFields, setEditInputFields] = useState<string[]>([]);
const [editDefaultUnit, setEditDefaultUnit] = useState<string | null>(null);
// Custom "+" add state
const [addingType, setAddingType] = useState(false);
const [newTypeText, setNewTypeText] = useState("");
const [addingMuscle, setAddingMuscle] = useState(false);
const [newMuscleText, setNewMuscleText] = useState("");
const [addingField, setAddingField] = useState(false);
const [newFieldText, setNewFieldText] = useState("");
const [customTypes, setCustomTypes] = useState<string[]>([]);
const [customMuscles, setCustomMuscles] = useState<string[]>([]);
const [customFields, setCustomFields] = useState<{ value: string; label: string }[]>([]);
useEffect(() => {
fetchExercise();
}, [exerciseId]);
const fetchExercise = async () => {
try {
const res = await fetch(`/api/exercises/${exerciseId}`);
if (!res.ok) throw new Error("Not found");
const data = await res.json();
setExercise(data.exercise);
setHistory(data.history || []);
// Populate edit form
setEditName(data.exercise.name);
setEditType(data.exercise.type);
const mg = JSON.parse(data.exercise.muscleGroups || "[]") as string[];
setEditMuscleGroups(mg);
const ifs = JSON.parse(data.exercise.inputFields || '["sets","reps","weight"]') as string[];
setEditInputFields(ifs);
setEditDefaultUnit(data.exercise.defaultWeightUnit);
// Detect custom values not in default lists
const knownTypes = EXERCISE_TYPES;
if (!knownTypes.includes(data.exercise.type)) {
setCustomTypes((prev) => prev.includes(data.exercise.type) ? prev : [...prev, data.exercise.type]);
}
const knownMuscles = MUSCLE_GROUPS;
mg.forEach((m: string) => {
if (!knownMuscles.includes(m)) {
setCustomMuscles((prev) => prev.includes(m) ? prev : [...prev, m]);
}
});
const knownFields = INPUT_FIELD_OPTIONS.map((f) => f.value);
ifs.forEach((f: string) => {
if (!knownFields.includes(f)) {
setCustomFields((prev) =>
prev.some((cf) => cf.value === f)
? prev
: [...prev, { value: f, label: f.charAt(0).toUpperCase() + f.slice(1) }]
);
}
});
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch(`/api/exercises/${exerciseId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: editName,
type: editType,
muscleGroups: editMuscleGroups,
inputFields: editInputFields,
defaultWeightUnit: editDefaultUnit,
}),
});
if (!res.ok) throw new Error("Failed to save");
const updated = await res.json();
setExercise(updated);
setEditing(false);
} catch (err) {
console.error(err);
alert("Failed to save changes");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm("Delete this exercise and all its history?")) return;
setDeleting(true);
try {
const res = await fetch(`/api/exercises/${exerciseId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to delete");
router.push("/main/exercises");
} catch (err) {
console.error(err);
alert("Failed to delete exercise");
setDeleting(false);
}
};
const toggleMuscleGroup = (group: string) => {
setEditMuscleGroups((prev) =>
prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group]
);
};
const toggleInputField = (field: string) => {
setEditInputFields((prev) =>
prev.includes(field)
? prev.filter((f) => f !== field)
: [...prev, field]
);
};
if (loading) {
return (
<div className="min-h-screen bg-[#0A0A0A] flex items-center justify-center">
<Loader className="w-8 h-8 animate-spin text-zinc-500" />
</div>
);
}
if (!exercise) {
return (
<div className="min-h-screen bg-[#0A0A0A] p-6">
<Link
href="/main/exercises"
className="text-zinc-400 hover:text-white flex items-center gap-1 mb-4"
>
<ChevronLeft className="w-5 h-5" />
Back
</Link>
<p className="text-zinc-500">Exercise not found</p>
</div>
);
}
const muscleGroups = JSON.parse(exercise.muscleGroups || "[]") as string[];
const inputFields = JSON.parse(
exercise.inputFields || '["sets","reps","weight"]'
) as string[];
return (
<div className="min-h-screen bg-[#0A0A0A]">
{/* Header */}
<div className="border-b border-zinc-800 px-4 py-4">
<div className="max-w-2xl mx-auto flex items-center gap-4">
<Link
href="/main/exercises"
className="p-2 hover:bg-zinc-900 rounded-lg text-zinc-400 hover:text-white"
>
<ChevronLeft className="w-6 h-6" />
</Link>
<h1 className="text-xl font-bold text-white flex-1 truncate">
{exercise.name}
</h1>
{!editing && (
<div className="flex items-center gap-2">
<button
onClick={() => setEditing(true)}
className="p-2 hover:bg-zinc-900 rounded-lg text-zinc-400 hover:text-white"
>
<Pencil className="w-5 h-5" />
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="p-2 hover:bg-zinc-900 rounded-lg text-red-500 hover:text-red-400 disabled:opacity-50"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
)}
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
{/* Edit Mode */}
{editing ? (
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6 space-y-5">
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Name
</label>
<input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-white/20"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Equipment
</label>
<div className="flex flex-wrap gap-2">
{[...EXERCISE_TYPES, ...customTypes].map((type) => (
<button
key={type}
onClick={() => setEditType(type)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
editType === type
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white"
}`}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
</button>
))}
{addingType ? (
<form
onSubmit={(e) => {
e.preventDefault();
const val = newTypeText.trim().toLowerCase();
if (val && !EXERCISE_TYPES.includes(val) && !customTypes.includes(val)) {
setCustomTypes((p) => [...p, val]);
setEditType(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.includes(val) && !customTypes.includes(val)) {
setCustomTypes((p) => [...p, val]);
setEditType(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>
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Muscle Groups
</label>
<div className="flex flex-wrap gap-2">
{[...MUSCLE_GROUPS, ...customMuscles].map((group) => (
<button
key={group}
onClick={() => toggleMuscleGroup(group)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
editMuscleGroups.includes(group)
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white"
}`}
>
{group.charAt(0).toUpperCase() + group.slice(1)}
</button>
))}
{addingMuscle ? (
<form
onSubmit={(e) => {
e.preventDefault();
const val = newMuscleText.trim().toLowerCase();
if (val && !MUSCLE_GROUPS.includes(val) && !customMuscles.includes(val)) {
setCustomMuscles((p) => [...p, val]);
}
if (val && !editMuscleGroups.includes(val)) {
setEditMuscleGroups((p) => [...p, val]);
}
setNewMuscleText("");
setAddingMuscle(false);
}}
className="flex items-center gap-1"
>
<input
autoFocus
value={newMuscleText}
onChange={(e) => setNewMuscleText(e.target.value)}
onBlur={() => {
const val = newMuscleText.trim().toLowerCase();
if (val && !MUSCLE_GROUPS.includes(val) && !customMuscles.includes(val)) {
setCustomMuscles((p) => [...p, val]);
}
if (val && !editMuscleGroups.includes(val)) {
setEditMuscleGroups((p) => [...p, val]);
}
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>
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Input Fields
</label>
<p className="text-xs text-zinc-600 mb-2">
Choose which fields are relevant when logging this exercise
</p>
<div className="flex flex-wrap gap-2">
{[...INPUT_FIELD_OPTIONS, ...customFields].map((field) => (
<button
key={field.value}
onClick={() => toggleInputField(field.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
editInputFields.includes(field.value)
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white"
}`}
>
{field.label}
</button>
))}
{addingField ? (
<form
onSubmit={(e) => {
e.preventDefault();
const val = newFieldText.trim().toLowerCase();
if (
val &&
!INPUT_FIELD_OPTIONS.some((f) => f.value === val) &&
!customFields.some((f) => f.value === val)
) {
setCustomFields((p) => [
...p,
{ value: val, label: val.charAt(0).toUpperCase() + val.slice(1) },
]);
}
if (val && !editInputFields.includes(val)) {
setEditInputFields((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();
if (
val &&
!INPUT_FIELD_OPTIONS.some((f) => f.value === val) &&
!customFields.some((f) => f.value === val)
) {
setCustomFields((p) => [
...p,
{ value: val, label: val.charAt(0).toUpperCase() + val.slice(1) },
]);
}
if (val && !editInputFields.includes(val)) {
setEditInputFields((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>
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Default Weight Unit
</label>
<div className="flex gap-2">
{[
{ value: null, label: "User Default" },
{ value: "lbs", label: "Pounds" },
{ value: "kg", label: "Kilograms" },
].map((opt) => (
<button
key={opt.label}
onClick={() => setEditDefaultUnit(opt.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
editDefaultUnit === opt.value
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="flex-1 py-3 bg-white text-black font-bold rounded-lg hover:bg-zinc-200 disabled:opacity-50 flex items-center justify-center gap-2"
>
{saving ? (
<Loader className="w-5 h-5 animate-spin" />
) : (
<Check className="w-5 h-5" />
)}
Save
</button>
<button
onClick={() => setEditing(false)}
className="flex-1 py-3 bg-zinc-800 text-white font-medium rounded-lg hover:bg-zinc-700 flex items-center justify-center gap-2"
>
<X className="w-5 h-5" />
Cancel
</button>
</div>
</div>
) : (
/* View Mode */
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
<div className="flex items-start gap-4 mb-4">
<div className="bg-zinc-800 p-3 rounded-lg">
<Dumbbell className="w-6 h-6 text-zinc-400" />
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-white">
{exercise.name}
</h2>
<span className="inline-block mt-1 px-2 py-0.5 bg-zinc-800 text-zinc-300 rounded text-xs font-medium">
{exercise.type.charAt(0).toUpperCase() +
exercise.type.slice(1)}
</span>
</div>
</div>
{muscleGroups.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{muscleGroups.map((group) => (
<span
key={group}
className="px-2 py-1 bg-zinc-800 text-zinc-400 rounded text-xs"
>
{group.charAt(0).toUpperCase() + group.slice(1)}
</span>
))}
</div>
)}
<div className="border-t border-zinc-800 pt-4 mt-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-2">
Tracked Fields
</p>
<div className="flex flex-wrap gap-2">
{inputFields.map((field) => (
<span
key={field}
className="px-2 py-1 bg-zinc-800 text-zinc-300 rounded text-xs font-medium"
>
{field.charAt(0).toUpperCase() + field.slice(1)}
</span>
))}
</div>
</div>
{exercise.defaultWeightUnit && (
<p className="text-xs text-zinc-500 mt-3">
Default unit:{" "}
<span className="text-zinc-300">
{exercise.defaultWeightUnit}
</span>
</p>
)}
</div>
)}
{/* History */}
<div>
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Calendar className="w-5 h-5 text-zinc-400" />
History
</h3>
{history.length === 0 ? (
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-8 text-center">
<TrendingUp className="w-10 h-10 text-zinc-700 mx-auto mb-3" />
<p className="text-zinc-500">No history yet</p>
<p className="text-zinc-600 text-sm mt-1">
Start logging this exercise to see your progress
</p>
</div>
) : (
<div className="space-y-1">
{history.map((entry) => {
const summary = formatSetsSummary(
entry.sets.map((s: any) => ({ weight: s.weight, reps: s.reps, weightUnit: s.weightUnit }))
);
const dateStr = new Date(entry.workout.date).toLocaleDateString(
"en-US",
{ month: "short", day: "numeric" }
);
return (
<Link
key={entry.workout.id}
href={`/main/workouts/${entry.workout.id}`}
className="flex items-baseline gap-2 px-3 py-1.5 rounded-md hover:bg-zinc-800/60 transition"
>
<span className="text-xs text-zinc-500 flex-shrink-0 tabular-nums">
{dateStr}
</span>
<span className="text-xs text-zinc-600 flex-shrink-0">·</span>
<span className="text-xs text-zinc-500 flex-shrink-0">
{entry.sets.length} {entry.sets.length === 1 ? "set" : "sets"}
</span>
{summary && (
<>
<span className="text-xs text-zinc-600 flex-shrink-0">·</span>
<span className="text-sm text-zinc-300 truncate">
{summary}
</span>
</>
)}
</Link>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
import ExercisesClient from "@/components/exercises/ExercisesClient";
export default async function ExercisesPage() {
const user = await getCurrentUser();
if (!user) {
redirect("/auth/login");
}
return (
<div className="min-h-screen bg-[#0A0A0A]">
<div className="border-b border-zinc-800 px-4 py-4 sm:px-6">
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-white">Exercises</h1>
<p className="text-zinc-500 text-sm mt-1">
Browse and manage your exercise library
</p>
</div>
</div>
<ExercisesClient />
</div>
);
}
@@ -0,0 +1,612 @@
"use client";
import { useState, useRef } from "react";
import { ChevronLeft, Upload, Trash2, Check, X } from "lucide-react";
import Link from "next/link";
interface ParsedSet {
setNumber: number;
weight?: number;
weightUnit: string;
reps?: number;
notes?: string;
}
interface ParsedExercise {
exerciseId: string;
exerciseName: string;
sets: ParsedSet[];
}
interface ParsedWorkout {
date: string;
exercises: ParsedExercise[];
}
interface WorkoutState extends ParsedWorkout {
status: "pending" | "approved" | "skipped";
}
export default function ImportCSVPage() {
const [workouts, setWorkouts] = useState<WorkoutState[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [unmapped, setUnmapped] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const currentWorkout = workouts[currentIndex];
const approved = workouts.filter((w) => w.status === "approved").length;
const skipped = workouts.filter((w) => w.status === "skipped").length;
const remaining = workouts.filter((w) => w.status === "pending").length;
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (!file) return;
setLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/import/parse", {
method: "POST",
body: formData,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to parse CSV");
}
const data = await response.json();
const initialWorkouts: WorkoutState[] = data.workouts.map(
(w: ParsedWorkout) => ({
...w,
status: "pending" as const,
})
);
setWorkouts(initialWorkouts);
setUnmapped(data.unmapped || []);
setCurrentIndex(0);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to parse CSV");
} finally {
setLoading(false);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (fileInputRef.current) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInputRef.current.files = dataTransfer.files;
const event = new Event("change", { bubbles: true });
fileInputRef.current.dispatchEvent(event);
}
}
};
const updateSet = (
exerciseIdx: number,
setIdx: number,
field: keyof ParsedSet,
value: any
) => {
if (!currentWorkout) return;
const updatedWorkouts = [...workouts];
const workout = updatedWorkouts[currentIndex];
const set = workout.exercises[exerciseIdx].sets[setIdx];
if (field === "setNumber") {
set[field] = value ? parseInt(value, 10) : 0;
} else if (field === "reps") {
set[field] = value ? parseInt(value, 10) : undefined;
} else if (field === "weight") {
set[field] = value ? parseFloat(value) : undefined;
} else {
(set[field] as any) = value;
}
setWorkouts(updatedWorkouts);
};
const deleteSet = (exerciseIdx: number, setIdx: number) => {
if (!currentWorkout) return;
const updatedWorkouts = [...workouts];
const workout = updatedWorkouts[currentIndex];
const exercise = workout.exercises[exerciseIdx];
// Remove the set
exercise.sets.splice(setIdx, 1);
// Renumber remaining sets
exercise.sets.forEach((set, idx) => {
set.setNumber = idx + 1;
});
// If no sets left, remove the exercise
if (exercise.sets.length === 0) {
workout.exercises.splice(exerciseIdx, 1);
}
setWorkouts(updatedWorkouts);
};
const deleteExercise = (exerciseIdx: number) => {
if (!currentWorkout) return;
const updatedWorkouts = [...workouts];
const workout = updatedWorkouts[currentIndex];
workout.exercises.splice(exerciseIdx, 1);
setWorkouts(updatedWorkouts);
};
const approveWorkout = async () => {
if (!currentWorkout) return;
try {
setLoading(true);
// Transform workout to API format
const setLogs = [];
for (const exercise of currentWorkout.exercises) {
for (const set of exercise.sets) {
setLogs.push({
exerciseId: exercise.exerciseId,
setNumber: set.setNumber,
weight: set.weight || null,
weightUnit: set.weightUnit,
reps: set.reps || null,
notes: set.notes || null,
});
}
}
const response = await fetch("/api/workouts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
date: currentWorkout.date,
sets: setLogs,
}),
});
if (!response.ok) {
throw new Error("Failed to save workout");
}
// Mark as approved and move to next
const updatedWorkouts = [...workouts];
updatedWorkouts[currentIndex].status = "approved";
setWorkouts(updatedWorkouts);
// Find next pending workout
const nextPending = updatedWorkouts.findIndex(
(w) => w.status === "pending"
);
if (nextPending !== -1) {
setCurrentIndex(nextPending);
} else {
setCurrentIndex(currentIndex + 1);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save workout");
} finally {
setLoading(false);
}
};
const skipWorkout = () => {
if (!currentWorkout) return;
const updatedWorkouts = [...workouts];
updatedWorkouts[currentIndex].status = "skipped";
setWorkouts(updatedWorkouts);
// Find next pending workout
const nextPending = updatedWorkouts.findIndex(
(w, idx) => w.status === "pending" && idx > currentIndex
);
if (nextPending !== -1) {
setCurrentIndex(nextPending);
} else {
setCurrentIndex(currentIndex + 1);
}
};
const deleteWorkout = () => {
const updatedWorkouts = workouts.filter((_, idx) => idx !== currentIndex);
setWorkouts(updatedWorkouts);
if (updatedWorkouts.length > 0) {
setCurrentIndex(Math.min(currentIndex, updatedWorkouts.length - 1));
}
};
// Upload step
if (workouts.length === 0) {
return (
<div className="min-h-screen bg-[#0A0A0A] pb-24 md:pb-8">
{/* Header */}
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
<Link
href="/main/workouts"
className="p-2 hover:bg-zinc-900 rounded-lg -ml-2 text-zinc-400 hover:text-white"
aria-label="Back"
>
<ChevronLeft className="w-6 h-6" />
</Link>
<h1 className="text-2xl font-display text-white tracking-wider">
Import Workouts
</h1>
</div>
</div>
{/* Upload Area */}
<div className="max-w-4xl mx-auto px-4 py-12">
{error && (
<div className="mb-6 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<p className="text-red-200">{error}</p>
<button
onClick={() => setError(null)}
className="mt-2 text-sm text-red-300 hover:text-red-200"
>
Dismiss
</button>
</div>
)}
<div className="bg-zinc-900 rounded-lg p-12 border-2 border-dashed border-zinc-700 hover:border-zinc-600 transition-colors cursor-pointer"
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileChange}
className="hidden"
/>
<div className="flex flex-col items-center gap-4">
<div className="p-4 bg-zinc-800 rounded-lg">
<Upload className="w-8 h-8 text-zinc-400" />
</div>
<div className="text-center">
<h2 className="text-xl font-semibold text-white mb-2">
Upload CSV File
</h2>
<p className="text-zinc-400 mb-4">
Drag and drop your CSV file here or click to select
</p>
<p className="text-sm text-zinc-500">
CSV columns: date, exercise, weight, reps, notes
</p>
</div>
{loading && (
<p className="text-zinc-400 text-sm">Parsing CSV...</p>
)}
</div>
</div>
{/* Example Format */}
<div className="mt-12 bg-zinc-900 rounded-lg p-6 border border-zinc-800">
<h3 className="text-lg font-semibold text-white mb-4">
CSV Format Example
</h3>
<pre className="text-xs text-zinc-400 overflow-x-auto bg-zinc-950 p-4 rounded border border-zinc-800">
{`date,exercise,weight,reps,notes
2025-02-15,Bench,225,5,good form
2025-02-15,Bench,225,5,
2025-02-15,Bench,225,3,
2025-02-16,Squat,315,8,30kg per leg
2025-02-16,Squat,315,6,`}
</pre>
</div>
</div>
</div>
);
}
// Review step
if (!currentWorkout) {
return (
<div className="min-h-screen bg-[#0A0A0A] pb-24 md:pb-8">
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
<Link
href="/main/workouts"
className="p-2 hover:bg-zinc-900 rounded-lg -ml-2 text-zinc-400 hover:text-white"
aria-label="Back"
>
<ChevronLeft className="w-6 h-6" />
</Link>
<h1 className="text-2xl font-display text-white tracking-wider">
Import Complete
</h1>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 py-12">
<div className="bg-zinc-900 rounded-lg p-8 border border-zinc-800 text-center">
<Check className="w-12 h-12 text-green-500 mx-auto mb-4" />
<h2 className="text-2xl font-semibold text-white mb-2">
All Done!
</h2>
<p className="text-zinc-400 mb-6">
{approved} workouts approved, {skipped} skipped
</p>
<Link
href="/main/workouts"
className="inline-block px-6 py-2 bg-white text-black font-semibold rounded-lg hover:bg-zinc-200 transition-colors"
>
View Workouts
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#0A0A0A] pb-24 md:pb-8">
{/* Header */}
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
<div className="max-w-4xl mx-auto px-4 py-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<button
onClick={() => setWorkouts([])}
className="p-2 hover:bg-zinc-900 rounded-lg -ml-2 text-zinc-400 hover:text-white"
aria-label="Back"
>
<ChevronLeft className="w-6 h-6" />
</button>
<h1 className="text-2xl font-display text-white tracking-wider">
Review Workouts
</h1>
</div>
</div>
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-zinc-400">
{approved} approved, {skipped} skipped, {remaining} remaining
</span>
<span className="text-zinc-500">
{currentIndex + 1} of {workouts.length}
</span>
</div>
<div className="w-full bg-zinc-800 h-2 rounded-full overflow-hidden">
<div
className="h-full bg-white transition-all duration-300"
style={{
width: `${((approved + skipped) / workouts.length) * 100}%`,
}}
/>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="max-w-4xl mx-auto px-4 py-6">
{error && (
<div className="mb-6 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<p className="text-red-200">{error}</p>
</div>
)}
{/* Unmapped Exercises Warning */}
{unmapped.length > 0 && (
<div className="mb-6 p-4 bg-yellow-900/20 border border-yellow-800 rounded-lg">
<p className="text-yellow-200 font-semibold mb-2">
Unmapped exercises (not in your database):
</p>
<div className="flex flex-wrap gap-2">
{unmapped.map((name) => (
<span
key={name}
className="px-3 py-1 bg-yellow-900/30 border border-yellow-700 rounded text-sm text-yellow-200"
>
{name}
</span>
))}
</div>
</div>
)}
{/* Workout Card */}
<div className="bg-zinc-900 rounded-lg border border-zinc-800 overflow-hidden">
{/* Date Header */}
<div className="bg-zinc-800 px-6 py-4 border-b border-zinc-700">
<h2 className="text-xl font-semibold text-white">
{new Date(currentWorkout.date).toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</h2>
</div>
{/* Exercises */}
<div className="divide-y divide-zinc-800">
{currentWorkout.exercises.map((exercise, exIdx) => (
<div key={exIdx} className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">
{exercise.exerciseName}
</h3>
<button
onClick={() => deleteExercise(exIdx)}
className="p-2 hover:bg-zinc-800 rounded text-zinc-400 hover:text-red-400 transition-colors"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
{/* Sets Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-700">
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
Set
</th>
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
Weight
</th>
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
Unit
</th>
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
Reps
</th>
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
Notes
</th>
<th className="text-right py-2 px-3 text-zinc-400 font-medium">
Action
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{exercise.sets.map((set, setIdx) => (
<tr key={setIdx}>
<td className="py-3 px-3">
<input
type="number"
min="1"
value={set.setNumber}
onChange={(e) =>
updateSet(exIdx, setIdx, "setNumber", e.target.value)
}
className="w-12 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white text-center"
/>
</td>
<td className="py-3 px-3">
<input
type="number"
step="0.5"
placeholder="—"
value={set.weight || ""}
onChange={(e) =>
updateSet(exIdx, setIdx, "weight", e.target.value)
}
className="w-20 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white"
/>
</td>
<td className="py-3 px-3">
<select
value={set.weightUnit}
onChange={(e) =>
updateSet(
exIdx,
setIdx,
"weightUnit",
e.target.value
)
}
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white text-sm"
>
<option>lbs</option>
<option>kg</option>
</select>
</td>
<td className="py-3 px-3">
<input
type="number"
min="1"
placeholder="—"
value={set.reps || ""}
onChange={(e) =>
updateSet(exIdx, setIdx, "reps", e.target.value)
}
className="w-16 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white"
/>
</td>
<td className="py-3 px-3">
<input
type="text"
placeholder="—"
value={set.notes || ""}
onChange={(e) =>
updateSet(exIdx, setIdx, "notes", e.target.value)
}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white text-sm"
/>
</td>
<td className="py-3 px-3 text-right">
<button
onClick={() => deleteSet(exIdx, setIdx)}
className="p-1 hover:bg-zinc-800 rounded text-zinc-400 hover:text-red-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
</div>
{/* Action Buttons */}
<div className="mt-6 flex gap-3 justify-between">
<button
onClick={deleteWorkout}
className="px-4 py-2 bg-red-900/20 border border-red-800 text-red-200 rounded-lg hover:bg-red-900/30 transition-colors font-medium"
>
Delete Workout
</button>
<div className="flex gap-3">
<button
onClick={skipWorkout}
disabled={loading}
className="px-6 py-2 bg-zinc-800 border border-zinc-700 text-white rounded-lg hover:bg-zinc-700 transition-colors font-medium disabled:opacity-50"
>
Skip
</button>
<button
onClick={approveWorkout}
disabled={loading || currentWorkout.exercises.length === 0}
className="px-6 py-2 bg-white text-black rounded-lg hover:bg-zinc-200 transition-colors font-medium disabled:opacity-50 flex items-center gap-2"
>
{loading ? "Saving..." : "Approve"}
<Check className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
import ImportCSVPage from "./page-csv";
export const metadata = {
title: "Import Workouts",
description: "Import workouts from CSV",
};
export default async function ImportPage() {
const user = await getCurrentUser();
if (!user) redirect("/auth/login");
return <ImportCSVPage />;
}
+24
View File
@@ -0,0 +1,24 @@
import { getCurrentUser } from '@/lib/auth';
import { redirect } from 'next/navigation';
import Navigation from './navigation';
export default async function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getCurrentUser();
if (!user) {
redirect('/auth/login');
}
return (
<div className="min-h-screen flex flex-col bg-[#0A0A0A]">
<Navigation userName={user.name || user.email || 'User'} />
<main className="flex-1 app-content pb-20 md:pb-0">
{children}
</main>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import {
LayoutDashboard,
Dumbbell,
ListChecks,
Upload,
Settings,
LogOut,
} from 'lucide-react';
import { logoutAction } from './actions';
interface NavigationProps {
userName: string;
}
const navLinks = [
{ href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/main/workouts', label: 'Workouts', icon: Dumbbell },
{ href: '/main/exercises', label: 'Exercises', icon: ListChecks },
{ href: '/main/import', label: 'Import', icon: Upload },
{ href: '/main/settings', label: 'Settings', icon: Settings },
];
export default function Navigation({ userName }: NavigationProps) {
const pathname = usePathname();
const router = useRouter();
const isActive = (href: string) => {
return pathname === href || pathname.startsWith(href + '/');
};
const handleLogout = async () => {
await logoutAction();
router.push('/auth/login');
};
return (
<>
{/* Desktop Sidebar */}
<aside className="hidden md:flex fixed left-0 top-0 h-screen w-[var(--sidebar-width)] border-r border-zinc-800 bg-[#0A0A0A] flex-col">
<div className="p-6 border-b border-zinc-800">
<h2 className="text-3xl font-display text-white tracking-wider">Workout</h2>
<p className="text-xs text-zinc-500 mt-1 uppercase tracking-widest font-sans">Planner</p>
</div>
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
{navLinks.map((link) => {
const Icon = link.icon;
const active = isActive(link.href);
return (
<a
key={link.href}
href={link.href}
className={`flex items-center gap-3 px-4 py-2.5 rounded transition-all duration-200 ${
active
? 'bg-white text-black font-semibold'
: 'text-zinc-500 hover:text-white hover:bg-zinc-900'
}`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">{link.label}</span>
</a>
);
})}
</nav>
<div className="border-t border-zinc-800 p-4 space-y-4">
<div className="px-4 py-2">
<p className="text-xs text-zinc-600 uppercase tracking-widest">User</p>
<p className="font-semibold text-white truncate mt-1">{userName}</p>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-2.5 rounded transition-all duration-200 w-full justify-start text-red-500 hover:text-red-400 hover:bg-red-950/30"
>
<LogOut className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">Logout</span>
</button>
</div>
</aside>
{/* Mobile Bottom Nav */}
<header className="flex md:hidden fixed bottom-0 left-0 right-0 border-t border-zinc-800 bg-[#0A0A0A]">
<nav className="flex items-center justify-around h-[var(--bottom-nav-height)] w-full">
{navLinks.map((link) => {
const Icon = link.icon;
const active = isActive(link.href);
return (
<a
key={link.href}
href={link.href}
className={`flex flex-col items-center justify-center flex-1 h-full gap-1 transition-colors duration-200 ${
active
? 'text-white bg-zinc-900'
: 'text-zinc-500 hover:text-white'
}`}
>
<Icon className="w-6 h-6" />
<span className="text-xs">{link.label}</span>
</a>
);
})}
<button
onClick={handleLogout}
className="flex flex-col items-center justify-center flex-1 h-full gap-1 text-red-500 hover:text-red-400 transition-colors duration-200"
>
<LogOut className="w-6 h-6" />
<span className="text-xs">Logout</span>
</button>
</nav>
</header>
</>
);
}
@@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
import SettingsForm from "@/components/settings/SettingsForm";
export default async function SettingsPage() {
const user = await getCurrentUser();
if (!user) {
redirect("/auth/login");
}
return (
<div className="min-h-screen bg-[#0A0A0A]">
<div className="border-b border-zinc-800 px-4 py-4 sm:px-6">
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-white">Settings</h1>
<p className="text-zinc-500 text-sm mt-1">
Manage your preferences and account
</p>
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6">
<SettingsForm user={user} />
</div>
</div>
);
}
@@ -0,0 +1,239 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import {
ChevronLeft,
Trash2,
Loader,
Pencil,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { WorkoutWithSets } from "@/types";
import { formatSetsSummary } from "@/lib/formatSets";
function buildSetSummary(set: {
weight?: number | null;
weightUnit?: string | null;
reps?: number | null;
rpe?: number | null;
notes?: string | null;
durationSeconds?: number | null;
distance?: number | null;
calories?: number | null;
}) {
const parts: string[] = [];
if (set.weight) parts.push(`${set.weight} ${set.weightUnit === "kg" ? "kg" : "lbs"}`);
if (set.reps) parts.push(`${set.reps} reps`);
if ((set as any).durationSeconds) parts.push(`${(set as any).durationSeconds}s`);
if ((set as any).distance) parts.push(`${(set as any).distance} mi`);
if ((set as any).calories) parts.push(`${(set as any).calories} cal`);
if (set.rpe) parts.push(`RPE ${set.rpe}`);
if (set.notes) parts.push(set.notes);
return parts.length > 0 ? parts.join(" · ") : "No data";
}
export default function WorkoutDetailPage() {
const params = useParams();
const router = useRouter();
const [workout, setWorkout] = useState<WorkoutWithSets | null>(null);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false);
const [expandedExercise, setExpandedExercise] = useState<string | null>(null);
const workoutId = params.id as string;
useEffect(() => {
const fetchWorkout = async () => {
try {
const response = await fetch(`/api/workouts/${workoutId}`);
if (!response.ok) throw new Error("Failed to fetch");
const data = await response.json();
setWorkout(data);
} catch (error) {
console.error("Error fetching workout:", error);
} finally {
setLoading(false);
}
};
fetchWorkout();
}, [workoutId]);
const handleDelete = async () => {
if (!confirm("Are you sure you want to delete this workout?")) return;
setDeleting(true);
try {
const response = await fetch(`/api/workouts/${workoutId}`, { method: "DELETE" });
if (!response.ok) throw new Error("Failed to delete");
router.push("/main/workouts");
} catch (error) {
console.error("Error deleting workout:", error);
alert("Failed to delete workout");
setDeleting(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-[#0A0A0A] flex items-center justify-center">
<Loader className="w-8 h-8 animate-spin text-white" />
</div>
);
}
if (!workout) {
return (
<div className="min-h-screen bg-[#0A0A0A]">
<div className="max-w-2xl mx-auto px-4 py-6">
<Link href="/main/workouts" className="inline-flex items-center gap-2 text-white hover:text-gray-200 mb-4">
<ChevronLeft className="w-5 h-5" /> Back
</Link>
<p className="text-zinc-400">Workout not found</p>
</div>
</div>
);
}
// Format date
const date = new Date(workout.date);
const formattedDate = date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
// Group sets by exercise (preserving order)
const exerciseGroups: Array<{ exerciseId: string; exerciseName: string; sets: typeof workout.setLogs }> = [];
const seen = new Set<string>();
for (const set of workout.setLogs) {
if (!seen.has(set.exercise.id)) {
seen.add(set.exercise.id);
exerciseGroups.push({
exerciseId: set.exercise.id,
exerciseName: set.exercise.name,
sets: workout.setLogs.filter((s) => s.exercise.id === set.exercise.id),
});
}
}
// Build post-workout stats
const stats: string[] = [];
if (workout.durationMinutes) stats.push(`${workout.durationMinutes} min`);
if (workout.difficulty) stats.push(`Difficulty ${workout.difficulty}/10`);
return (
<div className="min-h-screen bg-[#0A0A0A]">
{/* Header */}
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
<Link href="/main/workouts" className="p-2 hover:bg-zinc-800 rounded-lg -ml-2" aria-label="Back">
<ChevronLeft className="w-6 h-6 text-white" />
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-lg font-bold text-white truncate">
{workout.name || "Unnamed Workout"}
</h1>
<p className="text-sm text-zinc-400">{formattedDate}</p>
</div>
<Link
href={`/main/workouts/new?edit=${workoutId}`}
className="p-2 hover:bg-zinc-800 text-zinc-400 hover:text-white rounded-lg transition-colors"
aria-label="Edit workout"
>
<Pencil className="w-5 h-5" />
</Link>
<button
onClick={handleDelete}
disabled={deleting}
className="p-2 hover:bg-zinc-800 text-red-500 rounded-lg -mr-2 disabled:opacity-50"
aria-label="Delete workout"
>
{deleting ? <Loader className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
</button>
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-4 space-y-2">
{/* Exercises — compact collapsible cards */}
{exerciseGroups.map((group) => {
const isExpanded = expandedExercise === group.exerciseId;
const setsWithData = group.sets.filter((s) => s.reps || s.weight);
const summary = formatSetsSummary(setsWithData) ||
`${group.sets.length} set${group.sets.length !== 1 ? "s" : ""}`;
return (
<div key={group.exerciseId} className="border border-zinc-800 rounded-lg bg-zinc-900">
{/* Exercise header */}
<button
type="button"
onClick={() => setExpandedExercise(isExpanded ? null : group.exerciseId)}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-zinc-800/50 transition-colors rounded-lg"
>
<div className="text-left flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<h4 className="font-semibold text-white text-sm truncate">
{group.exerciseName}
</h4>
<span className="text-xs text-zinc-500 flex-shrink-0">
{group.sets.length} set{group.sets.length !== 1 ? "s" : ""}
</span>
</div>
{!isExpanded && setsWithData.length > 0 && (
<p className="text-xs text-zinc-400 mt-0.5 truncate">{summary}</p>
)}
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-zinc-500 flex-shrink-0 ml-2" />
) : (
<ChevronDown className="w-4 h-4 text-zinc-500 flex-shrink-0 ml-2" />
)}
</button>
{/* Expanded sets — same style as locked SetRow */}
{isExpanded && (
<div className="border-t border-zinc-800 p-3 space-y-2">
{group.sets.map((set) => (
<div key={set.id} className="flex items-center gap-1.5">
<div className="bg-white text-black rounded-full w-6 h-6 flex items-center justify-center font-semibold text-xs flex-shrink-0">
{set.setNumber}
</div>
<p className="text-sm text-zinc-300 truncate flex-1">
{buildSetSummary(set)}
</p>
</div>
))}
</div>
)}
</div>
);
})}
{/* Post-workout info (notes, duration, difficulty) */}
{(workout.notes || stats.length > 0) && (
<div className="border border-zinc-800 rounded-lg bg-zinc-900 px-3 py-2.5 space-y-1.5">
{stats.length > 0 && (
<p className="text-xs text-zinc-400">{stats.join(" · ")}</p>
)}
{workout.notes && (
<p className="text-sm text-zinc-300">{workout.notes}</p>
)}
</div>
)}
{/* Edit button at bottom */}
<div className="pt-4 pb-8">
<Link
href={`/main/workouts/new?edit=${workoutId}`}
className="w-full py-3 border border-zinc-700 text-white font-semibold rounded-lg hover:bg-zinc-800 flex items-center justify-center gap-2 transition"
>
<Pencil className="w-5 h-5" />
Edit Workout
</Link>
</div>
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
import { getCurrentUser } from "@/lib/auth";
import { getExercises } from "@/lib/db/exercises";
import { getWorkoutById } from "@/lib/db/workouts";
import WorkoutForm, { EditWorkoutData } from "@/components/workouts/WorkoutForm";
export const metadata = {
title: "Log Workout",
description: "Log a new workout",
};
export default async function NewWorkoutPage({
searchParams,
}: {
searchParams: { edit?: string };
}) {
const user = await getCurrentUser();
if (!user) {
redirect("/auth/login");
}
const exercises = await getExercises(user.id);
// If ?edit=WORKOUT_ID, fetch existing workout for editing
let editWorkout: EditWorkoutData | undefined;
if (searchParams.edit) {
const workout = await getWorkoutById(searchParams.edit);
if (workout && workout.userId === user.id) {
// Group sets by exercise
const grouped: Record<string, EditWorkoutData["exercises"][number]> = {};
for (const set of workout.setLogs) {
const exId = set.exercise.id;
if (!grouped[exId]) {
grouped[exId] = {
exercise: set.exercise,
sets: [],
};
}
grouped[exId].sets.push({
setNumber: set.setNumber,
reps: set.reps ?? undefined,
weight: set.weight ?? undefined,
rpe: set.rpe ?? undefined,
notes: set.notes ?? undefined,
});
}
editWorkout = {
id: workout.id,
name: workout.name || "",
date: workout.date.toISOString(),
durationMinutes: workout.durationMinutes,
difficulty: workout.difficulty,
caloriesBurned: (workout as any).caloriesBurned ?? null,
notes: workout.notes,
exercises: Object.values(grouped),
};
}
}
const isEditing = !!editWorkout;
return (
<div className="min-h-screen bg-[#0A0A0A] pb-24 md:pb-8">
{/* Header */}
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
<Link
href={isEditing ? `/main/workouts/${editWorkout!.id}` : "/main/workouts"}
className="p-2 hover:bg-zinc-900 rounded-lg -ml-2 text-zinc-400 hover:text-white"
aria-label="Back"
>
<ChevronLeft className="w-6 h-6" />
</Link>
<h1 className="text-2xl font-display text-white tracking-wider">
{isEditing ? "Edit Workout" : "Log Workout"}
</h1>
</div>
</div>
{/* Form */}
<div className="max-w-2xl mx-auto px-4 py-6 pb-12">
<WorkoutForm
exercises={exercises}
recentlyUsedExercises={[]}
editWorkout={editWorkout}
/>
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { Plus, Activity, Upload } from "lucide-react";
import { getCurrentUser } from "@/lib/auth";
import { getWorkouts } from "@/lib/db/workouts";
import WorkoutCard from "@/components/workouts/WorkoutCard";
interface PageProps {
searchParams: { q?: string; dateFrom?: string; dateTo?: string };
}
export const metadata = {
title: "Workout History",
description: "View your workout history",
};
export default async function WorkoutsPage({ searchParams }: PageProps) {
const user = await getCurrentUser();
if (!user) {
redirect("/auth/login");
}
// Parse search params
const query = searchParams.q || "";
const dateFrom = searchParams.dateFrom
? new Date(searchParams.dateFrom)
: undefined;
const dateTo = searchParams.dateTo
? new Date(searchParams.dateTo)
: undefined;
// Fetch workouts
const workouts = await getWorkouts(user.id, {
query,
dateFrom,
dateTo,
limit: 50,
});
return (
<div className="min-h-screen bg-[#0A0A0A]">
{/* Header */}
<div className="border-b border-zinc-800 sticky top-0 z-40">
<div className="max-w-2xl mx-auto px-4 py-4 sm:py-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl sm:text-3xl font-bold text-white">
Workout History
</h1>
<Link
href="/main/import"
className="p-2 hover:bg-zinc-900 rounded-lg text-zinc-400 hover:text-white transition"
title="Import workouts from CSV"
>
<Upload className="w-5 h-5" />
</Link>
</div>
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-6">
{/* Search and filters */}
<form className="mb-6 space-y-4">
{/* Search bar */}
<div>
<input
type="text"
name="q"
placeholder="Search workouts..."
defaultValue={query}
className="w-full px-4 py-3 border border-zinc-700 bg-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-white text-white placeholder-zinc-500 text-base"
/>
</div>
{/* Date range */}
<div className="grid grid-cols-2 gap-3 sm:gap-4">
<div>
<label className="block text-sm text-zinc-400 mb-1">From</label>
<input
type="date"
name="dateFrom"
defaultValue={searchParams.dateFrom || ""}
className="w-full px-4 py-2 border border-zinc-700 bg-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-white text-white text-base"
/>
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1">To</label>
<input
type="date"
name="dateTo"
defaultValue={searchParams.dateTo || ""}
className="w-full px-4 py-2 border border-zinc-700 bg-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-white text-white text-base"
/>
</div>
</div>
{/* Submit buttons */}
<div className="flex gap-2">
<button
type="submit"
className="flex-1 px-4 py-2 bg-white text-black rounded-lg font-medium hover:bg-gray-100 touch-target"
>
Filter
</button>
<Link
href="/main/workouts"
className="flex-1 px-4 py-2 bg-zinc-800 text-white rounded-lg font-medium hover:bg-zinc-700 text-center touch-target"
>
Clear
</Link>
</div>
</form>
{/* Workout list */}
{workouts.length === 0 ? (
<div className="text-center py-12">
<div className="flex justify-center mb-4">
<Activity className="w-12 h-12 text-zinc-600" />
</div>
<h2 className="text-lg font-semibold text-white mb-2">
No workouts yet
</h2>
<p className="text-zinc-400 mb-6">
{query || dateFrom || dateTo
? "No workouts match your filters. Try adjusting your search."
: "Start tracking your fitness journey by logging your first workout."}
</p>
<Link
href="/main/workouts/new"
className="inline-flex items-center gap-2 px-6 py-3 bg-white text-black rounded-lg font-semibold hover:bg-gray-100 touch-target"
>
<Plus className="w-5 h-5" />
Log Your First Workout
</Link>
</div>
) : (
<div className="space-y-3 pb-20 sm:pb-6">
{workouts.map((workout) => (
<WorkoutCard key={workout.id} workout={workout} />
))}
</div>
)}
</div>
{/* Floating action button for mobile, regular button for desktop */}
{workouts.length > 0 && (
<>
{/* Mobile FAB */}
<div className="fixed bottom-6 right-6 sm:hidden">
<Link
href="/main/workouts/new"
className="flex items-center justify-center w-14 h-14 bg-white text-black rounded-full shadow-lg hover:bg-gray-100 active:bg-gray-200 touch-target"
aria-label="Log new workout"
>
<Plus className="w-6 h-6" />
</Link>
</div>
{/* Desktop button */}
<div className="hidden sm:block fixed bottom-6 right-6">
<Link
href="/main/workouts/new"
className="flex items-center gap-2 px-6 py-3 bg-white text-black rounded-lg font-semibold hover:bg-gray-100 shadow-lg"
>
<Plus className="w-5 h-5" />
Log Workout
</Link>
</div>
</>
)}
</div>
);
}