update config, admin-web tooling & relocate agent skills
Update app.json config and add new dependencies in package.json. Update .gitignore for new patterns. Add timed-exercise editor/list components, warmup/stretch video migration, and Supabase helpers in admin-web. Relocate agent skills from .agents/skills/ to .opencode/skills/.
This commit is contained in:
@@ -17,7 +17,9 @@ async function getProgram(id: string) {
|
||||
const { data, error } = await (supabase.from("workout_programs") as any)
|
||||
.select(`
|
||||
*,
|
||||
program_tabatas (*)
|
||||
program_tabatas (*),
|
||||
workout_warmup_exercises (*),
|
||||
workout_stretch_exercises (*)
|
||||
`)
|
||||
.eq("id", id)
|
||||
.single()
|
||||
@@ -30,6 +32,17 @@ async function getProgram(id: string) {
|
||||
if (data.program_tabatas) {
|
||||
data.program_tabatas.sort((a: any, b: any) => a.position - b.position)
|
||||
}
|
||||
if (data.workout_warmup_exercises) {
|
||||
data.workout_warmup_exercises.sort((a: any, b: any) => a.position - b.position)
|
||||
}
|
||||
if (data.workout_stretch_exercises) {
|
||||
data.workout_stretch_exercises.sort((a: any, b: any) => a.position - b.position)
|
||||
}
|
||||
|
||||
// Map to ProgramForm's expected shape
|
||||
data.tabatas = data.program_tabatas
|
||||
data.warmup = data.workout_warmup_exercises
|
||||
data.stretch = data.workout_stretch_exercises
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -14,14 +14,22 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import TabataEditor, { TabataData } from "@/components/tabata-editor"
|
||||
import { TimedExerciseList } from "@/components/timed-exercise-list"
|
||||
import { TimedExerciseData, createEmptyTimedExercise } from "@/components/timed-exercise-editor"
|
||||
import { toast } from "sonner"
|
||||
import type { Database } from "@/lib/supabase"
|
||||
|
||||
type WorkoutProgram = Database["public"]["Tables"]["workout_programs"]["Row"]
|
||||
type ProgramTabata = Database["public"]["Tables"]["program_tabatas"]["Row"]
|
||||
type WarmupRow = Database["public"]["Tables"]["workout_warmup_exercises"]["Row"]
|
||||
type StretchRow = Database["public"]["Tables"]["workout_stretch_exercises"]["Row"]
|
||||
|
||||
interface ProgramFormProps {
|
||||
initialData?: WorkoutProgram & { tabatas?: ProgramTabata[] }
|
||||
initialData?: WorkoutProgram & {
|
||||
tabatas?: ProgramTabata[]
|
||||
warmup?: WarmupRow[]
|
||||
stretch?: StretchRow[]
|
||||
}
|
||||
mode?: "create" | "edit"
|
||||
}
|
||||
|
||||
@@ -72,6 +80,7 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor
|
||||
exercise_1_modification_en: t.exercise_1_modification_en || "",
|
||||
exercise_1_progression: t.exercise_1_progression || "",
|
||||
exercise_1_progression_en: t.exercise_1_progression_en || "",
|
||||
exercise_1_video_url: t.exercise_1_video_url || "",
|
||||
exercise_2_name: t.exercise_2_name || "",
|
||||
exercise_2_name_en: t.exercise_2_name_en || "",
|
||||
exercise_2_tip: t.exercise_2_tip || "",
|
||||
@@ -80,18 +89,55 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor
|
||||
exercise_2_modification_en: t.exercise_2_modification_en || "",
|
||||
exercise_2_progression: t.exercise_2_progression || "",
|
||||
exercise_2_progression_en: t.exercise_2_progression_en || "",
|
||||
exercise_2_video_url: t.exercise_2_video_url || "",
|
||||
rounds: t.rounds || 8,
|
||||
work_time: t.work_time || 20,
|
||||
rest_time: t.rest_time || 10,
|
||||
}))
|
||||
}
|
||||
return [
|
||||
{ position: 1, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", rounds: 8, work_time: 20, rest_time: 10 },
|
||||
{ position: 2, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", rounds: 8, work_time: 20, rest_time: 10 },
|
||||
{ position: 3, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", rounds: 8, work_time: 20, rest_time: 10 },
|
||||
{ position: 1, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_1_video_url: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", exercise_2_video_url: "", rounds: 8, work_time: 20, rest_time: 10 },
|
||||
{ position: 2, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_1_video_url: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", exercise_2_video_url: "", rounds: 8, work_time: 20, rest_time: 10 },
|
||||
{ position: 3, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_1_video_url: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", exercise_2_video_url: "", rounds: 8, work_time: 20, rest_time: 10 },
|
||||
]
|
||||
})
|
||||
|
||||
// Warmup state
|
||||
const [warmup, setWarmup] = React.useState<TimedExerciseData[]>(() => {
|
||||
if (initialData?.warmup && initialData.warmup.length > 0) {
|
||||
return initialData.warmup
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((w, i) => ({
|
||||
position: i + 1,
|
||||
name: w.name || "",
|
||||
name_en: w.name_en || "",
|
||||
tip: w.tip || "",
|
||||
tip_en: w.tip_en || "",
|
||||
duration: w.duration || 30,
|
||||
video_url: w.video_url || "",
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Stretch state
|
||||
const [stretch, setStretch] = React.useState<TimedExerciseData[]>(() => {
|
||||
if (initialData?.stretch && initialData.stretch.length > 0) {
|
||||
return initialData.stretch
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((s, i) => ({
|
||||
position: i + 1,
|
||||
name: s.name || "",
|
||||
name_en: s.name_en || "",
|
||||
tip: s.tip || "",
|
||||
tip_en: s.tip_en || "",
|
||||
duration: s.duration || 30,
|
||||
video_url: s.video_url || "",
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
@@ -106,6 +152,16 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor
|
||||
}
|
||||
})
|
||||
|
||||
warmup.forEach((w, i) => {
|
||||
if (!w.name.trim()) newErrors[`warmup_${i}`] = `Warmup ${i + 1}: Name is required`
|
||||
if (!w.duration || w.duration < 1) newErrors[`warmup_${i}_dur`] = `Warmup ${i + 1}: Duration must be >= 1`
|
||||
})
|
||||
|
||||
stretch.forEach((s, i) => {
|
||||
if (!s.name.trim()) newErrors[`stretch_${i}`] = `Stretch ${i + 1}: Name is required`
|
||||
if (!s.duration || s.duration < 1) newErrors[`stretch_${i}_dur`] = `Stretch ${i + 1}: Duration must be >= 1`
|
||||
})
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
@@ -174,6 +230,7 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor
|
||||
exercise_1_modification_en: tabata.exercise_1_modification_en.trim() || null,
|
||||
exercise_1_progression: tabata.exercise_1_progression.trim() || null,
|
||||
exercise_1_progression_en: tabata.exercise_1_progression_en.trim() || null,
|
||||
exercise_1_video_url: tabata.exercise_1_video_url.trim() || null,
|
||||
exercise_2_name: tabata.exercise_2_name.trim(),
|
||||
exercise_2_name_en: tabata.exercise_2_name_en.trim() || null,
|
||||
exercise_2_tip: tabata.exercise_2_tip.trim() || null,
|
||||
@@ -182,6 +239,7 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor
|
||||
exercise_2_modification_en: tabata.exercise_2_modification_en.trim() || null,
|
||||
exercise_2_progression: tabata.exercise_2_progression.trim() || null,
|
||||
exercise_2_progression_en: tabata.exercise_2_progression_en.trim() || null,
|
||||
exercise_2_video_url: tabata.exercise_2_video_url.trim() || null,
|
||||
rounds: tabata.rounds,
|
||||
work_time: tabata.work_time,
|
||||
rest_time: tabata.rest_time,
|
||||
@@ -207,6 +265,54 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor
|
||||
}
|
||||
}
|
||||
|
||||
// Replace warmup exercises (delete all + insert)
|
||||
{
|
||||
const { error: delErr } = await (supabase.from("workout_warmup_exercises") as any)
|
||||
.delete()
|
||||
.eq("program_id", programId)
|
||||
if (delErr) throw delErr
|
||||
|
||||
if (warmup.length > 0) {
|
||||
const warmupPayload = warmup.map((w, i) => ({
|
||||
program_id: programId,
|
||||
position: i + 1,
|
||||
name: w.name.trim(),
|
||||
name_en: w.name_en.trim() || null,
|
||||
tip: w.tip.trim() || null,
|
||||
tip_en: w.tip_en.trim() || null,
|
||||
duration: w.duration,
|
||||
video_url: w.video_url.trim() || null,
|
||||
}))
|
||||
const { error: insErr } = await (supabase.from("workout_warmup_exercises") as any)
|
||||
.insert(warmupPayload)
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
}
|
||||
|
||||
// Replace stretch exercises (delete all + insert)
|
||||
{
|
||||
const { error: delErr } = await (supabase.from("workout_stretch_exercises") as any)
|
||||
.delete()
|
||||
.eq("program_id", programId)
|
||||
if (delErr) throw delErr
|
||||
|
||||
if (stretch.length > 0) {
|
||||
const stretchPayload = stretch.map((s, i) => ({
|
||||
program_id: programId,
|
||||
position: i + 1,
|
||||
name: s.name.trim(),
|
||||
name_en: s.name_en.trim() || null,
|
||||
tip: s.tip.trim() || null,
|
||||
tip_en: s.tip_en.trim() || null,
|
||||
duration: s.duration,
|
||||
video_url: s.video_url.trim() || null,
|
||||
}))
|
||||
const { error: insErr } = await (supabase.from("workout_stretch_exercises") as any)
|
||||
.insert(stretchPayload)
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(mode === "edit" ? "Program updated" : "Program created", {
|
||||
description: `"${title}" has been ${mode === "edit" ? "updated" : "created"} successfully.`
|
||||
})
|
||||
@@ -237,13 +343,19 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Tabs defaultValue="basics" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 bg-neutral-900">
|
||||
<TabsList className="grid w-full grid-cols-4 bg-neutral-900">
|
||||
<TabsTrigger value="basics" className="data-[state=active]:bg-neutral-800">
|
||||
Basics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="warmup" className="data-[state=active]:bg-neutral-800">
|
||||
Warmup
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tabatas" className="data-[state=active]:bg-neutral-800">
|
||||
Tabatas
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="stretch" className="data-[state=active]:bg-neutral-800">
|
||||
Stretch
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab 1: Basics */}
|
||||
@@ -365,7 +477,22 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 2: Tabatas */}
|
||||
{/* Tab 2: Warmup */}
|
||||
<TabsContent value="warmup" className="space-y-4">
|
||||
<TimedExerciseList
|
||||
title="Warmup exercises"
|
||||
description="Dynamic warmup sequence before the tabatas. Amber accent in the player."
|
||||
emptyLabel="No warmup exercises yet. Add the first one to get started."
|
||||
accentColor="text-amber-400"
|
||||
items={warmup}
|
||||
onChange={setWarmup}
|
||||
errors={Object.fromEntries(
|
||||
warmup.map((_, i) => [i, errors[`warmup_${i}`] || errors[`warmup_${i}_dur`] || ""]).filter(([, v]) => v)
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 3: Tabatas */}
|
||||
<TabsContent value="tabatas" className="space-y-4">
|
||||
<div className="space-y-1 mb-4">
|
||||
<p className="text-sm text-neutral-500">
|
||||
@@ -396,6 +523,21 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor
|
||||
)
|
||||
})}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 4: Stretch */}
|
||||
<TabsContent value="stretch" className="space-y-4">
|
||||
<TimedExerciseList
|
||||
title="Stretch exercises"
|
||||
description="Cool-down stretches after the tabatas. Lavender accent in the player."
|
||||
emptyLabel="No stretch exercises yet. Add the first one to get started."
|
||||
accentColor="text-violet-300"
|
||||
items={stretch}
|
||||
onChange={setStretch}
|
||||
errors={Object.fromEntries(
|
||||
stretch.map((_, i) => [i, errors[`stretch_${i}`] || errors[`stretch_${i}_dur`] || ""]).filter(([, v]) => v)
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ChevronDown, ChevronRight, Clock, Dumbbell } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { MediaUpload } from "@/components/media-upload"
|
||||
import type { Database } from "@/lib/supabase"
|
||||
|
||||
type ProgramTabata = Database["public"]["Tables"]["program_tabatas"]["Row"]
|
||||
@@ -20,6 +21,7 @@ interface TabataData {
|
||||
exercise_1_modification_en: string
|
||||
exercise_1_progression: string
|
||||
exercise_1_progression_en: string
|
||||
exercise_1_video_url: string
|
||||
exercise_2_name: string
|
||||
exercise_2_name_en: string
|
||||
exercise_2_tip: string
|
||||
@@ -28,6 +30,7 @@ interface TabataData {
|
||||
exercise_2_modification_en: string
|
||||
exercise_2_progression: string
|
||||
exercise_2_progression_en: string
|
||||
exercise_2_video_url: string
|
||||
rounds: number
|
||||
work_time: number
|
||||
rest_time: number
|
||||
@@ -52,6 +55,7 @@ function getDefaultTabata(position: number): TabataData {
|
||||
exercise_1_modification_en: "",
|
||||
exercise_1_progression: "",
|
||||
exercise_1_progression_en: "",
|
||||
exercise_1_video_url: "",
|
||||
exercise_2_name: "",
|
||||
exercise_2_name_en: "",
|
||||
exercise_2_tip: "",
|
||||
@@ -60,6 +64,7 @@ function getDefaultTabata(position: number): TabataData {
|
||||
exercise_2_modification_en: "",
|
||||
exercise_2_progression: "",
|
||||
exercise_2_progression_en: "",
|
||||
exercise_2_video_url: "",
|
||||
rounds: 8,
|
||||
work_time: 20,
|
||||
rest_time: 10,
|
||||
@@ -188,6 +193,16 @@ function ExerciseSection({ label, number, data, onChange, errors }: ExerciseSect
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background video */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Background Video</Label>
|
||||
<MediaUpload
|
||||
type="video"
|
||||
value={(data[`${prefix}_video_url` as keyof TabataData] as string) || undefined}
|
||||
onChange={(url) => onChange(`${prefix}_video_url`, url)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
177
admin-web/components/timed-exercise-editor.tsx
Normal file
177
admin-web/components/timed-exercise-editor.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ArrowDown, ArrowUp, Trash2, Clock } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { MediaUpload } from "@/components/media-upload"
|
||||
|
||||
export interface TimedExerciseData {
|
||||
position: number
|
||||
name: string
|
||||
name_en: string
|
||||
tip: string
|
||||
tip_en: string
|
||||
duration: number
|
||||
video_url: string
|
||||
}
|
||||
|
||||
interface TimedExerciseRowProps {
|
||||
data: TimedExerciseData
|
||||
index: number
|
||||
total: number
|
||||
onChange: (data: TimedExerciseData) => void
|
||||
onRemove: () => void
|
||||
onMoveUp: () => void
|
||||
onMoveDown: () => void
|
||||
accentColor?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function TimedExerciseRow({
|
||||
data,
|
||||
index,
|
||||
total,
|
||||
onChange,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
accentColor = "text-orange-500",
|
||||
error,
|
||||
}: TimedExerciseRowProps) {
|
||||
const update = <K extends keyof TimedExerciseData>(key: K, value: TimedExerciseData[K]) => {
|
||||
onChange({ ...data, [key]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-950 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-neutral-900 border-b border-neutral-800">
|
||||
<div className={cn("flex h-6 w-6 items-center justify-center rounded-full bg-neutral-800 text-xs font-semibold", accentColor)}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-white flex-1">
|
||||
{data.name || `Exercise ${index + 1}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-xs text-neutral-500 mr-2">
|
||||
<Clock className="h-3 w-3" />
|
||||
{data.duration}s
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMoveUp}
|
||||
disabled={index === 0}
|
||||
className="h-7 w-7 p-0 text-neutral-400 hover:text-white disabled:opacity-30"
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMoveDown}
|
||||
disabled={index === total - 1}
|
||||
className="h-7 w-7 p-0 text-neutral-400 hover:text-white disabled:opacity-30"
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
className="h-7 w-7 p-0 text-red-400 hover:bg-red-500/10 hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fields */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Name (FR) *</Label>
|
||||
<Input
|
||||
value={data.name}
|
||||
onChange={(e) => update("name", e.target.value)}
|
||||
placeholder="e.g., Jumping Jacks"
|
||||
className={cn("h-9 text-sm", error && "border-red-500")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Name (EN)</Label>
|
||||
<Input
|
||||
value={data.name_en}
|
||||
onChange={(e) => update("name_en", e.target.value)}
|
||||
placeholder="e.g., Jumping Jacks"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Tip (FR)</Label>
|
||||
<Input
|
||||
value={data.tip}
|
||||
onChange={(e) => update("tip", e.target.value)}
|
||||
placeholder="Conseil en français"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Tip (EN)</Label>
|
||||
<Input
|
||||
value={data.tip_en}
|
||||
onChange={(e) => update("tip_en", e.target.value)}
|
||||
placeholder="Tip in English"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Duration (seconds) *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.duration}
|
||||
onChange={(e) => update("duration", parseInt(e.target.value) || 0)}
|
||||
min={1}
|
||||
max={300}
|
||||
className="h-9 text-sm w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Background Video</Label>
|
||||
<MediaUpload
|
||||
type="video"
|
||||
value={data.video_url || undefined}
|
||||
onChange={(url) => update("video_url", url)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function createEmptyTimedExercise(position: number): TimedExerciseData {
|
||||
return {
|
||||
position,
|
||||
name: "",
|
||||
name_en: "",
|
||||
tip: "",
|
||||
tip_en: "",
|
||||
duration: 30,
|
||||
video_url: "",
|
||||
}
|
||||
}
|
||||
90
admin-web/components/timed-exercise-list.tsx
Normal file
90
admin-web/components/timed-exercise-list.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { TimedExerciseRow, TimedExerciseData, createEmptyTimedExercise } from "@/components/timed-exercise-editor"
|
||||
|
||||
interface TimedExerciseListProps {
|
||||
title: string
|
||||
description: string
|
||||
emptyLabel: string
|
||||
accentColor: string
|
||||
items: TimedExerciseData[]
|
||||
onChange: (items: TimedExerciseData[]) => void
|
||||
errors?: Record<number, string>
|
||||
}
|
||||
|
||||
export function TimedExerciseList({
|
||||
title,
|
||||
description,
|
||||
emptyLabel,
|
||||
accentColor,
|
||||
items,
|
||||
onChange,
|
||||
errors = {},
|
||||
}: TimedExerciseListProps) {
|
||||
const addItem = () => {
|
||||
onChange([...items, createEmptyTimedExercise(items.length + 1)])
|
||||
}
|
||||
|
||||
const updateItem = (index: number, data: TimedExerciseData) => {
|
||||
const next = [...items]
|
||||
next[index] = data
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
const next = items.filter((_, i) => i !== index).map((item, i) => ({ ...item, position: i + 1 }))
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= items.length) return
|
||||
const next = [...items]
|
||||
const [item] = next.splice(from, 1)
|
||||
next.splice(to, 0, item)
|
||||
onChange(next.map((item, i) => ({ ...item, position: i + 1 })))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium text-white">{title}</h3>
|
||||
<p className="text-sm text-neutral-500">{description}</p>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-neutral-800 bg-neutral-950/50 p-8 text-center">
|
||||
<p className="text-sm text-neutral-500 mb-4">{emptyLabel}</p>
|
||||
<Button type="button" onClick={addItem} variant="outline" className="border-neutral-700">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add exercise
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{items.map((item, i) => (
|
||||
<TimedExerciseRow
|
||||
key={i}
|
||||
data={item}
|
||||
index={i}
|
||||
total={items.length}
|
||||
accentColor={accentColor}
|
||||
onChange={(data) => updateItem(i, data)}
|
||||
onRemove={() => removeItem(i)}
|
||||
onMoveUp={() => move(i, i - 1)}
|
||||
onMoveDown={() => move(i, i + 1)}
|
||||
error={errors[i]}
|
||||
/>
|
||||
))}
|
||||
<Button type="button" onClick={addItem} variant="outline" className="w-full border-neutral-700 border-dashed">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add exercise
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -202,6 +202,8 @@ export interface Database {
|
||||
exercise_2_modification_en: string | null
|
||||
exercise_2_progression: string | null
|
||||
exercise_2_progression_en: string | null
|
||||
exercise_1_video_url: string | null
|
||||
exercise_2_video_url: string | null
|
||||
rounds: number
|
||||
work_time: number
|
||||
rest_time: number
|
||||
@@ -226,12 +228,66 @@ export interface Database {
|
||||
exercise_2_modification_en?: string | null
|
||||
exercise_2_progression?: string | null
|
||||
exercise_2_progression_en?: string | null
|
||||
exercise_1_video_url?: string | null
|
||||
exercise_2_video_url?: string | null
|
||||
rounds?: number
|
||||
work_time?: number
|
||||
rest_time?: number
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['program_tabatas']['Insert'], 'id' | 'program_id'>>
|
||||
}
|
||||
workout_warmup_exercises: {
|
||||
Row: {
|
||||
id: string
|
||||
program_id: string
|
||||
position: number
|
||||
name: string
|
||||
name_en: string | null
|
||||
tip: string | null
|
||||
tip_en: string | null
|
||||
duration: number
|
||||
video_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
program_id: string
|
||||
position: number
|
||||
name: string
|
||||
name_en?: string | null
|
||||
tip?: string | null
|
||||
tip_en?: string | null
|
||||
duration: number
|
||||
video_url?: string | null
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['workout_warmup_exercises']['Insert'], 'id' | 'program_id'>>
|
||||
}
|
||||
workout_stretch_exercises: {
|
||||
Row: {
|
||||
id: string
|
||||
program_id: string
|
||||
position: number
|
||||
name: string
|
||||
name_en: string | null
|
||||
tip: string | null
|
||||
tip_en: string | null
|
||||
duration: number
|
||||
video_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
program_id: string
|
||||
position: number
|
||||
name: string
|
||||
name_en?: string | null
|
||||
tip?: string | null
|
||||
tip_en?: string | null
|
||||
duration: number
|
||||
video_url?: string | null
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['workout_stretch_exercises']['Insert'], 'id' | 'program_id'>>
|
||||
}
|
||||
workout_videos: {
|
||||
Row: {
|
||||
id: string
|
||||
|
||||
71
admin-web/migrations/006_warmup_stretch_video.sql
Normal file
71
admin-web/migrations/006_warmup_stretch_video.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- Migration 006: Warmup, Stretch, and Exercise Video URLs
|
||||
-- Extends workout_programs with warmup and stretch blocks
|
||||
-- Adds video_url to tabata exercises for background playback during player
|
||||
|
||||
-- ─── Add video URLs to tabata exercises ─────────────────────
|
||||
|
||||
ALTER TABLE public.program_tabatas
|
||||
ADD COLUMN IF NOT EXISTS exercise_1_video_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS exercise_2_video_url TEXT;
|
||||
|
||||
-- ─── Warmup Exercises ───────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.workout_warmup_exercises (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
program_id UUID NOT NULL REFERENCES public.workout_programs(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
name_en TEXT,
|
||||
tip TEXT,
|
||||
tip_en TEXT,
|
||||
duration INTEGER NOT NULL, -- seconds
|
||||
video_url TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (program_id, position)
|
||||
);
|
||||
|
||||
-- ─── Stretch Exercises ──────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.workout_stretch_exercises (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
program_id UUID NOT NULL REFERENCES public.workout_programs(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
name_en TEXT,
|
||||
tip TEXT,
|
||||
tip_en TEXT,
|
||||
duration INTEGER NOT NULL, -- seconds
|
||||
video_url TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (program_id, position)
|
||||
);
|
||||
|
||||
-- ─── Indexes ────────────────────────────────────────────────
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_warmup_program_position
|
||||
ON public.workout_warmup_exercises (program_id, position);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stretch_program_position
|
||||
ON public.workout_stretch_exercises (program_id, position);
|
||||
|
||||
-- ─── Row Level Security ─────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.workout_warmup_exercises ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.workout_stretch_exercises ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Public read workout_warmup_exercises"
|
||||
ON public.workout_warmup_exercises FOR SELECT USING (true);
|
||||
|
||||
CREATE POLICY "Public read workout_stretch_exercises"
|
||||
ON public.workout_stretch_exercises FOR SELECT USING (true);
|
||||
|
||||
-- Admin write (service_role bypass RLS, authenticated users controlled elsewhere)
|
||||
CREATE POLICY "Admin write workout_warmup_exercises"
|
||||
ON public.workout_warmup_exercises FOR ALL
|
||||
USING (auth.role() = 'service_role')
|
||||
WITH CHECK (auth.role() = 'service_role');
|
||||
|
||||
CREATE POLICY "Admin write workout_stretch_exercises"
|
||||
ON public.workout_stretch_exercises FOR ALL
|
||||
USING (auth.role() = 'service_role')
|
||||
WITH CHECK (auth.role() = 'service_role');
|
||||
Reference in New Issue
Block a user