feat: add workout management pages
- Add workout detail page at /workouts/[id] - Add workout creation page at /workouts/new - Integrate WorkoutForm component
This commit is contained in:
74
admin-web/app/workouts/[id]/edit/page.tsx
Normal file
74
admin-web/app/workouts/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import WorkoutForm from "@/components/workout-form"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
|
||||
interface EditWorkoutPageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
async function getWorkout(id: string) {
|
||||
const { data, error } = await supabase
|
||||
.from("workouts")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: EditWorkoutPageProps): Promise<Metadata> {
|
||||
const resolvedParams = await params
|
||||
const workout = await getWorkout(resolvedParams.id)
|
||||
|
||||
if (!workout) {
|
||||
return {
|
||||
title: "Workout Not Found | TabataFit Admin",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `Edit ${workout.title} | TabataFit Admin`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function EditWorkoutPage({ params }: EditWorkoutPageProps) {
|
||||
const resolvedParams = await params
|
||||
const workout = await getWorkout(resolvedParams.id)
|
||||
|
||||
if (!workout) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href={`/workouts/${workout.id}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Workout
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Edit Workout</h1>
|
||||
<p className="text-neutral-400">
|
||||
Update the details for "{workout.title}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-6">
|
||||
<WorkoutForm initialData={workout} mode="edit" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
242
admin-web/app/workouts/[id]/page.tsx
Normal file
242
admin-web/app/workouts/[id]/page.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft, Edit, Trash2, Clock, Flame, Dumbbell, Music } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
|
||||
interface WorkoutDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
"full-body": "bg-orange-500/20 text-orange-500",
|
||||
"core": "bg-blue-500/20 text-blue-500",
|
||||
"upper-body": "bg-green-500/20 text-green-500",
|
||||
"lower-body": "bg-purple-500/20 text-purple-500",
|
||||
"cardio": "bg-red-500/20 text-red-500",
|
||||
}
|
||||
|
||||
async function getWorkout(id: string) {
|
||||
const { data, error } = await supabase
|
||||
.from("workouts")
|
||||
.select(`
|
||||
*,
|
||||
trainers (name, specialty, color)
|
||||
`)
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: WorkoutDetailPageProps): Promise<Metadata> {
|
||||
const resolvedParams = await params
|
||||
const workout = await getWorkout(resolvedParams.id)
|
||||
|
||||
if (!workout) {
|
||||
return {
|
||||
title: "Workout Not Found | TabataFit Admin",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${workout.title} | TabataFit Admin`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function WorkoutDetailPage({ params }: WorkoutDetailPageProps) {
|
||||
const resolvedParams = await params
|
||||
const workout = await getWorkout(resolvedParams.id)
|
||||
|
||||
if (!workout) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const trainer = workout.trainers as { name: string; specialty: string; color: string } | null
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href="/workouts">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Workouts
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-white">{workout.title}</h1>
|
||||
{workout.is_featured && (
|
||||
<Badge className="bg-orange-500/20 text-orange-500">Featured</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<span>By {trainer?.name || "Unknown Trainer"}</span>
|
||||
<span>•</span>
|
||||
<Badge className={CATEGORY_COLORS[workout.category] || "bg-neutral-500/20 text-neutral-500"}>
|
||||
{workout.category}
|
||||
</Badge>
|
||||
<span>•</span>
|
||||
<span>{workout.level}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild className="border-neutral-700">
|
||||
<Link href={`/workouts/${workout.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-sm">Duration</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{workout.duration} min</p>
|
||||
<p className="text-xs text-neutral-500">{workout.rounds} rounds</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Flame className="h-4 w-4" />
|
||||
<span className="text-sm">Calories</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{workout.calories}</p>
|
||||
<p className="text-xs text-neutral-500">estimated burn</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Dumbbell className="h-4 w-4" />
|
||||
<span className="text-sm">Timing</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{workout.work_time}s/{workout.rest_time}s</p>
|
||||
<p className="text-xs text-neutral-500">work/rest (+{workout.prep_time}s prep)</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Music className="h-4 w-4" />
|
||||
<span className="text-sm">Music</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white capitalize">{workout.music_vibe}</p>
|
||||
<p className="text-xs text-neutral-500">playlist vibe</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Exercises */}
|
||||
<Card className="bg-neutral-900 border-neutral-800 mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Exercises ({workout.exercises?.length || 0})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{workout.exercises?.map((exercise: { name: string; duration: number }, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between rounded-md border border-neutral-800 bg-neutral-950 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-neutral-800 text-xs text-neutral-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="text-white">{exercise.name}</span>
|
||||
</div>
|
||||
<span className="text-sm text-neutral-500">{exercise.duration}s</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Equipment */}
|
||||
{workout.equipment && workout.equipment.length > 0 && (
|
||||
<Card className="bg-neutral-900 border-neutral-800 mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Equipment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{workout.equipment.map((item: string) => (
|
||||
<Badge
|
||||
key={item}
|
||||
variant="secondary"
|
||||
className="bg-neutral-800 text-neutral-300"
|
||||
>
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Media */}
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Media</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{workout.thumbnail_url ? (
|
||||
<div>
|
||||
<p className="text-sm text-neutral-400 mb-2">Thumbnail</p>
|
||||
<img
|
||||
src={workout.thumbnail_url}
|
||||
alt={workout.title}
|
||||
className="rounded-lg max-h-48 object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">No thumbnail</p>
|
||||
)}
|
||||
|
||||
{workout.video_url ? (
|
||||
<div>
|
||||
<p className="text-sm text-neutral-400 mb-2">Video</p>
|
||||
<video
|
||||
src={workout.video_url}
|
||||
controls
|
||||
className="rounded-lg w-full max-h-64"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">No video</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
admin-web/app/workouts/new/page.tsx
Normal file
34
admin-web/app/workouts/new/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import WorkoutForm from "@/components/workout-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "New Workout | TabataFit Admin",
|
||||
description: "Create a new workout",
|
||||
}
|
||||
|
||||
export default function NewWorkoutPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href="/workouts">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Workouts
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Create New Workout</h1>
|
||||
<p className="text-neutral-400">
|
||||
Fill in the details below to create a new Tabata workout
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-6">
|
||||
<WorkoutForm mode="create" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user