'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 = ({ hasApiKey, exercises: _exercises, }) => { const [phase, setPhase] = useState('upload'); const [images, setImages] = useState([]); const [isAnalyzing, setIsAnalyzing] = useState(false); const [editableWorkouts, setEditableWorkouts] = useState([]); const [expandedWorkouts, setExpandedWorkouts] = useState>(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(null); // File upload handlers const fileToBase64 = (file: File): Promise => { 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) => { e.preventDefault(); e.stopPropagation(); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/') ); addImages(files); }; const handleFileSelect = (e: React.ChangeEvent) => { 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 (

Claude API Key Required

Configure your Claude API key in Settings to use import.

Go to Settings
); } return (

Import Workouts

Upload images of your workout to have Claude automatically parse them.

{/* Drag and Drop Zone */}

Drop your workout images here

or use the button below to select files

{/* Image Previews */} {images.length > 0 && (

Selected Images ({images.length})

{images.map((img) => (
Preview
))}
)} {/* Error Message */} {errorMessage && (

{errorMessage}

)} {/* Analyze Button */}
); } // Phase 2: Review & Edit if (phase === 'review') { return (

Review Workouts

Verify and edit the parsed workout data before saving.

{/* Error Message */} {errorMessage && (

{errorMessage}

)} {/* Workouts */}
{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 (
{/* Workout Header */}
{/* Expanded Content */} {isExpanded && (
{/* Workout Fields */}
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" />
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" />
{/* Warnings */} {workout.warnings && workout.warnings.length > 0 && (

Warnings:

    {workout.warnings.map((warning, idx) => (
  • • {warning}
  • ))}
)} {/* Exercises */}
{workout.exercises.map((exercise) => (
{/* Exercise Header */}
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" />
{/* Uncertain Warning */} {exercise.uncertain && (

{exercise.uncertainReason || 'Please verify this exercise name'}

)} {/* Sets */}
{exercise.sets.map((set, setIdx) => (
Set {setIdx + 1}
{/* Reps */}
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" />
{/* Weight */}
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" />
{/* Duration */}
updateSetField( workout.id, exercise.id, set.id, 'durationSeconds', seconds ) } />
{/* Distance */}
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" />
{/* RPE */}
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" />
{/* Calories */}
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" />
{/* Notes */}
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" />
))}
{/* Add Set Button */}
))}
{/* Add Exercise Button */} {/* Remove Workout Button */}
)}
); })}
{/* Action Buttons */}
); } // Phase 3: Save Success return (

Workouts Saved!

Successfully imported {saveSuccess?.ids.length || 0} workout(s).

{saveSuccess?.ids && saveSuccess.ids.length > 0 && (
{saveSuccess.ids.map((id) => ( View Workout ))}
)}
); }; // Helper component for duration input const DurationInput: React.FC<{ value: number | null; onChange: (seconds: number | null) => void; }> = ({ value, onChange }) => { const [minutes, setMinutes] = React.useState( value ? Math.floor(value / 60).toString() : '' ); const [seconds, setSeconds] = React.useState( 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 (
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" /> : 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" />
); }; export default WorkoutImportClient;