- Add Next.js rewrites to proxy API calls through same origin (fixes login/media on werkout.treytartt.com) - Fix mediaUrl() in DayCard and ExerciseRow to use relative paths in production - Add proxyTimeout for long-running workout generation endpoints - Add CSRF trusted origin for treytartt.com - Split docker-compose into production (Unraid) and dev configs - Show display_name and descriptions on workout type cards - Generator: rules engine improvements, movement enforcement, exercise selector updates - Add new test files for rules drift, workout research generation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
716 lines
25 KiB
TypeScript
716 lines
25 KiB
TypeScript
"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;
|
|
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
|
|
return `${window.location.protocol}//${window.location.hostname}:8001${path}`;
|
|
}
|
|
return 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,
|
|
plan_id: previewDay.plan_id,
|
|
});
|
|
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>
|
|
|
|
{previewDay.warnings && previewDay.warnings.length > 0 && (
|
|
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-2 text-xs text-yellow-200">
|
|
<p className="font-semibold mb-1">Warnings</p>
|
|
<ul className="list-disc list-inside space-y-0.5">
|
|
{previewDay.warnings.map((w, idx) => (
|
|
<li key={idx}>{w}</li>
|
|
))}
|
|
</ul>
|
|
</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}
|
|
</>
|
|
);
|
|
}
|