1012 lines
38 KiB
TypeScript
1012 lines
38 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import Link from 'next/link';
|
|
import {
|
|
Upload,
|
|
X,
|
|
AlertTriangle,
|
|
Check,
|
|
Plus,
|
|
Trash2,
|
|
Loader,
|
|
Image,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
} from 'lucide-react';
|
|
import { Exercise } from '@prisma/client';
|
|
import {
|
|
ParsedWorkout,
|
|
ParsedExercise,
|
|
ParsedSet,
|
|
ImportParseResponse,
|
|
ReviewedWorkout,
|
|
} from '@/types';
|
|
|
|
interface WorkoutImportClientProps {
|
|
hasApiKey: boolean;
|
|
exercises: Exercise[];
|
|
}
|
|
|
|
type Phase = 'upload' | 'review' | 'save';
|
|
|
|
interface ImageFile {
|
|
id: string;
|
|
file: File;
|
|
preview: string;
|
|
}
|
|
|
|
interface EditableSet extends ParsedSet {
|
|
id: string;
|
|
}
|
|
|
|
interface EditableExercise extends ParsedExercise {
|
|
id: string;
|
|
sets: EditableSet[];
|
|
}
|
|
|
|
interface EditableWorkout extends ParsedWorkout {
|
|
id: string;
|
|
exercises: EditableExercise[];
|
|
overallConfidence?: string;
|
|
warnings?: string[];
|
|
}
|
|
|
|
const WorkoutImportClient: React.FC<WorkoutImportClientProps> = ({
|
|
hasApiKey,
|
|
exercises: _exercises,
|
|
}) => {
|
|
const [phase, setPhase] = useState<Phase>('upload');
|
|
const [images, setImages] = useState<ImageFile[]>([]);
|
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
|
const [editableWorkouts, setEditableWorkouts] = useState<EditableWorkout[]>([]);
|
|
const [expandedWorkouts, setExpandedWorkouts] = useState<Set<string>>(new Set());
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [_saveProgress, setSaveProgress] = useState<{
|
|
current: number;
|
|
total: number;
|
|
} | null>(null);
|
|
const [saveSuccess, setSuccess] = useState<{ ids: string[] } | null>(null);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
// File upload handlers
|
|
const fileToBase64 = (file: File): Promise<string> => {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result as string);
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const files = Array.from(e.dataTransfer.files).filter((f) =>
|
|
f.type.startsWith('image/')
|
|
);
|
|
addImages(files);
|
|
};
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.currentTarget.files || []);
|
|
addImages(files);
|
|
};
|
|
|
|
const addImages = (files: File[]) => {
|
|
const newImages: ImageFile[] = files.map((file) => ({
|
|
id: Math.random().toString(36).slice(2),
|
|
file,
|
|
preview: URL.createObjectURL(file),
|
|
}));
|
|
setImages((prev) => [...prev, ...newImages]);
|
|
};
|
|
|
|
const removeImage = (id: string) => {
|
|
setImages((prev) => {
|
|
const img = prev.find((i) => i.id === id);
|
|
if (img) URL.revokeObjectURL(img.preview);
|
|
return prev.filter((i) => i.id !== id);
|
|
});
|
|
};
|
|
|
|
const handleAnalyze = async () => {
|
|
setIsAnalyzing(true);
|
|
setErrorMessage(null);
|
|
try {
|
|
const base64Images = await Promise.all(images.map((img) => fileToBase64(img.file)));
|
|
|
|
const response = await fetch('/api/workouts/import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ images: base64Images }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Analysis failed: ${response.statusText}`);
|
|
}
|
|
|
|
const data: ImportParseResponse = await response.json();
|
|
|
|
// Convert to editable format with unique IDs
|
|
const editable: EditableWorkout[] = data.workouts.map((w) => ({
|
|
...w,
|
|
id: Math.random().toString(36).slice(2),
|
|
date: w.date || new Date().toISOString().split('T')[0],
|
|
exercises: w.exercises.map((e) => ({
|
|
...e,
|
|
id: Math.random().toString(36).slice(2),
|
|
sets: e.sets.map((s) => ({
|
|
...s,
|
|
id: Math.random().toString(36).slice(2),
|
|
})),
|
|
})),
|
|
}));
|
|
|
|
setEditableWorkouts(editable);
|
|
setExpandedWorkouts(new Set(editable.map((w) => w.id)));
|
|
setPhase('review');
|
|
} catch (error) {
|
|
setErrorMessage(error instanceof Error ? error.message : 'Unknown error');
|
|
} finally {
|
|
setIsAnalyzing(false);
|
|
}
|
|
};
|
|
|
|
const toggleWorkoutExpanded = (id: string) => {
|
|
setExpandedWorkouts((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const updateWorkoutField = (
|
|
workoutId: string,
|
|
field: keyof EditableWorkout,
|
|
value: unknown
|
|
) => {
|
|
setEditableWorkouts((prev) =>
|
|
prev.map((w) =>
|
|
w.id === workoutId ? { ...w, [field]: value } : w
|
|
)
|
|
);
|
|
};
|
|
|
|
const updateExerciseField = (
|
|
workoutId: string,
|
|
exerciseId: string,
|
|
field: keyof EditableExercise,
|
|
value: unknown
|
|
) => {
|
|
setEditableWorkouts((prev) =>
|
|
prev.map((w) =>
|
|
w.id === workoutId
|
|
? {
|
|
...w,
|
|
exercises: w.exercises.map((e) =>
|
|
e.id === exerciseId ? { ...e, [field]: value } : e
|
|
),
|
|
}
|
|
: w
|
|
)
|
|
);
|
|
};
|
|
|
|
const updateSetField = (
|
|
workoutId: string,
|
|
exerciseId: string,
|
|
setId: string,
|
|
field: keyof EditableSet,
|
|
value: unknown
|
|
) => {
|
|
setEditableWorkouts((prev) =>
|
|
prev.map((w) =>
|
|
w.id === workoutId
|
|
? {
|
|
...w,
|
|
exercises: w.exercises.map((e) =>
|
|
e.id === exerciseId
|
|
? {
|
|
...e,
|
|
sets: e.sets.map((s) =>
|
|
s.id === setId ? { ...s, [field]: value } : s
|
|
),
|
|
}
|
|
: e
|
|
),
|
|
}
|
|
: w
|
|
)
|
|
);
|
|
};
|
|
|
|
const addSet = (workoutId: string, exerciseId: string) => {
|
|
const newSet: EditableSet = {
|
|
id: Math.random().toString(36).slice(2),
|
|
reps: null,
|
|
weight: null,
|
|
weightUnit: 'lb',
|
|
durationSeconds: null,
|
|
distance: null,
|
|
distanceUnit: 'mi',
|
|
calories: null,
|
|
rpe: null,
|
|
notes: null,
|
|
};
|
|
|
|
setEditableWorkouts((prev) =>
|
|
prev.map((w) =>
|
|
w.id === workoutId
|
|
? {
|
|
...w,
|
|
exercises: w.exercises.map((e) =>
|
|
e.id === exerciseId
|
|
? { ...e, sets: [...e.sets, newSet] }
|
|
: e
|
|
),
|
|
}
|
|
: w
|
|
)
|
|
);
|
|
};
|
|
|
|
const removeSet = (workoutId: string, exerciseId: string, setId: string) => {
|
|
setEditableWorkouts((prev) =>
|
|
prev.map((w) =>
|
|
w.id === workoutId
|
|
? {
|
|
...w,
|
|
exercises: w.exercises.map((e) =>
|
|
e.id === exerciseId
|
|
? { ...e, sets: e.sets.filter((s) => s.id !== setId) }
|
|
: e
|
|
),
|
|
}
|
|
: w
|
|
)
|
|
);
|
|
};
|
|
|
|
const addExercise = (workoutId: string) => {
|
|
const newExercise: EditableExercise = {
|
|
id: Math.random().toString(36).slice(2),
|
|
name: 'New Exercise',
|
|
uncertain: false,
|
|
uncertainReason: undefined,
|
|
sets: [
|
|
{
|
|
id: Math.random().toString(36).slice(2),
|
|
reps: null,
|
|
weight: null,
|
|
weightUnit: 'lb',
|
|
durationSeconds: null,
|
|
distance: null,
|
|
distanceUnit: 'mi',
|
|
calories: null,
|
|
rpe: null,
|
|
notes: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
setEditableWorkouts((prev) =>
|
|
prev.map((w) =>
|
|
w.id === workoutId
|
|
? { ...w, exercises: [...w.exercises, newExercise] }
|
|
: w
|
|
)
|
|
);
|
|
};
|
|
|
|
const removeExercise = (workoutId: string, exerciseId: string) => {
|
|
setEditableWorkouts((prev) =>
|
|
prev.map((w) =>
|
|
w.id === workoutId
|
|
? { ...w, exercises: w.exercises.filter((e) => e.id !== exerciseId) }
|
|
: w
|
|
)
|
|
);
|
|
};
|
|
|
|
const removeWorkout = (workoutId: string) => {
|
|
setEditableWorkouts((prev) => prev.filter((w) => w.id !== workoutId));
|
|
};
|
|
|
|
const handleSaveAll = async () => {
|
|
setIsSaving(true);
|
|
setSaveProgress({ current: 0, total: editableWorkouts.length });
|
|
setErrorMessage(null);
|
|
|
|
const createdIds: string[] = [];
|
|
|
|
try {
|
|
for (let i = 0; i < editableWorkouts.length; i++) {
|
|
const workout = editableWorkouts[i];
|
|
setSaveProgress({ current: i + 1, total: editableWorkouts.length });
|
|
|
|
// Prepare reviewed data (remove internal IDs)
|
|
const reviewedWorkout: ReviewedWorkout = {
|
|
date: workout.date,
|
|
name: workout.name,
|
|
notes: workout.notes,
|
|
exercises: workout.exercises.map((e) => ({
|
|
name: e.name,
|
|
sets: e.sets.map((s) => ({
|
|
reps: s.reps,
|
|
weight: s.weight,
|
|
weightUnit: s.weightUnit,
|
|
durationSeconds: s.durationSeconds,
|
|
distance: s.distance,
|
|
distanceUnit: s.distanceUnit,
|
|
calories: s.calories,
|
|
rpe: s.rpe,
|
|
notes: s.notes,
|
|
})),
|
|
})),
|
|
};
|
|
|
|
const response = await fetch('/api/workouts/import/save', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(reviewedWorkout),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to save workout: ${response.statusText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
if (result.id) {
|
|
createdIds.push(result.id);
|
|
}
|
|
}
|
|
|
|
setSuccess({ ids: createdIds });
|
|
setEditableWorkouts([]);
|
|
setPhase('save');
|
|
} catch (error) {
|
|
setErrorMessage(error instanceof Error ? error.message : 'Unknown error');
|
|
} finally {
|
|
setIsSaving(false);
|
|
setSaveProgress(null);
|
|
}
|
|
};
|
|
|
|
const resetToUpload = () => {
|
|
setPhase('upload');
|
|
setImages([]);
|
|
setEditableWorkouts([]);
|
|
setExpandedWorkouts(new Set());
|
|
setSuccess(null);
|
|
setErrorMessage(null);
|
|
};
|
|
|
|
// Phase 1: Upload
|
|
if (phase === 'upload') {
|
|
if (!hasApiKey) {
|
|
return (
|
|
<div className="min-h-screen bg-[#0A0A0A] p-4 md:p-8">
|
|
<div className="mx-auto max-w-2xl">
|
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-8">
|
|
<div className="flex items-start gap-4">
|
|
<AlertTriangle className="h-6 w-6 flex-shrink-0 text-amber-500 mt-1" />
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-white mb-2">
|
|
Claude API Key Required
|
|
</h3>
|
|
<p className="text-zinc-400 mb-4">
|
|
Configure your Claude API key in Settings to use import.
|
|
</p>
|
|
<Link
|
|
href="/main/settings"
|
|
className="inline-block px-4 py-2 bg-white text-black font-medium rounded-lg hover:bg-zinc-100 transition-colors"
|
|
>
|
|
Go to Settings
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#0A0A0A] p-4 md:p-8">
|
|
<div className="mx-auto max-w-2xl">
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-white mb-2">Import Workouts</h1>
|
|
<p className="text-zinc-400">
|
|
Upload images of your workout to have Claude automatically parse them.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
{/* Drag and Drop Zone */}
|
|
<div
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
className="rounded-lg border-2 border-dashed border-zinc-700 bg-zinc-900/50 p-8 text-center transition-colors hover:border-zinc-500"
|
|
>
|
|
<Image className="mx-auto h-12 w-12 text-zinc-500 mb-4" />
|
|
<h3 className="text-lg font-semibold text-white mb-2">
|
|
Drop your workout images here
|
|
</h3>
|
|
<p className="text-zinc-400 mb-6">
|
|
or use the button below to select files
|
|
</p>
|
|
<label>
|
|
<input
|
|
type="file"
|
|
multiple
|
|
accept="image/*"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
/>
|
|
<span className="inline-block px-6 py-2 bg-white text-black font-medium rounded-lg hover:bg-zinc-100 transition-colors cursor-pointer">
|
|
Select Images
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Image Previews */}
|
|
{images.length > 0 && (
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-white mb-4">
|
|
Selected Images ({images.length})
|
|
</h3>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
{images.map((img) => (
|
|
<div
|
|
key={img.id}
|
|
className="relative group rounded-lg overflow-hidden bg-zinc-800"
|
|
>
|
|
<img
|
|
src={img.preview}
|
|
alt="Preview"
|
|
className="w-full h-32 object-cover"
|
|
/>
|
|
<button
|
|
onClick={() => removeImage(img.id)}
|
|
className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
|
>
|
|
<X className="h-6 w-6 text-white" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error Message */}
|
|
{errorMessage && (
|
|
<div className="rounded-lg border border-red-800 bg-red-900/20 p-4">
|
|
<p className="text-red-400">{errorMessage}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Analyze Button */}
|
|
<button
|
|
onClick={handleAnalyze}
|
|
disabled={images.length === 0 || isAnalyzing}
|
|
className="w-full py-3 bg-white text-black font-semibold rounded-lg hover:bg-zinc-100 transition-colors disabled:bg-zinc-700 disabled:text-zinc-400 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
{isAnalyzing ? (
|
|
<>
|
|
<Loader className="h-5 w-5 animate-spin" />
|
|
Analyzing Images...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="h-5 w-5" />
|
|
Analyze ({images.length})
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Phase 2: Review & Edit
|
|
if (phase === 'review') {
|
|
return (
|
|
<div className="min-h-screen bg-[#0A0A0A] p-4 md:p-8">
|
|
<div className="mx-auto max-w-4xl">
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-white mb-2">Review Workouts</h1>
|
|
<p className="text-zinc-400">
|
|
Verify and edit the parsed workout data before saving.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{errorMessage && (
|
|
<div className="mb-6 rounded-lg border border-red-800 bg-red-900/20 p-4">
|
|
<p className="text-red-400">{errorMessage}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Workouts */}
|
|
<div className="space-y-4 mb-8">
|
|
{editableWorkouts.map((workout) => {
|
|
const isExpanded = expandedWorkouts.has(workout.id);
|
|
const confidenceColor =
|
|
workout.overallConfidence === 'high'
|
|
? 'bg-green-900/20 border-green-800'
|
|
: workout.overallConfidence === 'medium'
|
|
? 'bg-amber-900/20 border-amber-800'
|
|
: 'bg-red-900/20 border-red-800';
|
|
|
|
return (
|
|
<div
|
|
key={workout.id}
|
|
className="rounded-lg border border-zinc-800 bg-zinc-900 overflow-hidden"
|
|
>
|
|
{/* Workout Header */}
|
|
<div className="border-b border-zinc-800 p-4">
|
|
<button
|
|
onClick={() => toggleWorkoutExpanded(workout.id)}
|
|
className="w-full flex items-center justify-between text-left"
|
|
>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h3 className="text-lg font-semibold text-white">
|
|
{workout.name || 'Unnamed Workout'}
|
|
</h3>
|
|
{workout.overallConfidence !== 'high' && (
|
|
<span
|
|
className={`text-xs px-2 py-1 rounded border ${confidenceColor}`}
|
|
>
|
|
{workout.overallConfidence?.toUpperCase()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-zinc-400">{workout.date}</p>
|
|
</div>
|
|
{isExpanded ? (
|
|
<ChevronUp className="h-5 w-5 text-zinc-400" />
|
|
) : (
|
|
<ChevronDown className="h-5 w-5 text-zinc-400" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Expanded Content */}
|
|
{isExpanded && (
|
|
<div className="p-4 space-y-6 border-t border-zinc-800">
|
|
{/* Workout Fields */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-white mb-1">
|
|
Date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={workout.date}
|
|
onChange={(e) =>
|
|
updateWorkoutField(workout.id, 'date', e.target.value)
|
|
}
|
|
className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-600"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-white mb-1">
|
|
Workout Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={workout.name || ''}
|
|
onChange={(e) =>
|
|
updateWorkoutField(workout.id, 'name', e.target.value)
|
|
}
|
|
placeholder="e.g., Upper Body Day"
|
|
className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-600"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Warnings */}
|
|
{workout.warnings && workout.warnings.length > 0 && (
|
|
<div className="rounded-lg border border-amber-800 bg-amber-900/20 p-4">
|
|
<p className="text-sm font-medium text-amber-400 mb-2">
|
|
Warnings:
|
|
</p>
|
|
<ul className="space-y-1">
|
|
{workout.warnings.map((warning, idx) => (
|
|
<li key={idx} className="text-sm text-amber-300">
|
|
• {warning}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* Exercises */}
|
|
<div className="space-y-4">
|
|
{workout.exercises.map((exercise) => (
|
|
<div
|
|
key={exercise.id}
|
|
className="rounded-lg border border-zinc-700 bg-zinc-800/50 p-4"
|
|
>
|
|
{/* Exercise Header */}
|
|
<div className="flex items-start justify-between gap-4 mb-4">
|
|
<div className="flex-1">
|
|
<label className="block text-sm font-medium text-white mb-1">
|
|
Exercise Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={exercise.name || ''}
|
|
onChange={(e) =>
|
|
updateExerciseField(
|
|
workout.id,
|
|
exercise.id,
|
|
'name',
|
|
e.target.value
|
|
)
|
|
}
|
|
className="w-full px-3 py-2 bg-zinc-700 border border-zinc-600 rounded text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-500"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() =>
|
|
removeExercise(workout.id, exercise.id)
|
|
}
|
|
className="mt-6 p-2 text-red-400 hover:bg-red-900/20 rounded transition-colors"
|
|
>
|
|
<Trash2 className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Uncertain Warning */}
|
|
{exercise.uncertain && (
|
|
<div className="mb-4 rounded-lg border border-amber-800 bg-amber-900/20 p-3 flex items-start gap-2">
|
|
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
|
<p className="text-sm text-amber-300">
|
|
{exercise.uncertainReason ||
|
|
'Please verify this exercise name'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sets */}
|
|
<div className="space-y-3 mb-4">
|
|
{exercise.sets.map((set, setIdx) => (
|
|
<div
|
|
key={set.id}
|
|
className="bg-zinc-700/50 p-3 rounded border border-zinc-600"
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs font-medium text-zinc-400">
|
|
Set {setIdx + 1}
|
|
</span>
|
|
<button
|
|
onClick={() =>
|
|
removeSet(workout.id, exercise.id, set.id)
|
|
}
|
|
className="p-1 text-red-400 hover:bg-red-900/20 rounded"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
|
|
{/* Reps */}
|
|
<div>
|
|
<label className="block text-xs text-zinc-400 mb-1">
|
|
Reps
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={set.reps ?? ''}
|
|
onChange={(e) =>
|
|
updateSetField(
|
|
workout.id,
|
|
exercise.id,
|
|
set.id,
|
|
'reps',
|
|
e.target.value ? parseInt(e.target.value) : null
|
|
)
|
|
}
|
|
className="w-full px-2 py-1 bg-zinc-600 border border-zinc-500 rounded text-white text-sm placeholder-zinc-400 focus:outline-none focus:border-zinc-400"
|
|
/>
|
|
</div>
|
|
|
|
{/* Weight */}
|
|
<div>
|
|
<label className="block text-xs text-zinc-400 mb-1">
|
|
Weight ({set.weightUnit})
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.5"
|
|
value={set.weight ?? ''}
|
|
onChange={(e) =>
|
|
updateSetField(
|
|
workout.id,
|
|
exercise.id,
|
|
set.id,
|
|
'weight',
|
|
e.target.value ? parseFloat(e.target.value) : null
|
|
)
|
|
}
|
|
className="w-full px-2 py-1 bg-zinc-600 border border-zinc-500 rounded text-white text-sm placeholder-zinc-400 focus:outline-none focus:border-zinc-400"
|
|
/>
|
|
</div>
|
|
|
|
{/* Duration */}
|
|
<div>
|
|
<label className="block text-xs text-zinc-400 mb-1">
|
|
Time (mm:ss)
|
|
</label>
|
|
<DurationInput
|
|
value={set.durationSeconds ?? null}
|
|
onChange={(seconds) =>
|
|
updateSetField(
|
|
workout.id,
|
|
exercise.id,
|
|
set.id,
|
|
'durationSeconds',
|
|
seconds
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Distance */}
|
|
<div>
|
|
<label className="block text-xs text-zinc-400 mb-1">
|
|
Distance
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
value={set.distance ?? ''}
|
|
onChange={(e) =>
|
|
updateSetField(
|
|
workout.id,
|
|
exercise.id,
|
|
set.id,
|
|
'distance',
|
|
e.target.value ? parseFloat(e.target.value) : null
|
|
)
|
|
}
|
|
className="w-full px-2 py-1 bg-zinc-600 border border-zinc-500 rounded text-white text-sm placeholder-zinc-400 focus:outline-none focus:border-zinc-400"
|
|
/>
|
|
</div>
|
|
|
|
{/* RPE */}
|
|
<div>
|
|
<label className="block text-xs text-zinc-400 mb-1">
|
|
RPE
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="10"
|
|
value={set.rpe ?? ''}
|
|
onChange={(e) =>
|
|
updateSetField(
|
|
workout.id,
|
|
exercise.id,
|
|
set.id,
|
|
'rpe',
|
|
e.target.value ? parseInt(e.target.value) : null
|
|
)
|
|
}
|
|
className="w-full px-2 py-1 bg-zinc-600 border border-zinc-500 rounded text-white text-sm placeholder-zinc-400 focus:outline-none focus:border-zinc-400"
|
|
/>
|
|
</div>
|
|
|
|
{/* Calories */}
|
|
<div className="col-span-2 md:col-span-1 lg:col-span-1">
|
|
<label className="block text-xs text-zinc-400 mb-1">
|
|
Calories
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={set.calories ?? ''}
|
|
onChange={(e) =>
|
|
updateSetField(
|
|
workout.id,
|
|
exercise.id,
|
|
set.id,
|
|
'calories',
|
|
e.target.value ? parseInt(e.target.value) : null
|
|
)
|
|
}
|
|
className="w-full px-2 py-1 bg-zinc-600 border border-zinc-500 rounded text-white text-sm placeholder-zinc-400 focus:outline-none focus:border-zinc-400"
|
|
/>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div className="col-span-2 md:col-span-3 lg:col-span-2">
|
|
<label className="block text-xs text-zinc-400 mb-1">
|
|
Notes
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={set.notes ?? ''}
|
|
onChange={(e) =>
|
|
updateSetField(
|
|
workout.id,
|
|
exercise.id,
|
|
set.id,
|
|
'notes',
|
|
e.target.value || null
|
|
)
|
|
}
|
|
className="w-full px-2 py-1 bg-zinc-600 border border-zinc-500 rounded text-white text-sm placeholder-zinc-400 focus:outline-none focus:border-zinc-400"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Add Set Button */}
|
|
<button
|
|
onClick={() => addSet(workout.id, exercise.id)}
|
|
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Set
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Add Exercise Button */}
|
|
<button
|
|
onClick={() => addExercise(workout.id)}
|
|
className="flex items-center gap-2 text-white font-medium px-4 py-2 rounded-lg border border-zinc-700 hover:bg-zinc-800 transition-colors"
|
|
>
|
|
<Plus className="h-5 w-5" />
|
|
Add Exercise
|
|
</button>
|
|
|
|
{/* Remove Workout Button */}
|
|
<button
|
|
onClick={() => removeWorkout(workout.id)}
|
|
className="w-full flex items-center justify-center gap-2 text-red-400 hover:bg-red-900/20 px-4 py-2 rounded-lg border border-red-800 transition-colors"
|
|
>
|
|
<Trash2 className="h-5 w-5" />
|
|
Remove Workout
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex gap-4">
|
|
<button
|
|
onClick={resetToUpload}
|
|
className="flex-1 py-3 px-4 border border-zinc-700 text-white font-semibold rounded-lg hover:bg-zinc-800 transition-colors"
|
|
>
|
|
Back to Upload
|
|
</button>
|
|
<button
|
|
onClick={handleSaveAll}
|
|
disabled={editableWorkouts.length === 0 || isSaving}
|
|
className="flex-1 py-3 px-4 bg-white text-black font-semibold rounded-lg hover:bg-zinc-100 transition-colors disabled:bg-zinc-700 disabled:text-zinc-400 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
{isSaving ? (
|
|
<>
|
|
<Loader className="h-5 w-5 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Check className="h-5 w-5" />
|
|
Save All Workouts
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Phase 3: Save Success
|
|
return (
|
|
<div className="min-h-screen bg-[#0A0A0A] p-4 md:p-8">
|
|
<div className="mx-auto max-w-2xl">
|
|
<div className="rounded-lg border border-green-800 bg-green-900/20 p-8 text-center">
|
|
<Check className="mx-auto h-16 w-16 text-green-500 mb-4" />
|
|
<h1 className="text-3xl font-bold text-white mb-2">Workouts Saved!</h1>
|
|
<p className="text-zinc-400 mb-6">
|
|
Successfully imported {saveSuccess?.ids.length || 0} workout(s).
|
|
</p>
|
|
|
|
{saveSuccess?.ids && saveSuccess.ids.length > 0 && (
|
|
<div className="mb-8 space-y-2">
|
|
{saveSuccess.ids.map((id) => (
|
|
<Link
|
|
key={id}
|
|
href={`/main/workouts/${id}`}
|
|
className="block py-2 px-4 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors text-white"
|
|
>
|
|
View Workout
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={resetToUpload}
|
|
className="inline-block px-6 py-2 bg-white text-black font-medium rounded-lg hover:bg-zinc-100 transition-colors"
|
|
>
|
|
Import More Workouts
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Helper component for duration input
|
|
const DurationInput: React.FC<{
|
|
value: number | null;
|
|
onChange: (seconds: number | null) => void;
|
|
}> = ({ value, onChange }) => {
|
|
const [minutes, setMinutes] = React.useState<string>(
|
|
value ? Math.floor(value / 60).toString() : ''
|
|
);
|
|
const [seconds, setSeconds] = React.useState<string>(
|
|
value ? (value % 60).toString() : ''
|
|
);
|
|
|
|
const handleChange = (min: string, sec: string) => {
|
|
setMinutes(min);
|
|
setSeconds(sec);
|
|
|
|
const minNum = min ? parseInt(min) : 0;
|
|
const secNum = sec ? parseInt(sec) : 0;
|
|
const totalSeconds = minNum * 60 + secNum;
|
|
|
|
onChange(totalSeconds || null);
|
|
};
|
|
|
|
return (
|
|
<div className="flex gap-1">
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="59"
|
|
value={minutes}
|
|
onChange={(e) => handleChange(e.target.value, seconds)}
|
|
placeholder="0"
|
|
className="w-2/3 px-2 py-1 bg-zinc-600 border border-zinc-500 rounded text-white text-sm placeholder-zinc-400 focus:outline-none focus:border-zinc-400"
|
|
/>
|
|
<span className="flex items-center text-white text-sm">:</span>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="59"
|
|
value={seconds}
|
|
onChange={(e) => handleChange(minutes, e.target.value)}
|
|
placeholder="0"
|
|
className="w-2/3 px-2 py-1 bg-zinc-600 border border-zinc-500 rounded text-white text-sm placeholder-zinc-400 focus:outline-none focus:border-zinc-400"
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default WorkoutImportClient;
|