refactor: code quality cleanup — remove any types, add logger, rename Kine to Tabata

- Phase 0: Rename all Kine references to Tabata (types, files, imports, i18n, analytics events)
- Phase 1: Add test coverage for tabataProgramStore, workoutProgramStore, and color utils (47 tests)
- Phase 2: Remove all `any` types from production code with proper typed replacements
- Phase 3: Replace ~60 raw console.* calls with __DEV__-gated logger utility
- Phase 4: Verify .DS_Store housekeeping (already clean)

0 TypeScript errors, 583/583 tests passing.
This commit is contained in:
Millian Lamiaux
2026-04-17 18:56:24 +02:00
parent e0e02c4550
commit 791f432334
176 changed files with 16508 additions and 2305 deletions

View File

@@ -0,0 +1,86 @@
import { GoogleGenAI } from '@google/genai'
import { NextResponse } from 'next/server'
import { supabase } from '@/lib/supabase'
export async function POST(request: Request) {
try {
const { prompt, trainerId, filename } = await request.json()
const apiKey = process.env.GEMINI_API_KEY
if (!apiKey) {
return NextResponse.json(
{ error: 'GEMINI_API_KEY not configured' },
{ status: 500 }
)
}
const ai = new GoogleGenAI({ apiKey })
const config = {
imageConfig: {
aspectRatio: '1:1',
imageSize: '1K',
},
responseModalities: ['IMAGE', 'TEXT'] as string[],
}
const response = await ai.models.generateContentStream({
model: 'gemini-3.1-flash-image-preview',
config,
contents: [{ role: 'user', parts: [{ text: prompt }] }],
})
const images: { buffer: Buffer; mimeType: string }[] = []
for await (const chunk of response) {
const parts = chunk.candidates?.[0]?.content?.parts
if (!parts) continue
for (const part of parts) {
if (part.inlineData) {
images.push({
buffer: Buffer.from(part.inlineData.data || '', 'base64'),
mimeType: part.inlineData.mimeType || 'image/png',
})
}
}
}
if (images.length === 0) {
return NextResponse.json(
{ error: 'No images generated' },
{ status: 500 }
)
}
// Save first image to storage
const image = images[0]
const path = `${trainerId}/${filename}`
const { error } = await supabase.storage
.from('trainer-avatars')
.upload(path, image.buffer, {
contentType: image.mimeType,
upsert: true,
})
if (error) {
return NextResponse.json(
{ error: `Failed to upload avatar: ${error.message}` },
{ status: 500 }
)
}
const { data: { publicUrl } } = supabase.storage
.from('trainer-avatars')
.getPublicUrl(path)
return NextResponse.json({ url: publicUrl })
} catch (error) {
console.error('Avatar generation failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Generation failed' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,297 @@
import { GoogleGenAI, VideoGenerationReferenceType } from '@google/genai'
import { NextResponse } from 'next/server'
import { supabase } from '@/lib/supabase'
interface Exercise {
name: string
duration: number
}
interface GeneratedVideo {
exerciseName: string
videoType: 'exercise' | 'rest'
videoUrl: string
videoPath: string
durationSeconds: number
}
async function pollOperation(ai: GoogleGenAI, operation: any): Promise<any> {
while (!operation.done) {
await new Promise(resolve => setTimeout(resolve, 10000))
operation = await ai.operations.getVideosOperation({ operation })
}
return operation
}
async function uploadVideo(
bucket: string,
path: string,
buffer: Buffer
): Promise<string> {
const { error } = await supabase.storage
.from(bucket)
.upload(path, buffer, {
contentType: 'video/mp4',
upsert: true,
})
if (error) {
throw new Error(`Failed to upload video: ${error.message}`)
}
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(path)
return publicUrl
}
async function getMimeTypeFromUrl(url: string): Promise<string> {
try {
const urlPath = new URL(url).pathname
const extension = urlPath.split('.').pop()?.toLowerCase()
switch (extension) {
case 'png':
return 'image/png'
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'webp':
return 'image/webp'
case 'gif':
return 'image/gif'
default:
return 'image/png'
}
} catch {
return 'image/png'
}
}
async function generateVideoWithReference(
ai: GoogleGenAI,
apiKey: string,
prompt: string,
avatarUrl: string,
durationSeconds: number = 8
): Promise<{ uri: string; buffer: Buffer } | null> {
// Fetch avatar image from URL and convert to base64
const avatarResponse = await fetch(avatarUrl)
const avatarBuffer = await avatarResponse.arrayBuffer()
const avatarBase64 = Buffer.from(avatarBuffer).toString('base64')
const mimeType = await getMimeTypeFromUrl(avatarUrl)
const operation = await ai.models.generateVideos({
model: 'veo-3.1-fast-generate-preview',
prompt,
config: {
numberOfVideos: 1,
aspectRatio: '16:9',
resolution: '1080p',
durationSeconds,
referenceImages: [{
image: {
imageBytes: avatarBase64,
mimeType,
},
referenceType: 'character' as any,
}],
},
})
const completedOperation = await pollOperation(ai, operation)
const videoUri = completedOperation.response?.generatedVideos?.[0]?.video?.uri
if (!videoUri) {
return null
}
const response = await fetch(`${videoUri}&key=${apiKey}`)
const arrayBuffer = await response.arrayBuffer()
const videoBuffer = Buffer.from(arrayBuffer)
return { uri: videoUri, buffer: videoBuffer }
}
async function extendVideo(
ai: GoogleGenAI,
apiKey: string,
videoUri: string,
prompt: string,
durationSeconds: number = 8
): Promise<{ uri: string; buffer: Buffer } | null> {
const operation = await ai.models.generateVideos({
model: 'veo-3.1-fast-generate-preview',
prompt,
video: videoUri as any,
config: {
numberOfVideos: 1,
durationSeconds,
},
})
const completedOperation = await pollOperation(ai, operation)
const newVideoUri = completedOperation.response?.generatedVideos?.[0]?.video?.uri
if (!newVideoUri) {
return null
}
const response = await fetch(`${newVideoUri}&key=${apiKey}`)
const arrayBuffer = await response.arrayBuffer()
const videoBuffer = Buffer.from(arrayBuffer)
return { uri: newVideoUri, buffer: videoBuffer }
}
export async function POST(request: Request) {
try {
const { workoutId, trainerId, trainerAvatarUrl, exercises } = await request.json()
if (!trainerAvatarUrl) {
return NextResponse.json(
{ error: 'Trainer avatar URL is required' },
{ status: 400 }
)
}
const apiKey = process.env.GEMINI_API_KEY
if (!apiKey) {
return NextResponse.json(
{ error: 'GEMINI_API_KEY not configured' },
{ status: 500 }
)
}
const ai = new GoogleGenAI({ apiKey })
const generatedVideos: GeneratedVideo[] = []
for (const exercise of exercises) {
const exerciseName = exercise.name
console.log(`Processing exercise: ${exerciseName}`)
const safeName = exerciseName.toLowerCase().replace(/[^a-z0-9]/g, '_')
// 1. Generate 8s exercise video with avatar reference
console.log(`Generating base exercise video for: ${exerciseName}`)
const exercisePrompt = `A fitness trainer demonstrating ${exerciseName} exercise with correct form and technique. The person should be energetic and perform the exercise properly.`
let exerciseVideo = await generateVideoWithReference(
ai,
apiKey,
exercisePrompt,
trainerAvatarUrl,
8
)
if (!exerciseVideo) {
console.error(`Failed to generate base exercise video for: ${exerciseName}`)
continue
}
// 2. Extend exercise video to 22s (8s + 7s + 7s)
console.log(`Extending exercise video for: ${exerciseName}`)
const extendPrompt1 = `Continue the ${exerciseName} exercise motion seamlessly`
let extended1 = await extendVideo(ai, apiKey, exerciseVideo.uri, extendPrompt1, 8)
if (extended1) {
const extendPrompt2 = `Continue the ${exerciseName} exercise motion seamlessly`
const extended2 = await extendVideo(ai, apiKey, extended1.uri, extendPrompt2, 8)
if (extended2) {
exerciseVideo = extended2
}
}
const exercisePath = `${trainerId}/${workoutId}/${safeName}.mp4`
const exerciseUrl = await uploadVideo('trainer-videos', exercisePath, exerciseVideo.buffer)
generatedVideos.push({
exerciseName,
videoType: 'exercise',
videoUrl: exerciseUrl,
videoPath: exercisePath,
durationSeconds: 22,
})
console.log(`Exercise video uploaded: ${exerciseUrl}`)
// 3. Generate 8s rest video with avatar reference
console.log(`Generating rest video for: ${exerciseName}`)
const restPrompt = `A fitness trainer resting, standing still, breathing calmly with arms at sides. The person looks relaxed.`
const restVideo = await generateVideoWithReference(
ai,
apiKey,
restPrompt,
trainerAvatarUrl,
8
)
if (restVideo) {
const restPath = `${trainerId}/${workoutId}/${safeName}_rest.mp4`
const restUrl = await uploadVideo('trainer-videos', restPath, restVideo.buffer)
generatedVideos.push({
exerciseName,
videoType: 'rest',
videoUrl: restUrl,
videoPath: restPath,
durationSeconds: 8,
})
console.log(`Rest video uploaded: ${restUrl}`)
} else {
console.error(`Failed to generate rest video for: ${exerciseName}`)
}
}
if (generatedVideos.length === 0) {
return NextResponse.json(
{ error: 'No videos were generated' },
{ status: 500 }
)
}
// Save all videos to exercise_videos table
for (const video of generatedVideos) {
await (supabase.from('exercise_videos') as any)
.insert({
workout_id: workoutId,
trainer_id: trainerId,
exercise_name: video.exerciseName,
video_url: video.videoUrl,
video_path: video.videoPath,
video_type: video.videoType,
duration_seconds: video.durationSeconds,
})
}
// Update workout with primary video_url (first exercise video)
const primaryVideo = generatedVideos.find(v => v.videoType === 'exercise')
if (primaryVideo) {
await (supabase.from('workouts') as any)
.update({ video_url: primaryVideo.videoUrl })
.eq('id', workoutId)
}
// Mark workout as generated in program_workouts if applicable
await (supabase.from('program_workouts') as any)
.update({ video_generated: true })
.eq('workout_id', workoutId)
return NextResponse.json({
workoutId,
videos: generatedVideos,
totalVideos: generatedVideos.length,
})
} catch (error) {
console.error('Video generation failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Generation failed' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,11 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 16, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
</claude-mem-context>

View File

@@ -0,0 +1,11 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 16, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
</claude-mem-context>

View File

@@ -0,0 +1,11 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 16, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
</claude-mem-context>

View File

@@ -0,0 +1,81 @@
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 ProgramForm from "@/components/program-form"
import { supabase } from "@/lib/supabase"
interface EditProgramPageProps {
params: Promise<{
id: string
}>
}
async function getProgram(id: string) {
const { data, error } = await (supabase.from("workout_programs") as any)
.select(`
*,
program_tabatas (*)
`)
.eq("id", id)
.single()
if (error || !data) {
return null
}
// Sort tabatas by position
if (data.program_tabatas) {
data.program_tabatas.sort((a: any, b: any) => a.position - b.position)
}
return data
}
export async function generateMetadata({ params }: EditProgramPageProps): Promise<Metadata> {
const resolvedParams = await params
const program = await getProgram(resolvedParams.id)
if (!program) {
return {
title: "Program Not Found | TabataFit Admin",
}
}
return {
title: `Edit ${program.title} | TabataFit Admin`,
}
}
export default async function EditProgramPage({ params }: EditProgramPageProps) {
const resolvedParams = await params
const program = await getProgram(resolvedParams.id)
if (!program) {
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={`/programs/${program.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Program
</Link>
</Button>
<h1 className="text-3xl font-bold text-white mb-2">Edit Program</h1>
<p className="text-neutral-400">
Update the details for &quot;{program.title}&quot;
</p>
</div>
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-6">
<ProgramForm initialData={program} mode="edit" />
</div>
</div>
)
}

View File

@@ -0,0 +1,292 @@
import { Metadata } from "next"
import Link from "next/link"
import { notFound } from "next/navigation"
import { ArrowLeft, Edit, Trash2, Clock, Flame, Dumbbell, Zap, Timer, Target } 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 ProgramDetailPageProps {
params: Promise<{
id: string
}>
}
const BODY_ZONE_COLORS: Record<string, string> = {
"upper-body": "bg-green-500/20 text-green-500",
"lower-body": "bg-purple-500/20 text-purple-500",
"full-body": "bg-orange-500/20 text-orange-500",
}
const LEVEL_COLORS: Record<string, string> = {
"Beginner": "bg-emerald-500/20 text-emerald-500",
"Intermediate": "bg-yellow-500/20 text-yellow-500",
"Advanced": "bg-red-500/20 text-red-500",
}
async function getProgram(id: string) {
const { data, error } = await (supabase.from("workout_programs") as any)
.select(`
*,
program_tabatas (*)
`)
.eq("id", id)
.single()
if (error || !data) {
return null
}
// Sort tabatas by position
if (data.program_tabatas) {
data.program_tabatas.sort((a: any, b: any) => a.position - b.position)
}
return data
}
export async function generateMetadata({ params }: ProgramDetailPageProps): Promise<Metadata> {
const resolvedParams = await params
const program = await getProgram(resolvedParams.id)
if (!program) {
return {
title: "Program Not Found | TabataFit Admin",
}
}
return {
title: `${program.title} | TabataFit Admin`,
}
}
export default async function ProgramDetailPage({ params }: ProgramDetailPageProps) {
const resolvedParams = await params
const program = await getProgram(resolvedParams.id)
if (!program) {
notFound()
}
const tabatas = program.program_tabatas || []
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="/programs">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Programs
</Link>
</Button>
<div className="flex items-center gap-3 mb-2">
{program.accent_color && (
<div
className="h-4 w-4 rounded-full flex-shrink-0"
style={{ backgroundColor: program.accent_color }}
/>
)}
<h1 className="text-3xl font-bold text-white">{program.title}</h1>
<Badge className={program.is_free ? "bg-emerald-500/20 text-emerald-500" : "bg-amber-500/20 text-amber-500"}>
{program.is_free ? "Free" : "Premium"}
</Badge>
</div>
<div className="flex items-center gap-2 text-neutral-400">
<Badge className={BODY_ZONE_COLORS[program.body_zone] || "bg-neutral-500/20 text-neutral-500"}>
{program.body_zone}
</Badge>
<span className="text-neutral-600">|</span>
<Badge className={LEVEL_COLORS[program.level] || "bg-neutral-500/20 text-neutral-500"}>
{program.level}
</Badge>
</div>
{program.description && (
<p className="text-neutral-400 mt-3 max-w-2xl">{program.description}</p>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" asChild className="border-neutral-700">
<Link href={`/programs/${program.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</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">{program.estimated_duration} min</p>
<p className="text-xs text-neutral-500">estimated total</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">{program.estimated_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">Tabatas</span>
</div>
<p className="text-2xl font-bold text-white">{tabatas.length}</p>
<p className="text-xs text-neutral-500">exercise pairs</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">
<Target className="h-4 w-4" />
<span className="text-sm">Sort Order</span>
</div>
<p className="text-2xl font-bold text-white">{program.sort_order}</p>
<p className="text-xs text-neutral-500">display position</p>
</CardContent>
</Card>
</div>
{/* Tabatas */}
<div className="space-y-6">
{tabatas.map((tabata: any) => (
<Card key={tabata.id} className="bg-neutral-900 border-neutral-800">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500/20 text-orange-500 font-bold text-sm">
{tabata.position}
</div>
<CardTitle className="text-white">Tabata {tabata.position}</CardTitle>
</div>
<div className="flex items-center gap-4 text-sm text-neutral-400">
<span className="flex items-center gap-1">
<Timer className="h-3.5 w-3.5" />
{tabata.rounds} rounds
</span>
<span className="flex items-center gap-1">
<Zap className="h-3.5 w-3.5" />
{tabata.work_time}s work
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{tabata.rest_time}s rest
</span>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
{/* Exercise 1 */}
<div className="rounded-lg border border-neutral-800 bg-neutral-950 p-4 space-y-3">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-orange-500/20 text-orange-500">
Exercise 1
</Badge>
</div>
<div>
<p className="text-white font-medium">{tabata.exercise_1_name}</p>
{tabata.exercise_1_name_en && (
<p className="text-sm text-neutral-500">{tabata.exercise_1_name_en}</p>
)}
</div>
{tabata.exercise_1_tip && (
<div>
<p className="text-xs text-neutral-500 mb-0.5">Tip</p>
<p className="text-sm text-neutral-300">{tabata.exercise_1_tip}</p>
{tabata.exercise_1_tip_en && (
<p className="text-xs text-neutral-500">{tabata.exercise_1_tip_en}</p>
)}
</div>
)}
{tabata.exercise_1_modification && (
<div>
<p className="text-xs text-neutral-500 mb-0.5">Modification</p>
<p className="text-sm text-neutral-300">{tabata.exercise_1_modification}</p>
{tabata.exercise_1_modification_en && (
<p className="text-xs text-neutral-500">{tabata.exercise_1_modification_en}</p>
)}
</div>
)}
{tabata.exercise_1_progression && (
<div>
<p className="text-xs text-neutral-500 mb-0.5">Progression</p>
<p className="text-sm text-neutral-300">{tabata.exercise_1_progression}</p>
{tabata.exercise_1_progression_en && (
<p className="text-xs text-neutral-500">{tabata.exercise_1_progression_en}</p>
)}
</div>
)}
</div>
{/* Exercise 2 */}
<div className="rounded-lg border border-neutral-800 bg-neutral-950 p-4 space-y-3">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-blue-500/20 text-blue-500">
Exercise 2
</Badge>
</div>
<div>
<p className="text-white font-medium">{tabata.exercise_2_name}</p>
{tabata.exercise_2_name_en && (
<p className="text-sm text-neutral-500">{tabata.exercise_2_name_en}</p>
)}
</div>
{tabata.exercise_2_tip && (
<div>
<p className="text-xs text-neutral-500 mb-0.5">Tip</p>
<p className="text-sm text-neutral-300">{tabata.exercise_2_tip}</p>
{tabata.exercise_2_tip_en && (
<p className="text-xs text-neutral-500">{tabata.exercise_2_tip_en}</p>
)}
</div>
)}
{tabata.exercise_2_modification && (
<div>
<p className="text-xs text-neutral-500 mb-0.5">Modification</p>
<p className="text-sm text-neutral-300">{tabata.exercise_2_modification}</p>
{tabata.exercise_2_modification_en && (
<p className="text-xs text-neutral-500">{tabata.exercise_2_modification_en}</p>
)}
</div>
)}
{tabata.exercise_2_progression && (
<div>
<p className="text-xs text-neutral-500 mb-0.5">Progression</p>
<p className="text-sm text-neutral-300">{tabata.exercise_2_progression}</p>
{tabata.exercise_2_progression_en && (
<p className="text-xs text-neutral-500">{tabata.exercise_2_progression_en}</p>
)}
</div>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 16, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
</claude-mem-context>

View 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 ProgramForm from "@/components/program-form"
export const metadata: Metadata = {
title: "New Program | TabataFit Admin",
description: "Create a new workout program",
}
export default function NewProgramPage() {
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="/programs">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Programs
</Link>
</Button>
<h1 className="text-3xl font-bold text-white mb-2">Create New Program</h1>
<p className="text-neutral-400">
Fill in the details below to create a new workout program with 3 tabatas
</p>
</div>
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-6">
<ProgramForm mode="create" />
</div>
</div>
)
}

View File

@@ -0,0 +1,318 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Select } from "@/components/ui/select";
import { Plus, Trash2, Edit, Loader2, Eye } from "lucide-react";
import { toast } from "sonner";
import type { Database } from "@/lib/supabase";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type WorkoutProgram = Database["public"]["Tables"]["workout_programs"]["Row"];
const BODY_ZONE_OPTIONS = [
{ value: "all", label: "All Zones" },
{ value: "upper-body", label: "Upper Body" },
{ value: "lower-body", label: "Lower Body" },
{ value: "full-body", label: "Full Body" },
]
const LEVEL_OPTIONS = [
{ value: "all", label: "All Levels" },
{ value: "Beginner", label: "Beginner" },
{ value: "Intermediate", label: "Intermediate" },
{ value: "Advanced", label: "Advanced" },
]
export default function ProgramsPage() {
const router = useRouter();
const [programs, setPrograms] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [programToDelete, setProgramToDelete] = useState<WorkoutProgram | null>(null);
const [filterZone, setFilterZone] = useState("all");
const [filterLevel, setFilterLevel] = useState("all");
useEffect(() => {
fetchPrograms();
}, []);
const fetchPrograms = async () => {
try {
const { data, error } = await (supabase.from("workout_programs") as any)
.select(`
*,
program_tabatas (id)
`)
.order("sort_order", { ascending: true });
if (error) throw error;
setPrograms(data || []);
} catch (error) {
console.error("Failed to fetch programs:", error);
} finally {
setLoading(false);
}
};
const openDeleteDialog = (program: WorkoutProgram) => {
setProgramToDelete(program);
setDeleteDialogOpen(true);
};
const handleDelete = async () => {
if (!programToDelete) return;
setDeletingId(programToDelete.id);
try {
// Delete tabatas first (foreign key constraint)
const { error: tabataError } = await (supabase.from("program_tabatas") as any)
.delete()
.eq("program_id", programToDelete.id);
if (tabataError) throw tabataError;
const { error } = await (supabase.from("workout_programs") as any)
.delete()
.eq("id", programToDelete.id);
if (error) throw error;
setPrograms(programs.filter((p) => p.id !== programToDelete.id));
toast.success("Program deleted", {
description: "The program has been removed successfully."
});
} catch (error) {
console.error("Failed to delete program:", error);
toast.error("Failed to delete program");
} finally {
setDeletingId(null);
setDeleteDialogOpen(false);
setProgramToDelete(null);
}
};
const getBodyZoneColor = (zone: string) => {
const colors: Record<string, string> = {
"upper-body": "bg-green-500/20 text-green-500",
"lower-body": "bg-purple-500/20 text-purple-500",
"full-body": "bg-orange-500/20 text-orange-500",
};
return colors[zone] || "bg-neutral-500/20 text-neutral-500";
};
const getLevelColor = (level: string) => {
const colors: Record<string, string> = {
"Beginner": "bg-emerald-500/20 text-emerald-500",
"Intermediate": "bg-yellow-500/20 text-yellow-500",
"Advanced": "bg-red-500/20 text-red-500",
};
return colors[level] || "bg-neutral-500/20 text-neutral-500";
};
const filteredPrograms = programs.filter((p) => {
if (filterZone !== "all" && p.body_zone !== filterZone) return false;
if (filterLevel !== "all" && p.level !== filterLevel) return false;
return true;
});
return (
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Programs</h1>
<p className="text-neutral-400">Manage your workout programs</p>
</div>
<Button className="bg-orange-500 hover:bg-orange-600" asChild>
<Link href="/programs/new">
<Plus className="w-4 h-4 mr-2" />
Add Program
</Link>
</Button>
</div>
{/* Filters */}
<div className="flex gap-4 mb-6">
<div className="w-48">
<Select
value={filterZone}
onValueChange={setFilterZone}
options={BODY_ZONE_OPTIONS}
placeholder="Filter by zone"
/>
</div>
<div className="w-48">
<Select
value={filterLevel}
onValueChange={setFilterLevel}
options={LEVEL_OPTIONS}
placeholder="Filter by level"
/>
</div>
</div>
<Card className="bg-neutral-900 border-neutral-800">
<CardContent className="p-0">
{loading ? (
<div className="p-8 flex justify-center">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
) : filteredPrograms.length === 0 ? (
<div className="p-8 text-center text-neutral-500">
{programs.length === 0
? "No programs yet. Create your first program to get started."
: "No programs match your filters."}
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-neutral-800">
<TableHead className="text-neutral-400">Title</TableHead>
<TableHead className="text-neutral-400">Body Zone</TableHead>
<TableHead className="text-neutral-400">Level</TableHead>
<TableHead className="text-neutral-400">Access</TableHead>
<TableHead className="text-neutral-400">Duration</TableHead>
<TableHead className="text-neutral-400">Calories</TableHead>
<TableHead className="text-neutral-400">Tabatas</TableHead>
<TableHead className="text-neutral-400 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPrograms.map((program) => (
<TableRow
key={program.id}
className="border-neutral-800 cursor-pointer hover:bg-neutral-800/50 transition-colors"
onClick={() => router.push(`/programs/${program.id}`)}
>
<TableCell className="text-white font-medium">
<div className="flex items-center gap-2">
{program.accent_color && (
<div
className="h-3 w-3 rounded-full flex-shrink-0"
style={{ backgroundColor: program.accent_color }}
/>
)}
{program.title}
</div>
</TableCell>
<TableCell>
<Badge className={getBodyZoneColor(program.body_zone)}>
{program.body_zone}
</Badge>
</TableCell>
<TableCell>
<Badge className={getLevelColor(program.level)}>
{program.level}
</Badge>
</TableCell>
<TableCell>
<Badge className={program.is_free ? "bg-emerald-500/20 text-emerald-500" : "bg-amber-500/20 text-amber-500"}>
{program.is_free ? "Free" : "Premium"}
</Badge>
</TableCell>
<TableCell className="text-neutral-300">{program.estimated_duration} min</TableCell>
<TableCell className="text-neutral-300">{program.estimated_calories}</TableCell>
<TableCell className="text-neutral-300">
{program.program_tabatas?.length || 0}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="text-neutral-400 hover:text-white"
asChild
>
<Link href={`/programs/${program.id}`}>
<Eye className="w-4 h-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="text-neutral-400 hover:text-white"
asChild
>
<Link href={`/programs/${program.id}/edit`}>
<Edit className="w-4 h-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="text-neutral-400 hover:text-red-500"
onClick={() => openDeleteDialog(program)}
disabled={deletingId === program.id}
>
{deletingId === program.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="bg-neutral-900 border-neutral-800 text-white">
<DialogHeader>
<DialogTitle>Delete Program</DialogTitle>
<DialogDescription className="text-neutral-400">
Are you sure you want to delete &quot;{programToDelete?.title}&quot;? This will also delete all associated tabatas. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
className="border-neutral-700 text-neutral-300 hover:bg-neutral-800"
>
Cancel
</Button>
<Button
onClick={handleDelete}
disabled={!!deletingId}
className="bg-red-500 hover:bg-red-600"
>
{deletingId ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,66 @@
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 TrainerForm from "@/components/trainer-form"
import { supabase } from "@/lib/supabase"
interface EditTrainerPageProps {
params: Promise<{
id: string
}>
}
async function getTrainer(id: string) {
const { data, error } = await (supabase.from("trainers") as any)
.select("*")
.eq("id", id)
.single()
if (error || !data) {
return null
}
return data
}
export async function generateMetadata({ params }: EditTrainerPageProps): Promise<Metadata> {
const resolvedParams = await params
const trainer = await getTrainer(resolvedParams.id)
if (!trainer) {
return {
title: "Trainer Not Found | TabataFit Admin",
}
}
return {
title: `Edit ${trainer.name} | TabataFit Admin`,
}
}
export default async function EditTrainerPage({ params }: EditTrainerPageProps) {
const resolvedParams = await params
const trainer = await getTrainer(resolvedParams.id)
if (!trainer) {
notFound()
}
return (
<div className="p-8 max-w-2xl">
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
<Link href="/trainers">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trainers
</Link>
</Button>
<h1 className="text-2xl font-bold text-white mb-6">Edit Trainer</h1>
<TrainerForm initialData={trainer} mode="edit" />
</div>
)
}

View File

@@ -0,0 +1,10 @@
import TrainerForm from "@/components/trainer-form"
export default function NewTrainerPage() {
return (
<div className="p-8 max-w-2xl">
<h1 className="text-2xl font-bold text-white mb-6">Add New Trainer</h1>
<TrainerForm mode="create" />
</div>
)
}

View File

@@ -16,6 +16,7 @@ import { Plus, Trash2, Edit, Loader2, Users, AlertCircle } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import type { Database } from "@/lib/supabase";
import { useRouter } from "next/navigation";
type Trainer = Database["public"]["Tables"]["trainers"]["Row"];
@@ -145,12 +146,21 @@ export default function TrainersPage() {
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold"
style={{ backgroundColor: trainer.color }}
>
{trainer.name[0]}
</div>
{trainer.avatar_url ? (
<img
src={trainer.avatar_url}
alt={trainer.name}
className="w-12 h-12 rounded-full object-cover border-2"
style={{ borderColor: trainer.color }}
/>
) : (
<div
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold"
style={{ backgroundColor: trainer.color }}
>
{trainer.name[0]}
</div>
)}
<div>
<h3 className="text-lg font-semibold text-white">{trainer.name}</h3>
<p className="text-neutral-400">{trainer.specialty}</p>
@@ -161,9 +171,11 @@ export default function TrainersPage() {
</div>
<div className="flex gap-2">
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
<Edit className="w-4 h-4" />
</Button>
<Link href={`/trainers/${trainer.id}/edit`}>
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
<Edit className="w-4 h-4" />
</Button>
</Link>
<Button
variant="ghost"
size="icon"

View File

@@ -14,8 +14,7 @@ interface EditWorkoutPageProps {
}
async function getWorkout(id: string) {
const { data, error } = await supabase
.from("workouts")
const { data, error } = await (supabase.from("workouts") as any)
.select("*")
.eq("id", id)
.single()

View File

@@ -0,0 +1,283 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter, useParams } from "next/navigation"
import Link from "next/link"
import { Loader2, Play, CheckCircle, AlertCircle, ArrowLeft, Video } from "lucide-react"
import { supabase } from "@/lib/supabase"
import { getWorkoutVideoStatus } from "@/lib/ai"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
type Workout = {
id: string
title: string
trainer_id: string
exercises: { name: string; duration: number }[]
video_url: string | null
}
type Trainer = {
id: string
name: string
avatar_url: string | null
specialty: string
}
export default function GenerateVideoPage() {
const router = useRouter()
const params = useParams()
const workoutId = params.id as string
const [workout, setWorkout] = useState<Workout | null>(null)
const [trainer, setTrainer] = useState<Trainer | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0)
const [status, setStatus] = useState<"idle" | "generating" | "done" | "error">("idle")
const [videoStatus, setVideoStatus] = useState<{
hasVideo: boolean
videoUrl: string | null
inProgram: boolean
videoGenerated: boolean
} | null>(null)
useEffect(() => {
if (workoutId) {
loadData(workoutId)
}
}, [workoutId])
const loadData = async (id: string) => {
setLoading(true)
const { data: w } = await (supabase.from("workouts") as any)
.select("*")
.eq("id", id)
.single()
if (!w) {
toast.error("Workout not found")
router.push("/workouts")
return
}
setWorkout(w)
if (w.trainer_id) {
const { data: t } = await (supabase.from("trainers") as any)
.select("*")
.eq("id", w.trainer_id)
.single()
setTrainer(t)
}
const videoStatus = await getWorkoutVideoStatus(id)
setVideoStatus(videoStatus)
setLoading(false)
}
const handleGenerate = async () => {
if (!workout || !trainer) return
setStatus("generating")
setGenerating(true)
setProgress(0)
try {
const progressInterval = setInterval(() => {
setProgress(p => Math.min(p + 5, 90))
}, 3000)
const response = await fetch('/api/ai/generate-video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workoutId: workout.id,
trainerId: trainer.id,
trainerAvatarUrl: trainer.avatar_url,
exercises: workout.exercises,
}),
})
clearInterval(progressInterval)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Generation failed')
}
const { videos, videoCount } = await response.json()
setProgress(100)
setVideoStatus(prev => prev ? { ...prev, hasVideo: true, videoUrl: videos[0]?.videoUrl } : null)
if (videoStatus?.inProgram) {
await (supabase.from("program_workouts") as any)
.update({ video_generated: true })
.eq("workout_id", workout.id)
}
setStatus("done")
toast.success(`Generated ${videoCount} exercise video(s)!`)
} catch (err) {
console.error("Generation failed:", err)
toast.error(err instanceof Error ? err.message : "Failed to generate video")
setStatus("error")
} finally {
setGenerating(false)
}
}
if (loading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
)
}
if (!workout) {
return (
<div className="p-8 text-center">
<p className="text-neutral-400">Workout not found</p>
</div>
)
}
const needsGeneration = !videoStatus?.hasVideo
return (
<div className="p-8 max-w-3xl">
<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-2xl font-bold text-white mb-6">Generate Workout Video</h1>
<Card className="bg-neutral-900 border-neutral-800">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-white">{workout.title}</CardTitle>
{videoStatus?.inProgram && (
<Badge variant="outline" className="border-amber-500 text-amber-500">
In Program
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center gap-4 p-4 rounded-lg bg-neutral-800">
{trainer?.avatar_url && (
<img
src={trainer.avatar_url}
alt={trainer.name}
className="w-16 h-16 rounded-full object-cover border-2 border-neutral-700"
/>
)}
<div>
<p className="font-medium text-white">{trainer?.name}</p>
<p className="text-sm text-neutral-400">{trainer?.specialty}</p>
</div>
</div>
{videoStatus?.inProgram && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertCircle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="text-sm">
<p className="text-amber-500 font-medium">Workout is in a program</p>
<p className="text-neutral-400">
The video_generated flag will be set to true after generation.
</p>
</div>
</div>
)}
<div>
<h3 className="font-medium text-white mb-3">Exercises</h3>
<div className="space-y-2">
{workout.exercises?.map((ex, i) => (
<div key={i} className="flex items-center gap-3 text-sm">
<span className="w-8 h-8 rounded-full bg-neutral-800 flex items-center justify-center text-neutral-400 text-xs">
{i + 1}
</span>
<span className="text-neutral-300">{ex.name}</span>
<span className="text-neutral-500 ml-auto">{ex.duration}s</span>
</div>
))}
</div>
</div>
{videoStatus?.hasVideo && videoStatus.videoUrl && !generating && (
<div>
<h3 className="font-medium text-white mb-3">Generated Video</h3>
<video
src={videoStatus.videoUrl}
controls
autoPlay
loop
className="w-full max-h-80 rounded-lg border border-neutral-700"
/>
<p className="text-xs text-neutral-500 mt-2">
Video loops seamlessly (8 seconds)
</p>
</div>
)}
{needsGeneration && (
<div className="pt-4 border-t border-neutral-800">
{status === "generating" ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-purple-500" />
<span className="text-white">Generating video with Veo 3.1...</span>
</div>
<div className="h-2 w-full rounded-full bg-neutral-800">
<div
className="h-full bg-purple-500 transition-all duration-1000"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center gap-2 text-sm text-neutral-400">
<Video className="w-4 h-4" />
<span>Creating 8-second looping video with {trainer?.name}</span>
</div>
<p className="text-xs text-neutral-500">
This may take up to 2 minutes. Please wait...
</p>
</div>
) : (
<Button
onClick={handleGenerate}
className="w-full bg-purple-500 hover:bg-purple-600"
>
<Play className="w-4 h-4" />
Generate 8s Looping Video
</Button>
)}
</div>
)}
{status === "done" && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle className="w-5 h-5 text-green-500" />
<span className="text-green-500 font-medium">Video generated and saved!</span>
</div>
)}
{status === "error" && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-5 h-5 text-red-500" />
<span className="text-red-500">Video generation failed. Check console for details.</span>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,7 +1,7 @@
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 { ArrowLeft, Edit, Trash2, Clock, Flame, Dumbbell, Music, Video } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -23,8 +23,7 @@ const CATEGORY_COLORS: Record<string, string> = {
}
async function getWorkout(id: string) {
const { data, error } = await supabase
.from("workouts")
const { data, error } = await (supabase.from("workouts") as any)
.select(`
*,
trainers (name, specialty, color)
@@ -95,6 +94,12 @@ export default async function WorkoutDetailPage({ params }: WorkoutDetailPagePro
</div>
<div className="flex gap-2">
<Button variant="outline" asChild className="border-neutral-700">
<Link href={`/workouts/${workout.id}/generate-video`}>
<Video className="mr-2 h-4 w-4" />
Generate Video
</Link>
</Button>
<Button variant="outline" asChild className="border-neutral-700">
<Link href={`/workouts/${workout.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />