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:
Millian Lamiaux
2026-04-21 21:51:11 +02:00
parent d4edf54aeb
commit 8c90b73d90
42 changed files with 980 additions and 20 deletions

View File

@@ -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 */}

View File

@@ -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>

View 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: "",
}
}

View 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>
)
}