workout generator audit: rules engine, structure rules, split patterns, injury UX, metadata cleanup
- Add rules_engine.py with quantitative rules for all 8 workout types - Add quality gate retry loop in generate_single_workout() - Expand calibrate_structure_rules to all 120 combinations (8 types × 5 goals × 3 sections) - Wire WeeklySplitPattern DB records into _pick_weekly_split() - Enforce movement patterns from WorkoutStructureRule in exercise selection - Add straight-set strength support (single main lift, 4-6 rounds) - Add modality consistency check for duration-dominant workout types - Add InjuryStep component to onboarding and preferences - Add sibling exercise exclusion in regenerate and preview_day endpoints - Display generator warnings on dashboard - Expand fix_rep_durations, fix_exercise_flags, fix_movement_pattern_typo - Add audit_exercise_data and check_rules_drift management commands - Add Next.js frontend with dashboard, onboarding, preferences, history pages - Add generator app with ML-powered workout generation pipeline - 96 new tests across 7 test modules Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
35
werkout-frontend/components/auth/AuthGuard.tsx
Normal file
35
werkout-frontend/components/auth/AuthGuard.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: AuthGuardProps) {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [loading, user, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
137
werkout-frontend/components/layout/BottomNav.tsx
Normal file
137
werkout-frontend/components/layout/BottomNav.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
href: "/dashboard",
|
||||
label: "Dashboard",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/plans",
|
||||
label: "Plans",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/preferences",
|
||||
label: "Prefs",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/rules",
|
||||
label: "Rules",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/history",
|
||||
label: "History",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function BottomNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 h-16 pb-safe bg-zinc-900 border-t border-zinc-800 md:hidden">
|
||||
<div className="flex items-center justify-around h-full">
|
||||
{tabs.map((tab) => {
|
||||
const isActive =
|
||||
pathname === tab.href || pathname.startsWith(tab.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`flex flex-col items-center gap-1 text-xs font-medium transition-colors duration-150 ${
|
||||
isActive ? "text-[#39FF14]" : "text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
73
werkout-frontend/components/layout/Navbar.tsx
Normal file
73
werkout-frontend/components/layout/Navbar.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/plans", label: "Plans" },
|
||||
{ href: "/rules", label: "Rules" },
|
||||
{ href: "/history", label: "History" },
|
||||
];
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 h-16 bg-zinc-900/80 backdrop-blur border-b border-zinc-800 px-6 flex items-center justify-between">
|
||||
<Link href="/dashboard" className="text-[#39FF14] font-bold text-xl tracking-tight">
|
||||
WERKOUT
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
{navLinks.map((link) => {
|
||||
const isActive =
|
||||
pathname === link.href || pathname.startsWith(link.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`text-sm font-medium transition-colors duration-150 ${
|
||||
isActive
|
||||
? "text-[#39FF14]"
|
||||
: "text-zinc-400 hover:text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{user && (
|
||||
<span className="text-sm text-zinc-300 hidden sm:inline">
|
||||
{user.first_name}
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
href="/preferences"
|
||||
className={`text-sm font-medium transition-colors duration-150 ${
|
||||
pathname === "/preferences"
|
||||
? "text-[#39FF14]"
|
||||
: "text-zinc-400 hover:text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
Preferences
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
59
werkout-frontend/components/onboarding/DurationStep.tsx
Normal file
59
werkout-frontend/components/onboarding/DurationStep.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { Slider } from "@/components/ui/Slider";
|
||||
|
||||
interface DurationStepProps {
|
||||
duration: number;
|
||||
onChange: (min: number) => void;
|
||||
}
|
||||
|
||||
export function DurationStep({ duration, onChange }: DurationStepProps) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Workout Duration
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-10">
|
||||
How long do you want each workout to be? This is the total time
|
||||
including warm-up, working sets, and rest periods.
|
||||
</p>
|
||||
|
||||
{/* Big centered display */}
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="text-7xl font-bold text-accent tabular-nums mb-2">
|
||||
{duration}
|
||||
</div>
|
||||
<div className="text-lg text-zinc-400">minutes</div>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div className="max-w-md mx-auto">
|
||||
<Slider
|
||||
min={20}
|
||||
max={90}
|
||||
value={duration}
|
||||
onChange={onChange}
|
||||
step={5}
|
||||
label="Duration"
|
||||
unit="min"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Duration guide */}
|
||||
<div className="mt-10 grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-300">Quick</div>
|
||||
<div className="text-xs text-zinc-500">20-30 min</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-300">Standard</div>
|
||||
<div className="text-xs text-zinc-500">40-60 min</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-300">Extended</div>
|
||||
<div className="text-xs text-zinc-500">70-90 min</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
werkout-frontend/components/onboarding/EquipmentStep.tsx
Normal file
102
werkout-frontend/components/onboarding/EquipmentStep.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import type { Equipment } from "@/lib/types";
|
||||
|
||||
interface EquipmentStepProps {
|
||||
selectedIds: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
export function EquipmentStep({ selectedIds, onChange }: EquipmentStepProps) {
|
||||
const [equipment, setEquipment] = useState<Equipment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
try {
|
||||
const data = await api.getEquipment();
|
||||
setEquipment(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch equipment:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetch();
|
||||
}, []);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter((i) => i !== id));
|
||||
} else {
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
// Group by category
|
||||
const grouped = equipment.reduce<Record<string, Equipment[]>>(
|
||||
(acc, item) => {
|
||||
const cat = item.category || "Other";
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(item);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
What equipment do you have?
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
Select all the equipment you have access to. This helps us build
|
||||
workouts tailored to your setup.
|
||||
</p>
|
||||
|
||||
{Object.entries(grouped).map(([category, items]) => (
|
||||
<div key={category} className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
{category}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{items.map((item) => {
|
||||
const isSelected = selectedIds.includes(item.id);
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
onClick={() => toggle(item.id)}
|
||||
className={`p-4 text-center transition-all duration-150 ${
|
||||
isSelected
|
||||
? "border-[#39FF14] bg-[rgba(57,255,20,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isSelected ? "text-accent" : "text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
werkout-frontend/components/onboarding/ExcludedExercisesStep.tsx
Normal file
167
werkout-frontend/components/onboarding/ExcludedExercisesStep.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import type { Exercise } from "@/lib/types";
|
||||
|
||||
interface ExcludedExercisesStepProps {
|
||||
selectedIds: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
export function ExcludedExercisesStep({
|
||||
selectedIds,
|
||||
onChange,
|
||||
}: ExcludedExercisesStepProps) {
|
||||
const [exercises, setExercises] = useState<Exercise[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchExercises() {
|
||||
try {
|
||||
const data = await api.getExercises();
|
||||
setExercises(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch exercises:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchExercises();
|
||||
}, []);
|
||||
|
||||
const excludedExercises = useMemo(
|
||||
() => exercises.filter((e) => selectedIds.includes(e.id)),
|
||||
[exercises, selectedIds]
|
||||
);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (search.length < 2) return [];
|
||||
const query = search.toLowerCase();
|
||||
return exercises.filter((e) =>
|
||||
e.name.toLowerCase().includes(query)
|
||||
);
|
||||
}, [exercises, search]);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter((i) => i !== id));
|
||||
} else {
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Exclude Exercises
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
Search for exercises you can't or won't do. They'll never
|
||||
appear in your generated workouts.
|
||||
</p>
|
||||
|
||||
{/* Excluded chips */}
|
||||
<div className="mb-4">
|
||||
{excludedExercises.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500 italic">
|
||||
No exercises excluded yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{excludedExercises.map((ex) => (
|
||||
<button
|
||||
key={ex.id}
|
||||
type="button"
|
||||
onClick={() => toggle(ex.id)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-red-500/15 text-red-400 border border-red-500/30 hover:bg-red-500/25 transition-colors"
|
||||
>
|
||||
{ex.name}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search exercises..."
|
||||
className="w-full px-4 py-3 rounded-xl bg-zinc-900 border border-zinc-700/50 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-zinc-500 transition-colors mb-4"
|
||||
/>
|
||||
|
||||
{/* Search results */}
|
||||
{search.length >= 2 && (
|
||||
<div className="max-h-80 overflow-y-auto rounded-xl border border-zinc-700/50">
|
||||
{searchResults.length === 0 ? (
|
||||
<p className="p-4 text-sm text-zinc-500">
|
||||
No exercises match "{search}"
|
||||
</p>
|
||||
) : (
|
||||
searchResults.map((ex) => {
|
||||
const isExcluded = selectedIds.includes(ex.id);
|
||||
return (
|
||||
<button
|
||||
key={ex.id}
|
||||
type="button"
|
||||
onClick={() => toggle(ex.id)}
|
||||
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-zinc-800 last:border-b-0 transition-colors ${
|
||||
isExcluded
|
||||
? "bg-red-500/10 hover:bg-red-500/15"
|
||||
: "bg-zinc-900 hover:bg-zinc-800/50"
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isExcluded ? "text-red-400" : "text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{ex.name}
|
||||
</span>
|
||||
{ex.muscle_groups && (
|
||||
<span className="ml-2 text-xs text-zinc-500">
|
||||
{ex.muscle_groups}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isExcluded && (
|
||||
<span className="text-xs text-red-400 font-medium">
|
||||
Excluded
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
werkout-frontend/components/onboarding/GoalsStep.tsx
Normal file
118
werkout-frontend/components/onboarding/GoalsStep.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { GOAL_LABELS, FITNESS_LEVEL_LABELS } from "@/lib/types";
|
||||
|
||||
interface GoalsStepProps {
|
||||
fitnessLevel: number;
|
||||
primaryGoal: string;
|
||||
secondaryGoal: string;
|
||||
onChange: (data: {
|
||||
fitness_level?: number;
|
||||
primary_goal?: string;
|
||||
secondary_goal?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const FITNESS_LEVEL_DESCRIPTIONS: Record<number, string> = {
|
||||
1: "New to structured training or returning after a long break.",
|
||||
2: "Consistent training for 6+ months with good form knowledge.",
|
||||
3: "Years of experience with complex programming and periodization.",
|
||||
4: "Competitive athlete or highly experienced lifter.",
|
||||
};
|
||||
|
||||
const goalOptions = Object.keys(GOAL_LABELS);
|
||||
|
||||
export function GoalsStep({
|
||||
fitnessLevel,
|
||||
primaryGoal,
|
||||
secondaryGoal,
|
||||
onChange,
|
||||
}: GoalsStepProps) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Your Fitness Profile
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-8">
|
||||
Tell us about your experience level and what you want to achieve.
|
||||
</p>
|
||||
|
||||
{/* Fitness Level */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
Fitness Level
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{Object.entries(FITNESS_LEVEL_LABELS).map(([key, label]) => {
|
||||
const level = Number(key);
|
||||
const isSelected = fitnessLevel === level;
|
||||
return (
|
||||
<Card
|
||||
key={level}
|
||||
onClick={() => onChange({ fitness_level: level })}
|
||||
className={`p-4 transition-all duration-150 ${
|
||||
isSelected
|
||||
? "border-[#39FF14] bg-[rgba(57,255,20,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={`text-base font-semibold ${
|
||||
isSelected ? "text-accent" : "text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm text-zinc-400">
|
||||
{FITNESS_LEVEL_DESCRIPTIONS[level]}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary Goal */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Primary Goal
|
||||
</label>
|
||||
<select
|
||||
value={primaryGoal}
|
||||
onChange={(e) => onChange({ primary_goal: e.target.value })}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-3 text-base focus:outline-none focus:border-accent transition-colors"
|
||||
>
|
||||
{goalOptions.map((goal) => (
|
||||
<option key={goal} value={goal}>
|
||||
{GOAL_LABELS[goal]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Secondary Goal */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Secondary Goal
|
||||
</label>
|
||||
<select
|
||||
value={secondaryGoal}
|
||||
onChange={(e) => onChange({ secondary_goal: e.target.value })}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-3 text-base focus:outline-none focus:border-accent transition-colors"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{goalOptions
|
||||
.filter((g) => g !== primaryGoal)
|
||||
.map((goal) => (
|
||||
<option key={goal} value={goal}>
|
||||
{GOAL_LABELS[goal]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
werkout-frontend/components/onboarding/InjuryStep.tsx
Normal file
150
werkout-frontend/components/onboarding/InjuryStep.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import type { InjuryType } from "@/lib/types";
|
||||
|
||||
interface InjuryStepProps {
|
||||
injuryTypes: InjuryType[];
|
||||
onChange: (injuries: InjuryType[]) => void;
|
||||
}
|
||||
|
||||
const INJURY_AREAS: { type: string; label: string; icon: string }[] = [
|
||||
{ type: "knee", label: "Knee", icon: "🦵" },
|
||||
{ type: "lower_back", label: "Lower Back", icon: "🔻" },
|
||||
{ type: "upper_back", label: "Upper Back", icon: "🔺" },
|
||||
{ type: "shoulder", label: "Shoulder", icon: "💪" },
|
||||
{ type: "hip", label: "Hip", icon: "🦴" },
|
||||
{ type: "wrist", label: "Wrist", icon: "✋" },
|
||||
{ type: "ankle", label: "Ankle", icon: "🦶" },
|
||||
{ type: "neck", label: "Neck", icon: "🧣" },
|
||||
];
|
||||
|
||||
const SEVERITY_OPTIONS: {
|
||||
value: "mild" | "moderate" | "severe";
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "mild",
|
||||
label: "Mild",
|
||||
color: "text-yellow-400",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
borderColor: "border-yellow-500/50",
|
||||
},
|
||||
{
|
||||
value: "moderate",
|
||||
label: "Moderate",
|
||||
color: "text-orange-400",
|
||||
bgColor: "bg-orange-500/10",
|
||||
borderColor: "border-orange-500/50",
|
||||
},
|
||||
{
|
||||
value: "severe",
|
||||
label: "Severe",
|
||||
color: "text-red-400",
|
||||
bgColor: "bg-red-500/10",
|
||||
borderColor: "border-red-500/50",
|
||||
},
|
||||
];
|
||||
|
||||
export function InjuryStep({ injuryTypes, onChange }: InjuryStepProps) {
|
||||
const getInjury = (type: string): InjuryType | undefined =>
|
||||
injuryTypes.find((i) => i.type === type);
|
||||
|
||||
const toggleInjury = (type: string) => {
|
||||
const existing = getInjury(type);
|
||||
if (existing) {
|
||||
onChange(injuryTypes.filter((i) => i.type !== type));
|
||||
} else {
|
||||
onChange([...injuryTypes, { type, severity: "moderate" }]);
|
||||
}
|
||||
};
|
||||
|
||||
const setSeverity = (type: string, severity: "mild" | "moderate" | "severe") => {
|
||||
onChange(
|
||||
injuryTypes.map((i) => (i.type === type ? { ...i, severity } : i))
|
||||
);
|
||||
};
|
||||
|
||||
const getSeverityStyle = (type: string) => {
|
||||
const injury = getInjury(type);
|
||||
if (!injury) return "";
|
||||
const opt = SEVERITY_OPTIONS.find((s) => s.value === injury.severity);
|
||||
return opt ? `${opt.borderColor} ${opt.bgColor}` : "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Any injuries or limitations?
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
Select any body areas with current injuries. We will adjust your
|
||||
workouts to avoid aggravating them. You can skip this step if you have
|
||||
no injuries.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{INJURY_AREAS.map((area) => {
|
||||
const injury = getInjury(area.type);
|
||||
const isSelected = !!injury;
|
||||
|
||||
return (
|
||||
<div key={area.type}>
|
||||
<Card
|
||||
onClick={() => toggleInjury(area.type)}
|
||||
className={`p-4 text-center transition-all duration-150 ${
|
||||
isSelected ? getSeverityStyle(area.type) : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-2xl">{area.icon}</span>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isSelected ? "text-zinc-100" : "text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{area.label}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{isSelected && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
{SEVERITY_OPTIONS.map((sev) => (
|
||||
<button
|
||||
key={sev.value}
|
||||
type="button"
|
||||
onClick={() => setSeverity(area.type, sev.value)}
|
||||
className={`flex-1 text-xs py-1.5 rounded-lg border transition-all duration-150 ${
|
||||
injury.severity === sev.value
|
||||
? `${sev.bgColor} ${sev.borderColor} ${sev.color} font-semibold`
|
||||
: "border-zinc-700 text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{sev.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{injuryTypes.length > 0 && (
|
||||
<div className="mt-6 p-4 rounded-lg bg-zinc-800/50 border border-zinc-700/50">
|
||||
<p className="text-sm text-zinc-400">
|
||||
<span className="font-medium text-zinc-300">
|
||||
{injuryTypes.length} area{injuryTypes.length !== 1 ? "s" : ""} selected.
|
||||
</span>{" "}
|
||||
Exercises that could aggravate these areas will be excluded or
|
||||
modified based on severity level.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
werkout-frontend/components/onboarding/MusclesStep.tsx
Normal file
98
werkout-frontend/components/onboarding/MusclesStep.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import type { Muscle } from "@/lib/types";
|
||||
|
||||
interface MusclesStepProps {
|
||||
selectedIds: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
export function MusclesStep({ selectedIds, onChange }: MusclesStepProps) {
|
||||
const [muscles, setMuscles] = useState<Muscle[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
try {
|
||||
const data = await api.getMuscles();
|
||||
setMuscles(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch muscles:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetch();
|
||||
}, []);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter((i) => i !== id));
|
||||
} else {
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Target Muscles
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
Select the muscle groups you want to focus on. Leave empty to target all
|
||||
muscle groups equally.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedIds.length === muscles.length) {
|
||||
onChange([]);
|
||||
} else {
|
||||
onChange(muscles.map((m) => m.id));
|
||||
}
|
||||
}}
|
||||
className="mb-4 text-sm font-medium text-accent hover:underline"
|
||||
>
|
||||
{selectedIds.length === muscles.length ? "Deselect All" : "Select All"}
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{muscles.map((muscle) => {
|
||||
const isSelected = selectedIds.includes(muscle.id);
|
||||
return (
|
||||
<Card
|
||||
key={muscle.id}
|
||||
onClick={() => toggle(muscle.id)}
|
||||
className={`p-4 text-center transition-all duration-150 ${
|
||||
isSelected
|
||||
? "border-[#39FF14] bg-[rgba(57,255,20,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isSelected ? "text-accent" : "text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{muscle.name}
|
||||
</span>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
werkout-frontend/components/onboarding/ScheduleStep.tsx
Normal file
97
werkout-frontend/components/onboarding/ScheduleStep.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { DAY_NAMES } from "@/lib/types";
|
||||
|
||||
interface ScheduleStepProps {
|
||||
daysPerWeek: number;
|
||||
preferredDays: number[];
|
||||
onChange: (data: {
|
||||
days_per_week?: number;
|
||||
preferred_days?: number[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const DAYS_OPTIONS = [3, 4, 5, 6];
|
||||
|
||||
export function ScheduleStep({
|
||||
daysPerWeek,
|
||||
preferredDays,
|
||||
onChange,
|
||||
}: ScheduleStepProps) {
|
||||
const toggleDay = (dayIndex: number) => {
|
||||
if (preferredDays.includes(dayIndex)) {
|
||||
onChange({
|
||||
preferred_days: preferredDays.filter((d) => d !== dayIndex),
|
||||
});
|
||||
} else {
|
||||
onChange({
|
||||
preferred_days: [...preferredDays, dayIndex],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Your Schedule
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-8">
|
||||
How often do you want to work out, and which days work best for you?
|
||||
</p>
|
||||
|
||||
{/* Days per week */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
Days Per Week
|
||||
</h3>
|
||||
<div className="flex gap-3">
|
||||
{DAYS_OPTIONS.map((num) => {
|
||||
const isSelected = daysPerWeek === num;
|
||||
return (
|
||||
<button
|
||||
key={num}
|
||||
onClick={() => onChange({ days_per_week: num })}
|
||||
className={`flex-1 py-3 rounded-lg text-lg font-bold transition-all duration-150 cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-accent text-black"
|
||||
: "bg-zinc-800 text-zinc-300 hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferred days */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
Preferred Days
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{DAY_NAMES.map((name, index) => {
|
||||
const isSelected = preferredDays.includes(index);
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => toggleDay(index)}
|
||||
className={`flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-150 cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-accent text-black"
|
||||
: "bg-zinc-800 text-zinc-300 hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 mt-3">
|
||||
Select the days you prefer to train. This helps us schedule rest days
|
||||
optimally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
werkout-frontend/components/onboarding/WorkoutTypesStep.tsx
Normal file
104
werkout-frontend/components/onboarding/WorkoutTypesStep.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import type { WorkoutType } from "@/lib/types";
|
||||
|
||||
interface WorkoutTypesStepProps {
|
||||
selectedIds: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
const intensityVariant: Record<string, "success" | "warning" | "error"> = {
|
||||
low: "success",
|
||||
medium: "warning",
|
||||
high: "error",
|
||||
};
|
||||
|
||||
export function WorkoutTypesStep({
|
||||
selectedIds,
|
||||
onChange,
|
||||
}: WorkoutTypesStepProps) {
|
||||
const [workoutTypes, setWorkoutTypes] = useState<WorkoutType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
try {
|
||||
const data = await api.getWorkoutTypes();
|
||||
setWorkoutTypes(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch workout types:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetch();
|
||||
}, []);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter((i) => i !== id));
|
||||
} else {
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Workout Types
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
Select the types of workouts you enjoy. We'll use these to build
|
||||
your weekly plans.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{workoutTypes.map((wt) => {
|
||||
const isSelected = selectedIds.includes(wt.id);
|
||||
return (
|
||||
<Card
|
||||
key={wt.id}
|
||||
onClick={() => toggle(wt.id)}
|
||||
className={`p-4 transition-all duration-150 ${
|
||||
isSelected
|
||||
? "border-[#39FF14] bg-[rgba(57,255,20,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<span
|
||||
className={`text-base font-semibold ${
|
||||
isSelected ? "text-accent" : "text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{wt.name}
|
||||
</span>
|
||||
<Badge variant={intensityVariant[wt.typical_intensity] || "default"}>
|
||||
{wt.typical_intensity}
|
||||
</Badge>
|
||||
</div>
|
||||
{wt.description && (
|
||||
<p className="text-sm text-zinc-400 line-clamp-2">
|
||||
{wt.description}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
700
werkout-frontend/components/plans/DayCard.tsx
Normal file
700
werkout-frontend/components/plans/DayCard.tsx
Normal file
@@ -0,0 +1,700 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { VideoPlayer } from "@/components/workout/VideoPlayer";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
GeneratedWorkout,
|
||||
WorkoutDetail,
|
||||
Exercise,
|
||||
PreviewDay,
|
||||
PreviewExercise,
|
||||
} from "@/lib/types";
|
||||
|
||||
interface DayCardProps {
|
||||
// Saved mode
|
||||
workout?: GeneratedWorkout;
|
||||
detail?: WorkoutDetail;
|
||||
onUpdate?: () => void;
|
||||
// Preview mode
|
||||
previewDay?: PreviewDay;
|
||||
previewDayIndex?: number;
|
||||
onPreviewDayChange?: (dayIndex: number, newDay: PreviewDay) => void;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.round(seconds / 60);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
function EditIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function mediaUrl(path: string): string {
|
||||
if (typeof window === "undefined") return path;
|
||||
return `${window.location.protocol}//${window.location.hostname}:8001${path}`;
|
||||
}
|
||||
|
||||
function PlayIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoModal({ src, title, onClose }: { src: string; title: string; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-xl p-4 w-full max-w-lg flex flex-col gap-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-100 truncate">{title}</h3>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-100 p-1">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
<VideoPlayer src={src} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Swap exercise modal — works for both preview and saved mode
|
||||
function SwapModal({
|
||||
exerciseId,
|
||||
currentName,
|
||||
onSwap,
|
||||
onClose,
|
||||
}: {
|
||||
exerciseId: number;
|
||||
currentName: string;
|
||||
onSwap: (newExercise: Exercise) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [alternatives, setAlternatives] = useState<Exercise[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getSimilarExercises(exerciseId)
|
||||
.then(setAlternatives)
|
||||
.catch((err) => console.error("Failed to load alternatives:", err))
|
||||
.finally(() => setLoading(false));
|
||||
}, [exerciseId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-xl p-4 w-full max-w-md max-h-[70vh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">
|
||||
Replace: {currentName}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-100 p-1">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="text-center text-zinc-400 text-sm py-8">Loading alternatives...</div>
|
||||
) : alternatives.length === 0 ? (
|
||||
<div className="text-center text-zinc-400 text-sm py-8">No alternatives found.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 overflow-y-auto">
|
||||
{alternatives.map((ex) => (
|
||||
<button
|
||||
key={ex.id}
|
||||
className="text-left px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors text-sm text-zinc-200"
|
||||
onClick={() => onSwap(ex)}
|
||||
>
|
||||
{ex.name}
|
||||
{ex.muscle_groups && (
|
||||
<span className="text-[10px] text-zinc-500 ml-2">
|
||||
{ex.muscle_groups}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DayCard({
|
||||
workout,
|
||||
detail: externalDetail,
|
||||
onUpdate,
|
||||
previewDay,
|
||||
previewDayIndex,
|
||||
onPreviewDayChange,
|
||||
}: DayCardProps) {
|
||||
const isPreview = !!previewDay;
|
||||
|
||||
// Saved mode state
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [fetchedDetail, setFetchedDetail] = useState<WorkoutDetail | null>(null);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
|
||||
// Video preview modal state
|
||||
const [videoPreview, setVideoPreview] = useState<{ src: string; title: string } | null>(null);
|
||||
|
||||
// Swap modal state
|
||||
const [swapTarget, setSwapTarget] = useState<{
|
||||
exerciseId: number;
|
||||
name: string;
|
||||
// For saved mode
|
||||
supersetExerciseId?: number;
|
||||
// For preview mode
|
||||
supersetIndex?: number;
|
||||
exerciseIndex?: number;
|
||||
} | null>(null);
|
||||
|
||||
// Saved mode: fetch detail if not provided
|
||||
useEffect(() => {
|
||||
if (isPreview) return;
|
||||
if (externalDetail || !workout?.workout || workout.is_rest_day) return;
|
||||
let cancelled = false;
|
||||
api.getWorkoutDetail(workout.workout).then((data) => {
|
||||
if (!cancelled) setFetchedDetail(data);
|
||||
}).catch((err) => {
|
||||
console.error(`[DayCard] Failed to fetch detail for workout ${workout.workout}:`, err);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isPreview, workout?.workout, workout?.is_rest_day, externalDetail]);
|
||||
|
||||
const detail = externalDetail || fetchedDetail;
|
||||
|
||||
// ============================================
|
||||
// Preview mode handlers
|
||||
// ============================================
|
||||
|
||||
const handlePreviewDeleteDay = () => {
|
||||
if (previewDayIndex === undefined || !previewDay || !onPreviewDayChange) return;
|
||||
onPreviewDayChange(previewDayIndex, {
|
||||
...previewDay,
|
||||
is_rest_day: true,
|
||||
focus_area: "Rest Day",
|
||||
target_muscles: [],
|
||||
workout_spec: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreviewDeleteSuperset = (ssIdx: number) => {
|
||||
if (previewDayIndex === undefined || !previewDay?.workout_spec || !onPreviewDayChange) return;
|
||||
const newSupersets = previewDay.workout_spec.supersets.filter((_, i) => i !== ssIdx);
|
||||
onPreviewDayChange(previewDayIndex, {
|
||||
...previewDay,
|
||||
workout_spec: { ...previewDay.workout_spec, supersets: newSupersets },
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreviewDeleteExercise = (ssIdx: number, exIdx: number) => {
|
||||
if (previewDayIndex === undefined || !previewDay?.workout_spec || !onPreviewDayChange) return;
|
||||
const newSupersets = previewDay.workout_spec.supersets.map((ss, i) => {
|
||||
if (i !== ssIdx) return ss;
|
||||
const newExercises = ss.exercises.filter((_, j) => j !== exIdx);
|
||||
return { ...ss, exercises: newExercises };
|
||||
}).filter((ss) => ss.exercises.length > 0);
|
||||
onPreviewDayChange(previewDayIndex, {
|
||||
...previewDay,
|
||||
workout_spec: { ...previewDay.workout_spec, supersets: newSupersets },
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreviewSwapExercise = (ssIdx: number, exIdx: number, ex: PreviewExercise) => {
|
||||
setSwapTarget({
|
||||
exerciseId: ex.exercise_id,
|
||||
name: ex.exercise_name,
|
||||
supersetIndex: ssIdx,
|
||||
exerciseIndex: exIdx,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreviewSwapConfirm = (newExercise: Exercise) => {
|
||||
if (!swapTarget || swapTarget.supersetIndex === undefined || swapTarget.exerciseIndex === undefined) return;
|
||||
if (previewDayIndex === undefined || !previewDay?.workout_spec || !onPreviewDayChange) return;
|
||||
|
||||
const ssIdx = swapTarget.supersetIndex;
|
||||
const exIdx = swapTarget.exerciseIndex;
|
||||
|
||||
const newSupersets = previewDay.workout_spec.supersets.map((ss, i) => {
|
||||
if (i !== ssIdx) return ss;
|
||||
const newExercises = ss.exercises.map((ex, j) => {
|
||||
if (j !== exIdx) return ex;
|
||||
return {
|
||||
...ex,
|
||||
exercise_id: newExercise.id,
|
||||
exercise_name: newExercise.name,
|
||||
muscle_groups: newExercise.muscle_groups || "",
|
||||
};
|
||||
});
|
||||
return { ...ss, exercises: newExercises };
|
||||
});
|
||||
|
||||
onPreviewDayChange(previewDayIndex, {
|
||||
...previewDay,
|
||||
workout_spec: { ...previewDay.workout_spec, supersets: newSupersets },
|
||||
});
|
||||
setSwapTarget(null);
|
||||
};
|
||||
|
||||
const handlePreviewRegenerate = async () => {
|
||||
if (previewDayIndex === undefined || !previewDay || !onPreviewDayChange) return;
|
||||
setRegenerating(true);
|
||||
try {
|
||||
const newDay = await api.previewDay({
|
||||
target_muscles: previewDay.target_muscles,
|
||||
focus_area: previewDay.focus_area,
|
||||
workout_type_id: previewDay.workout_type_id,
|
||||
date: previewDay.date,
|
||||
});
|
||||
onPreviewDayChange(previewDayIndex, newDay);
|
||||
} catch (err) {
|
||||
console.error("Failed to regenerate day:", err);
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Saved mode handlers
|
||||
// ============================================
|
||||
|
||||
const handleDeleteDay = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!confirm("Remove this workout day?")) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteWorkoutDay(workout!.id);
|
||||
onUpdate?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete day:", err);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSuperset = async (e: React.MouseEvent, supersetId: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.deleteSuperset(supersetId);
|
||||
onUpdate?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete superset:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteExercise = async (e: React.MouseEvent, seId: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.deleteSupersetExercise(seId);
|
||||
onUpdate?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete exercise:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavedSwapExercise = (seId: number, exerciseId: number, name: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSwapTarget({ supersetExerciseId: seId, exerciseId, name });
|
||||
};
|
||||
|
||||
const handleSavedSwapConfirm = async (newExercise: Exercise) => {
|
||||
if (!swapTarget?.supersetExerciseId) return;
|
||||
try {
|
||||
await api.swapExercise(swapTarget.supersetExerciseId, newExercise.id);
|
||||
setSwapTarget(null);
|
||||
onUpdate?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to swap exercise:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Render: Preview mode
|
||||
// ============================================
|
||||
|
||||
if (isPreview && previewDay) {
|
||||
if (previewDay.is_rest_day) return null;
|
||||
|
||||
const spec = previewDay.workout_spec;
|
||||
const typeName = previewDay.workout_type_name?.replace(/_/g, " ");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="p-4 flex flex-col gap-3 relative">
|
||||
{/* Top-right actions */}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 z-10">
|
||||
<button
|
||||
onClick={handlePreviewRegenerate}
|
||||
disabled={regenerating}
|
||||
className="p-1 rounded-md text-zinc-500 hover:text-[#39FF14] hover:bg-zinc-800 transition-colors"
|
||||
title="Regenerate this day"
|
||||
>
|
||||
<RefreshIcon className={regenerating ? "animate-spin" : ""} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreviewDeleteDay}
|
||||
className="p-1 rounded-md text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition-colors"
|
||||
title="Remove this day"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2 pr-14">
|
||||
<div className="flex-1 min-w-0">
|
||||
{typeName && (
|
||||
<Badge variant="accent" className="text-[10px] capitalize mb-1.5">
|
||||
{typeName}
|
||||
</Badge>
|
||||
)}
|
||||
{previewDay.focus_area && (
|
||||
<p className="text-sm font-semibold text-zinc-100 break-words">
|
||||
{previewDay.focus_area}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{spec?.estimated_time && (
|
||||
<span className="text-xs text-zinc-400 font-medium whitespace-nowrap mt-0.5">
|
||||
{formatTime(spec.estimated_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supersets */}
|
||||
{spec && spec.supersets.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{spec.supersets.map((superset, si) => (
|
||||
<div key={si} className="bg-zinc-800/50 rounded-lg px-3 py-2 group/superset">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
{superset.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-zinc-500">
|
||||
{superset.rounds}x
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePreviewDeleteSuperset(si)}
|
||||
className="p-0.5 rounded text-zinc-600 hover:text-red-400 opacity-0 group-hover/superset:opacity-100 transition-opacity"
|
||||
title="Delete superset"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{superset.exercises.map((ex, ei) => (
|
||||
<div
|
||||
key={ei}
|
||||
className="flex items-center justify-between gap-1 group/exercise"
|
||||
>
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<span className="text-xs text-zinc-200 break-words">
|
||||
{ex.exercise_name}
|
||||
</span>
|
||||
{ex.video_url && (
|
||||
<button
|
||||
onClick={() => setVideoPreview({ src: mediaUrl(ex.video_url!), title: ex.exercise_name })}
|
||||
className="flex-shrink-0 p-0.5 rounded text-zinc-500 hover:text-[#39FF14] transition-colors"
|
||||
title="Preview video"
|
||||
>
|
||||
<PlayIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-xs font-semibold text-[#39FF14] whitespace-nowrap">
|
||||
{ex.reps ? `${ex.reps} reps` : ex.duration ? `${ex.duration}s` : ""}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover/exercise:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handlePreviewSwapExercise(si, ei, ex)}
|
||||
className="p-0.5 rounded text-zinc-500 hover:text-[#39FF14]"
|
||||
title="Swap exercise"
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePreviewDeleteExercise(si, ei)}
|
||||
className="p-0.5 rounded text-zinc-500 hover:text-red-400"
|
||||
title="Delete exercise"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Muscle summary fallback */}
|
||||
{!spec && previewDay.target_muscles.length > 0 && (
|
||||
<p className="text-xs text-zinc-400 break-words">
|
||||
{previewDay.target_muscles.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{swapTarget && (
|
||||
<SwapModal
|
||||
exerciseId={swapTarget.exerciseId}
|
||||
currentName={swapTarget.name}
|
||||
onSwap={handlePreviewSwapConfirm}
|
||||
onClose={() => setSwapTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{videoPreview && (
|
||||
<VideoModal
|
||||
src={videoPreview.src}
|
||||
title={videoPreview.title}
|
||||
onClose={() => setVideoPreview(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Render: Saved mode
|
||||
// ============================================
|
||||
|
||||
if (!workout || workout.is_rest_day) return null;
|
||||
|
||||
const typeName = workout.workout_type_name?.replace(/_/g, " ");
|
||||
const sortedSupersets = detail
|
||||
? [...detail.supersets].sort((a, b) => a.order - b.order)
|
||||
: [];
|
||||
|
||||
const cardContent = (
|
||||
<Card className="p-4 flex flex-col gap-3 relative">
|
||||
<button
|
||||
onClick={handleDeleteDay}
|
||||
disabled={deleting}
|
||||
className="absolute top-3 right-3 p-1 rounded-md text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition-colors z-10"
|
||||
title="Remove this day"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start justify-between gap-2 pr-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
{typeName && (
|
||||
<Badge variant="accent" className="text-[10px] capitalize mb-1.5">
|
||||
{typeName}
|
||||
</Badge>
|
||||
)}
|
||||
{workout.focus_area && (
|
||||
<p className="text-sm font-semibold text-zinc-100 break-words">
|
||||
{workout.focus_area}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{detail?.estimated_time && (
|
||||
<span className="text-xs text-zinc-400 font-medium whitespace-nowrap mt-0.5">
|
||||
{formatTime(detail.estimated_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sortedSupersets.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{sortedSupersets.map((superset, si) => {
|
||||
const sortedExercises = [...superset.exercises].sort(
|
||||
(a, b) => a.order - b.order
|
||||
);
|
||||
return (
|
||||
<div key={superset.id ?? `s-${si}`} className="bg-zinc-800/50 rounded-lg px-3 py-2 group/superset">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
{superset.name || `Set ${superset.order}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-zinc-500">
|
||||
{superset.rounds}x
|
||||
</span>
|
||||
{superset.id && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteSuperset(e, superset.id)}
|
||||
className="p-0.5 rounded text-zinc-600 hover:text-red-400 opacity-0 group-hover/superset:opacity-100 transition-opacity"
|
||||
title="Delete superset"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{sortedExercises.map((se, ei) => (
|
||||
<div
|
||||
key={se.id ?? `e-${si}-${ei}`}
|
||||
className="flex items-center justify-between gap-1 group/exercise"
|
||||
>
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<span className="text-xs text-zinc-200 break-words">
|
||||
{se.exercise.name}
|
||||
</span>
|
||||
{se.exercise.video_url && (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setVideoPreview({ src: mediaUrl(se.exercise.video_url), title: se.exercise.name }); }}
|
||||
className="flex-shrink-0 p-0.5 rounded text-zinc-500 hover:text-[#39FF14] transition-colors"
|
||||
title="Preview video"
|
||||
>
|
||||
<PlayIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-xs font-semibold text-[#39FF14] whitespace-nowrap">
|
||||
{se.reps ? `${se.reps} reps` : se.duration ? `${se.duration}s` : ""}
|
||||
</span>
|
||||
{se.id && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover/exercise:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={handleSavedSwapExercise(se.id, se.exercise.id, se.exercise.name)}
|
||||
className="p-0.5 rounded text-zinc-500 hover:text-[#39FF14]"
|
||||
title="Swap exercise"
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDeleteExercise(e, se.id)}
|
||||
className="p-0.5 rounded text-zinc-500 hover:text-red-400"
|
||||
title="Delete exercise"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!detail && workout.target_muscles.length > 0 && (
|
||||
<p className="text-xs text-zinc-400 break-words">
|
||||
{workout.target_muscles.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Status badges for saved workouts */}
|
||||
{workout.status === "accepted" && (
|
||||
<div className="mt-auto pt-1">
|
||||
<Badge variant="success">Saved</Badge>
|
||||
</div>
|
||||
)}
|
||||
{workout.status === "completed" && (
|
||||
<div className="mt-auto pt-1">
|
||||
<Badge variant="accent">Completed</Badge>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const modal = swapTarget ? (
|
||||
<SwapModal
|
||||
exerciseId={swapTarget.exerciseId}
|
||||
currentName={swapTarget.name}
|
||||
onSwap={handleSavedSwapConfirm}
|
||||
onClose={() => setSwapTarget(null)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const videoModal = videoPreview ? (
|
||||
<VideoModal
|
||||
src={videoPreview.src}
|
||||
title={videoPreview.title}
|
||||
onClose={() => setVideoPreview(null)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (workout.workout) {
|
||||
return (
|
||||
<>
|
||||
<Link href={`/workout/${workout.workout}`} className="block">
|
||||
{cardContent}
|
||||
</Link>
|
||||
{modal}
|
||||
{videoModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{cardContent}
|
||||
{modal}
|
||||
{videoModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
werkout-frontend/components/plans/PlanCard.tsx
Normal file
50
werkout-frontend/components/plans/PlanCard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { GeneratedWeeklyPlan } from "@/lib/types";
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: GeneratedWeeklyPlan;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function getStatusVariant(
|
||||
status: string
|
||||
): "success" | "warning" | "error" | "default" {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "pending":
|
||||
return "warning";
|
||||
case "failed":
|
||||
return "error";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
export function PlanCard({ plan }: PlanCardProps) {
|
||||
const workoutDays = plan.generated_workouts.filter((w) => !w.is_rest_day);
|
||||
const dateRange = `${formatDate(plan.week_start_date)} - ${formatDate(plan.week_end_date)}`;
|
||||
|
||||
return (
|
||||
<Link href={`/plans/${plan.id}`} className="block">
|
||||
<Card className="p-4 hover:bg-zinc-800/50 transition-colors duration-150">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">{dateRange}</h3>
|
||||
<Badge variant={getStatusVariant(plan.status)}>
|
||||
{plan.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-zinc-400">
|
||||
<span>{workoutDays.length} workout{workoutDays.length !== 1 ? "s" : ""}</span>
|
||||
<span>{plan.generation_time_ms}ms</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
73
werkout-frontend/components/plans/WeekPicker.tsx
Normal file
73
werkout-frontend/components/plans/WeekPicker.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
interface WeekPickerProps {
|
||||
selectedMonday: string;
|
||||
onChange: (monday: string) => void;
|
||||
}
|
||||
|
||||
function getMondayDate(dateStr: string): Date {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function formatWeekLabel(dateStr: string): string {
|
||||
const date = getMondayDate(dateStr);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function ChevronLeft() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronRight() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeekPicker({ selectedMonday, onChange }: WeekPickerProps) {
|
||||
const shiftWeek = (offset: number) => {
|
||||
const date = getMondayDate(selectedMonday);
|
||||
date.setDate(date.getDate() + offset * 7);
|
||||
onChange(formatDate(date));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => shiftWeek(-1)}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors"
|
||||
aria-label="Previous week"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-zinc-200 min-w-[160px] text-center">
|
||||
Week of {formatWeekLabel(selectedMonday)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => shiftWeek(1)}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors"
|
||||
aria-label="Next week"
|
||||
>
|
||||
<ChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
werkout-frontend/components/plans/WeeklyPlanGrid.tsx
Normal file
184
werkout-frontend/components/plans/WeeklyPlanGrid.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { DAY_NAMES } from "@/lib/types";
|
||||
import type {
|
||||
GeneratedWeeklyPlan,
|
||||
WorkoutDetail,
|
||||
WeeklyPreview,
|
||||
PreviewDay,
|
||||
} from "@/lib/types";
|
||||
import { DayCard } from "@/components/plans/DayCard";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface WeeklyPlanGridProps {
|
||||
plan?: GeneratedWeeklyPlan;
|
||||
preview?: WeeklyPreview;
|
||||
onUpdate?: () => void;
|
||||
onPreviewChange?: (preview: WeeklyPreview) => void;
|
||||
}
|
||||
|
||||
export function WeeklyPlanGrid({
|
||||
plan,
|
||||
preview,
|
||||
onUpdate,
|
||||
onPreviewChange,
|
||||
}: WeeklyPlanGridProps) {
|
||||
const [workoutDetails, setWorkoutDetails] = useState<
|
||||
Record<number, WorkoutDetail>
|
||||
>({});
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Saved mode: fetch workout details
|
||||
const workoutIds =
|
||||
plan?.generated_workouts
|
||||
.filter((w) => w.workout && !w.is_rest_day)
|
||||
.map((w) => w.workout!) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!plan || workoutIds.length === 0) return;
|
||||
let cancelled = false;
|
||||
async function fetchDetails() {
|
||||
const results = await Promise.allSettled(
|
||||
workoutIds.map((id) => api.getWorkoutDetail(id))
|
||||
);
|
||||
if (cancelled) return;
|
||||
const details: Record<number, WorkoutDetail> = {};
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === "fulfilled") {
|
||||
details[workoutIds[i]] = result.value;
|
||||
}
|
||||
});
|
||||
setWorkoutDetails(details);
|
||||
}
|
||||
fetchDetails();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [plan?.id, refreshKey]);
|
||||
|
||||
const handleSavedUpdate = () => {
|
||||
setRefreshKey((k) => k + 1);
|
||||
onUpdate?.();
|
||||
};
|
||||
|
||||
const handlePreviewDayChange = (dayIndex: number, newDay: PreviewDay) => {
|
||||
if (!preview || !onPreviewChange) return;
|
||||
const newDays = [...preview.days];
|
||||
newDays[dayIndex] = newDay;
|
||||
onPreviewChange({ ...preview, days: newDays });
|
||||
};
|
||||
|
||||
// Determine items to render
|
||||
if (preview) {
|
||||
const trainingDays = preview.days
|
||||
.map((day, idx) => ({ day, idx }))
|
||||
.filter((d) => !d.day.is_rest_day);
|
||||
|
||||
const pairs: (typeof trainingDays)[] = [];
|
||||
for (let i = 0; i < trainingDays.length; i += 2) {
|
||||
pairs.push(trainingDays.slice(i, i + 2));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Desktop: two per row */}
|
||||
<div className="hidden md:flex flex-col gap-4">
|
||||
{pairs.map((pair, rowIdx) => (
|
||||
<div key={rowIdx} className="grid grid-cols-2 gap-4">
|
||||
{pair.map(({ day, idx }) => (
|
||||
<div key={idx}>
|
||||
<div className="text-center text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">
|
||||
{DAY_NAMES[day.day_of_week]}
|
||||
</div>
|
||||
<DayCard
|
||||
previewDay={day}
|
||||
previewDayIndex={idx}
|
||||
onPreviewDayChange={handlePreviewDayChange}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile stack */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{trainingDays.map(({ day, idx }) => (
|
||||
<div key={idx}>
|
||||
<div className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1">
|
||||
{DAY_NAMES[day.day_of_week]}
|
||||
</div>
|
||||
<DayCard
|
||||
previewDay={day}
|
||||
previewDayIndex={idx}
|
||||
onPreviewDayChange={handlePreviewDayChange}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Saved plan mode
|
||||
if (!plan) return null;
|
||||
|
||||
const sortedWorkouts = [...plan.generated_workouts].sort(
|
||||
(a, b) => a.day_of_week - b.day_of_week
|
||||
);
|
||||
const nonRestWorkouts = sortedWorkouts.filter((w) => !w.is_rest_day);
|
||||
const pairs: (typeof nonRestWorkouts)[] = [];
|
||||
for (let i = 0; i < nonRestWorkouts.length; i += 2) {
|
||||
pairs.push(nonRestWorkouts.slice(i, i + 2));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Desktop: two per row */}
|
||||
<div className="hidden md:flex flex-col gap-4">
|
||||
{pairs.map((pair, rowIdx) => (
|
||||
<div key={rowIdx} className="grid grid-cols-2 gap-4">
|
||||
{pair.map((workout) => (
|
||||
<div key={workout.id}>
|
||||
<div className="text-center text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">
|
||||
{DAY_NAMES[workout.day_of_week]}
|
||||
</div>
|
||||
<DayCard
|
||||
workout={workout}
|
||||
detail={
|
||||
workout.workout
|
||||
? workoutDetails[workout.workout]
|
||||
: undefined
|
||||
}
|
||||
onUpdate={handleSavedUpdate}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile stack */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{nonRestWorkouts.map((workout) => (
|
||||
<div key={workout.id}>
|
||||
<div className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1">
|
||||
{DAY_NAMES[workout.day_of_week]}
|
||||
</div>
|
||||
<DayCard
|
||||
workout={workout}
|
||||
detail={
|
||||
workout.workout
|
||||
? workoutDetails[workout.workout]
|
||||
: undefined
|
||||
}
|
||||
onUpdate={handleSavedUpdate}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
werkout-frontend/components/ui/Badge.tsx
Normal file
35
werkout-frontend/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type BadgeVariant = "default" | "success" | "warning" | "error" | "accent";
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: BadgeVariant;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
default: "bg-zinc-700 text-zinc-300",
|
||||
success: "bg-green-500/20 text-green-400",
|
||||
warning: "bg-amber-500/20 text-amber-400",
|
||||
error: "bg-red-500/20 text-red-400",
|
||||
accent: "bg-[rgba(57,255,20,0.1)] text-[#39FF14]",
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
variant = "default",
|
||||
children,
|
||||
className = "",
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
${variantClasses[variant]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
69
werkout-frontend/components/ui/Button.tsx
Normal file
69
werkout-frontend/components/ui/Button.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
type Variant = "primary" | "secondary" | "danger" | "ghost";
|
||||
type Size = "sm" | "md" | "lg";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
loading?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
primary:
|
||||
"bg-accent text-black font-bold hover:bg-accent-hover active:bg-accent-hover",
|
||||
secondary: "bg-zinc-700 text-zinc-100 hover:bg-zinc-600 active:bg-zinc-500",
|
||||
danger: "bg-red-500 text-white hover:bg-red-600 active:bg-red-700",
|
||||
ghost: "bg-transparent text-zinc-100 hover:bg-zinc-800 active:bg-zinc-700",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
sm: "px-3 py-1.5 text-sm rounded-lg",
|
||||
md: "px-5 py-2.5 text-base rounded-lg",
|
||||
lg: "px-7 py-3.5 text-lg rounded-xl",
|
||||
};
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
loading = false,
|
||||
disabled,
|
||||
className = "",
|
||||
children,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={isDisabled}
|
||||
className={`
|
||||
inline-flex items-center justify-center gap-2 font-medium
|
||||
transition-colors duration-150 ease-in-out
|
||||
${variantClasses[variant]}
|
||||
${sizeClasses[size]}
|
||||
${isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
|
||||
${className}
|
||||
`}
|
||||
{...rest}
|
||||
>
|
||||
{loading && <Spinner size="sm" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button };
|
||||
export type { ButtonProps };
|
||||
23
werkout-frontend/components/ui/Card.tsx
Normal file
23
werkout-frontend/components/ui/Card.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode, HTMLAttributes } from "react";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Card({ className = "", children, onClick, ...rest }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`
|
||||
bg-zinc-900 border border-zinc-700/50 rounded-xl
|
||||
${onClick ? "hover:bg-zinc-800/50 cursor-pointer transition-colors duration-150" : ""}
|
||||
${className}
|
||||
`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
werkout-frontend/components/ui/Slider.tsx
Normal file
50
werkout-frontend/components/ui/Slider.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
interface SliderProps {
|
||||
min: number;
|
||||
max: number;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
step?: number;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Slider({
|
||||
min,
|
||||
max,
|
||||
value,
|
||||
onChange,
|
||||
step = 1,
|
||||
label,
|
||||
unit,
|
||||
className = "",
|
||||
}: SliderProps) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 ${className}`}>
|
||||
{(label || unit) && (
|
||||
<div className="flex items-center justify-between">
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-zinc-300">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<span className="text-sm font-semibold text-accent tabular-nums">
|
||||
{value}
|
||||
{unit && <span className="ml-0.5 text-zinc-400">{unit}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="range-slider w-full h-2 rounded-full appearance-none cursor-pointer bg-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
werkout-frontend/components/ui/Spinner.tsx
Normal file
37
werkout-frontend/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
type SpinnerSize = "sm" | "md" | "lg";
|
||||
|
||||
interface SpinnerProps {
|
||||
size?: SpinnerSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizePx: Record<SpinnerSize, number> = {
|
||||
sm: 16,
|
||||
md: 24,
|
||||
lg: 40,
|
||||
};
|
||||
|
||||
const borderWidth: Record<SpinnerSize, number> = {
|
||||
sm: 2,
|
||||
md: 3,
|
||||
lg: 4,
|
||||
};
|
||||
|
||||
export function Spinner({ size = "md", className = "" }: SpinnerProps) {
|
||||
const px = sizePx[size];
|
||||
const bw = borderWidth[size];
|
||||
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={`inline-block animate-spin rounded-full ${className}`}
|
||||
style={{
|
||||
width: px,
|
||||
height: px,
|
||||
border: `${bw}px solid rgba(63,63,70,0.6)`,
|
||||
borderTopColor: "#39FF14",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
79
werkout-frontend/components/workout/ExerciseRow.tsx
Normal file
79
werkout-frontend/components/workout/ExerciseRow.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { SupersetExercise } from "@/lib/types";
|
||||
|
||||
function mediaUrl(path: string): string {
|
||||
if (typeof window === "undefined") return path;
|
||||
return `${window.location.protocol}//${window.location.hostname}:8001${path}`;
|
||||
}
|
||||
|
||||
interface ExerciseRowProps {
|
||||
exercise: SupersetExercise;
|
||||
}
|
||||
|
||||
export function ExerciseRow({ exercise }: ExerciseRowProps) {
|
||||
const ex = exercise.exercise;
|
||||
|
||||
const details: string[] = [];
|
||||
if (exercise.reps) {
|
||||
details.push(`${exercise.reps} reps`);
|
||||
}
|
||||
if (exercise.duration) {
|
||||
details.push(`${exercise.duration}s`);
|
||||
}
|
||||
if (exercise.weight) {
|
||||
details.push(`${exercise.weight} lbs`);
|
||||
}
|
||||
|
||||
const muscles = ex.muscles?.map((m) => m.name) || [];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800/50 last:border-b-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-zinc-100 truncate">
|
||||
{ex.name}
|
||||
</span>
|
||||
{ex.video_url && (
|
||||
<Link
|
||||
href={mediaUrl(ex.video_url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0 text-[#39FF14] hover:text-[#39FF14]/80 transition-colors"
|
||||
title="Watch video"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{details.length > 0 && (
|
||||
<p className="text-xs text-zinc-400 mt-0.5">{details.join(" / ")}</p>
|
||||
)}
|
||||
|
||||
{muscles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{muscles.map((name) => (
|
||||
<Badge
|
||||
key={name}
|
||||
variant="default"
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
werkout-frontend/components/workout/SupersetCard.tsx
Normal file
71
werkout-frontend/components/workout/SupersetCard.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { ExerciseRow } from "@/components/workout/ExerciseRow";
|
||||
import type { Superset } from "@/lib/types";
|
||||
|
||||
interface SupersetCardProps {
|
||||
superset: Superset;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number | null): string {
|
||||
if (!seconds) return "";
|
||||
const mins = Math.round(seconds / 60);
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
export function SupersetCard({ superset, defaultOpen = false }: SupersetCardProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
const sortedExercises = [...superset.exercises].sort(
|
||||
(a, b) => a.order - b.order
|
||||
);
|
||||
|
||||
const displayName = superset.name || `Superset ${superset.order}`;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-zinc-800/50 transition-colors duration-150"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">{displayName}</h3>
|
||||
<Badge variant="accent">{superset.rounds}x</Badge>
|
||||
{superset.estimated_time && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
{formatTime(superset.estimated_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`text-zinc-500 transition-transform duration-200 ${
|
||||
open ? "rotate-180" : ""
|
||||
}`}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-zinc-700/50">
|
||||
{sortedExercises.map((exercise) => (
|
||||
<ExerciseRow key={exercise.id} exercise={exercise} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
52
werkout-frontend/components/workout/VideoPlayer.tsx
Normal file
52
werkout-frontend/components/workout/VideoPlayer.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Hls from "hls.js";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ src, poster }: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const isHLS = src.endsWith(".m3u8");
|
||||
|
||||
if (isHLS) {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hlsRef.current = hls;
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(video);
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
// Native HLS support (Safari)
|
||||
video.src = src;
|
||||
}
|
||||
} else {
|
||||
video.src = src;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
poster={poster}
|
||||
controls
|
||||
playsInline
|
||||
className="w-full rounded-lg bg-zinc-900"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user