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