diff --git a/server/prisma/dev.db b/server/prisma/dev.db index b2bebb9..ee7e88c 100644 Binary files a/server/prisma/dev.db and b/server/prisma/dev.db differ diff --git a/src/components/ExerciseModal.tsx b/src/components/ExerciseModal.tsx index 3d62720..e068691 100644 --- a/src/components/ExerciseModal.tsx +++ b/src/components/ExerciseModal.tsx @@ -1,10 +1,12 @@ import React, { useState } from 'react'; import { toTitleCase } from '../utils/text'; -import { X, Dumbbell, User, Flame, Timer as TimerIcon, ArrowUp, ArrowRight, Footprints, Ruler, Percent } from 'lucide-react'; +import { Dumbbell, User, Flame, Timer as TimerIcon, ArrowUp, ArrowRight, Footprints, Ruler, Percent } from 'lucide-react'; import { ExerciseDef, ExerciseType, Language } from '../types'; import { t } from '../services/i18n'; import { generateId } from '../utils/uuid'; import FilledInput from './FilledInput'; +import { Modal } from './ui/Modal'; +import { Button } from './ui/Button'; interface ExerciseModalProps { isOpen: boolean; @@ -58,97 +60,93 @@ const ExerciseModal: React.FC = ({ isOpen, onClose, onSave, setNewBwPercentage('100'); setIsUnilateral(false); setError(''); - onClose(); + onClose(); // Modal controls its own open state usually, but here checking prop }; - if (!isOpen) return null; - return ( -
-
-
-

{t('create_exercise', lang)}

- + +
+
+ { + setNewName(e.target.value); + setError(''); // Clear error when user types + }} + type="text" + autoFocus + autocapitalize="words" + onBlur={() => setNewName(toTitleCase(newName))} + /> + {error && ( +

{error}

+ )}
-
-
- { - setNewName(e.target.value); - setError(''); // Clear error when user types - }} - type="text" - autoFocus - autocapitalize="words" - onBlur={() => setNewName(toTitleCase(newName))} - /> - {error && ( -

{error}

- )} +
+ +
+ {[ + { id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell }, + { id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User }, + { id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame }, + { id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon }, + { id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp }, + { id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler }, + { id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints }, + ].map((type) => ( + + ))}
+
-
- -
- {[ - { id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell }, - { id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User }, - { id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame }, - { id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon }, - { id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp }, - { id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler }, - { id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints }, - ].map((type) => ( - - ))} -
-
+ {newType === ExerciseType.BODYWEIGHT && ( + setNewBwPercentage(e.target.value)} + icon={} + /> + )} - {newType === ExerciseType.BODYWEIGHT && ( - setNewBwPercentage(e.target.value)} - icon={} - /> - )} +
+ setIsUnilateral(e.target.checked)} + className="w-5 h-5 rounded border-2 border-outline bg-surface-container-high checked:bg-primary checked:border-primary cursor-pointer" + /> + +
-
- setIsUnilateral(e.target.checked)} - className="w-5 h-5 rounded border-2 border-outline bg-surface-container-high checked:bg-primary checked:border-primary cursor-pointer" - /> - -
- -
- -
+
+
-
+ ); }; diff --git a/src/components/History.tsx b/src/components/History.tsx index e27e2a1..5d2c04d 100644 --- a/src/components/History.tsx +++ b/src/components/History.tsx @@ -1,9 +1,12 @@ - import React, { useState } from 'react'; import { Calendar, Clock, TrendingUp, Gauge, Pencil, Trash2, X, Save, ArrowRight, ArrowUp, Timer, Activity, Dumbbell, Percent } from 'lucide-react'; import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types'; import { t } from '../services/i18n'; import { useSession } from '../context/SessionContext'; +import { Button } from './ui/Button'; +import { Card } from './ui/Card'; +import { Modal } from './ui/Modal'; +import FilledInput from './FilledInput'; interface HistoryProps { lang: Language; @@ -116,8 +119,6 @@ const History: React.FC = ({ lang }) => { } - - if (sessions.length === 0) { return (
@@ -129,257 +130,268 @@ const History: React.FC = ({ lang }) => { return (
-
+

{t('tab_history', lang)}

-
- {/* Regular Workout Sessions */} - {sessions.filter(s => s.type === 'STANDARD').map((session) => { - const totalWork = calculateSessionWork(session); +
+
+ {/* Regular Workout Sessions */} + {sessions.filter(s => s.type === 'STANDARD').map((session) => { + const totalWork = calculateSessionWork(session); - return ( -
setEditingSession(JSON.parse(JSON.stringify(session)))} - > -
-
-
- - {new Date(session.startTime).toISOString().split('T')[0]} - - - {new Date(session.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - - {session.endTime && ( + return ( + setEditingSession(JSON.parse(JSON.stringify(session)))} + > +
+
+
+ + {new Date(session.startTime).toISOString().split('T')[0]} + - {formatDuration(session.startTime, session.endTime)} + {new Date(session.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - )} - - {session.planName || t('no_plan', lang)} - - {session.userBodyWeight && ( - - {session.userBodyWeight}kg + {session.endTime && ( + + {formatDuration(session.startTime, session.endTime)} + + )} + + {session.planName || t('no_plan', lang)} - )} + {session.userBodyWeight && ( + + {session.userBodyWeight}kg + + )} +
+
+ + {t('sets_count', lang)}: {session.sets.length} + + {totalWork > 0 && ( + + + {(totalWork / 1000).toFixed(1)}t + + )} +
-
- - {t('sets_count', lang)}: {session.sets.length} - - {totalWork > 0 && ( - - - {(totalWork / 1000).toFixed(1)}t - - )} + +
+ +
- -
- - -
-
-
- ) - })} - - {/* Quick Log Sessions */} - {sessions.filter(s => s.type === 'QUICK_LOG').length > 0 && ( -
-

{t('quick_log', lang)}

- {Object.entries( - sessions - .filter(s => s.type === 'QUICK_LOG') - .reduce((groups: Record, session) => { - const date = new Date(session.startTime).toISOString().split('T')[0]; - if (!groups[date]) groups[date] = []; - groups[date].push(session); - return groups; - }, {}) + ) - .sort(([a], [b]) => b.localeCompare(a)) - .map(([date, daySessions]) => ( -
-
{date}
-
- {daySessions.flatMap(session => session.sets).map((set, idx) => ( -
-
-
- {set.exerciseName} - {set.side && {t(set.side.toLowerCase() as any, lang)}} -
-
- {set.type === ExerciseType.STRENGTH && `${set.weight || 0}kg x ${set.reps || 0}`} - {set.type === ExerciseType.BODYWEIGHT && `${set.weight ? `+${set.weight}kg` : 'BW'} x ${set.reps || 0}`} - {set.type === ExerciseType.CARDIO && `${set.durationSeconds || 0}s ${set.distanceMeters ? `/ ${set.distanceMeters}m` : ''}`} - {set.type === ExerciseType.STATIC && `${set.durationSeconds || 0}s`} - {set.type === ExerciseType.HIGH_JUMP && `${set.height || 0}cm`} - {set.type === ExerciseType.LONG_JUMP && `${set.distanceMeters || 0}m`} - {set.type === ExerciseType.PLYOMETRIC && `x ${set.reps || 0}`} -
-
- {new Date(set.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
-
-
- - -
-
- ))} -
-
- ))} -
- )} + })} + {/* Quick Log Sessions */} + {sessions.filter(s => s.type === 'QUICK_LOG').length > 0 && ( +
+

{t('quick_log', lang)}

+ {(Object.entries( + sessions + .filter(s => s.type === 'QUICK_LOG') + .reduce>((groups, session) => { + const date = new Date(session.startTime).toISOString().split('T')[0]; + if (!groups[date]) groups[date] = []; + groups[date].push(session); + return groups; + }, {}) + ) as [string, WorkoutSession[]][]) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([date, daySessions]) => ( +
+
{date}
+
+ {daySessions + .reduce((acc, session) => acc.concat(session.sets), []) + .map((set, idx) => ( + +
+
+ {set.exerciseName} + {set.side && {t(set.side.toLowerCase() as any, lang)}} +
+
+ {set.type === ExerciseType.STRENGTH && `${set.weight || 0}kg x ${set.reps || 0}`} + {set.type === ExerciseType.BODYWEIGHT && `${set.weight ? `+${set.weight}kg` : 'BW'} x ${set.reps || 0}`} + {set.type === ExerciseType.CARDIO && `${set.durationSeconds || 0}s ${set.distanceMeters ? `/ ${set.distanceMeters}m` : ''}`} + {set.type === ExerciseType.STATIC && `${set.durationSeconds || 0}s`} + {set.type === ExerciseType.HIGH_JUMP && `${set.height || 0}cm`} + {set.type === ExerciseType.LONG_JUMP && `${set.distanceMeters || 0}m`} + {set.type === ExerciseType.PLYOMETRIC && `x ${set.reps || 0}`} +
+
+ {new Date(set.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
+
+
+ + +
+
+ ))} +
+
+ ))} +
+ )} +
- {/* DELETE CONFIRMATION DIALOG (MD3) */} - {(deletingId || deletingSetInfo) && ( -
-
-

- {deletingId ? t('delete_workout', lang) : t('delete_set', lang) || 'Delete Set'} -

-

- {deletingId ? t('delete_confirm', lang) : t('delete_set_confirm', lang) || 'Are you sure you want to delete this set?'} -

-
- - -
+ {/* DELETE CONFIRMATION MODAL */} + { + setDeletingId(null); + setDeletingSetInfo(null); + }} + title={deletingId ? t('delete_workout', lang) : t('delete_set', lang) || 'Delete Set'} + maxWidth="sm" + > +
+

+ {deletingId ? t('delete_confirm', lang) : t('delete_set_confirm', lang) || 'Are you sure you want to delete this set?'} +

+
+ +
- )} +
- {/* EDIT SESSION FULLSCREEN DIALOG */} + {/* EDIT SESSION MODAL */} {editingSession && ( -
-
- -

{t('edit', lang)}

- -
- -
+ setEditingSession(null)} + title={t('edit', lang)} + maxWidth="lg" + > +
{/* Meta Info */} -
-
-
- - setEditingSession({ ...editingSession, startTime: parseDateFromInput(e.target.value) })} - className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1" - /> -
-
- - setEditingSession({ ...editingSession, endTime: parseDateFromInput(e.target.value) })} - className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1" - /> -
-
+
- + setEditingSession({ ...editingSession, userBodyWeight: parseFloat(e.target.value) })} - className="w-full bg-transparent text-on-surface focus:outline-none text-lg mt-1" + type="datetime-local" + value={formatDateForInput(editingSession.startTime)} + onChange={(e) => setEditingSession({ ...editingSession, startTime: parseDateFromInput(e.target.value) })} + className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1" />
+
+ + setEditingSession({ ...editingSession, endTime: parseDateFromInput(e.target.value) })} + className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1" + /> +
+
+
+ + setEditingSession({ ...editingSession, userBodyWeight: parseFloat(e.target.value) })} + className="w-full bg-transparent text-on-surface focus:outline-none text-lg mt-1" + />

{t('sets_count', lang)} ({editingSession.sets.length})

{editingSession.sets.map((set, idx) => ( -
-
+
+
{idx + 1} - {set.exerciseName}{set.side && {t(set.side.toLowerCase(), lang)}} + {set.exerciseName}{set.side && {t(set.side.toLowerCase() as any, lang)}}
- + +
-
+
{(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && (
@@ -450,11 +462,16 @@ const History: React.FC = ({ lang }) => {
))}
+ +
+ +
-
+ )} - -
); }; diff --git a/src/components/Plans.tsx b/src/components/Plans.tsx index c3d9bd5..22098ce 100644 --- a/src/components/Plans.tsx +++ b/src/components/Plans.tsx @@ -1,6 +1,5 @@ - import React, { useState, useEffect } from 'react'; -import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Scale, Edit2, User, Flame, Timer as TimerIcon, Ruler, Footprints, Activity, Percent, CheckCircle, GripVertical } from 'lucide-react'; +import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Edit2, User, Flame, Timer as TimerIcon, Ruler, Footprints, Activity, Percent, CheckCircle, GripVertical, Scale } from 'lucide-react'; import { WorkoutPlan, ExerciseDef, PlannedSet, Language, ExerciseType } from '../types'; import { getExercises, saveExercise } from '../services/storage'; import { t } from '../services/i18n'; @@ -11,6 +10,8 @@ import { useActiveWorkout } from '../context/ActiveWorkoutContext'; import FilledInput from './FilledInput'; import { toTitleCase } from '../utils/text'; +import { Button } from './ui/Button'; +import { Card } from './ui/Card'; interface PlansProps { lang: Language; @@ -162,26 +163,25 @@ const Plans: React.FC = ({ lang }) => { if (isEditing) { return (
-
- +
+

{t('plan_editor', lang)}

- +
-
- - setName(e.target.value)} - autoCapitalize="words" - onBlur={() => setName(toTitleCase(name))} - /> -
+ setName(e.target.value)} + type="text" + autocapitalize="words" + onBlur={() => setName(toTitleCase(name))} + />
@@ -200,9 +200,9 @@ const Plans: React.FC = ({ lang }) => {
{steps.map((step, idx) => ( -
onDragStart(idx)} onDragEnter={() => onDragEnter(idx)} @@ -221,7 +221,7 @@ const Plans: React.FC = ({ lang }) => {
{step.exerciseName}
- -
+ + ))}
- +
{showExerciseSelector && (
-
+
{t('select_exercise', lang)}
- - + +
@@ -278,9 +282,11 @@ const Plans: React.FC = ({ lang }) => { {isCreatingExercise && (
-
+

{t('create_exercise', lang)}

- +
@@ -330,13 +336,14 @@ const Plans: React.FC = ({ lang }) => { )}
- +
@@ -349,7 +356,7 @@ const Plans: React.FC = ({ lang }) => { return (
-
+

{t('my_plans', lang)}

@@ -363,23 +370,27 @@ const Plans: React.FC = ({ lang }) => {
) : ( plans.map(plan => ( -
+

{plan.name}

- +
- +

{plan.description || t('prep_no_instructions', lang)} @@ -388,15 +399,15 @@ const Plans: React.FC = ({ lang }) => {

{plan.steps.length} {t('exercises_count', lang)}
- +
-
+ )) )}
diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index 5a7245b..0b93395 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -9,6 +9,9 @@ import ExerciseModal from './ExerciseModal'; import FilledInput from './FilledInput'; import { t } from '../services/i18n'; import Snackbar from './Snackbar'; +import { Button } from './ui/Button'; +import { Card } from './ui/Card'; +import { Modal } from './ui/Modal'; interface ProfileProps { user: User; @@ -238,338 +241,343 @@ const Profile: React.FC = ({ user, onLogout, lang, onLanguageChang return (
-
+

{t('profile_title', lang)}

- +
+
- {/* User Info Card */} -
-
-
- {user.email[0].toUpperCase()} -
-
-
{user.email}
-
- {user.role === 'ADMIN' && } - {user.role} + {/* User Info Card */} + +
+
+ {user.email[0].toUpperCase()}
-
-
- -

{t('personal_data', lang)}

-
-
- - setWeight(e.target.value)} - className="w-full bg-transparent text-on-surface focus:outline-none" - /> -
-
- - setHeight(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none" /> -
-
- - setBirthDate(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1" /> -
-
- - -
-
- -
- - -
- - -
- - {/* WEIGHT TRACKER */} -
- - - {showWeightTracker && ( -
-
-
- - setTodayWeight(e.target.value)} - className="w-full bg-transparent text-on-surface focus:outline-none" - placeholder="Enter weight..." - /> +
+
{user.email}
+
+ {user.role === 'ADMIN' && } + {user.role}
- -
- -
-

History

- {weightHistory.length === 0 ? ( -

No weight records yet.

- ) : ( - weightHistory.map(record => ( -
- {new Date(record.date).toLocaleDateString()} - {record.weight} kg -
- )) - )}
- )} -
- {/* EXERCISE MANAGER */} -
- - - {showExercises && ( -
- - - setExerciseNameFilter(e.target.value)} - icon={} // No icon needed or maybe use Search icon? Profile doesn't import Search. I'll omit icon if optional. - type="text" - autoFocus={false} - /> - -
- +

{t('personal_data', lang)}

+
+
+ setShowArchived(e.target.checked)} - className="accent-primary" + type="number" + step="0.1" + value={weight} + onChange={(e) => setWeight(e.target.value)} + className="w-full bg-transparent text-on-surface focus:outline-none" />
- -
- {exercises - .filter(e => showArchived || !e.isArchived) - .filter(e => e.name.toLowerCase().includes(exerciseNameFilter.toLowerCase())) - .sort((a, b) => a.name.localeCompare(b.name)) - .map(ex => ( -
-
-
{ex.name}
-
- {exerciseTypeLabels[ex.type]} - {ex.isUnilateral && `, ${t('unilateral', lang)}`} -
-
-
- - -
-
- ))} +
+ + setHeight(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none" /> +
+
+ + setBirthDate(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1" /> +
+
+ +
- )} -
- {/* Change Password */} -
-

{t('change_pass_btn', lang)}

-
- setNewPassword(e.target.value)} - className="flex-1 bg-surface-container-high border-b border-outline-variant px-3 py-2 text-on-surface focus:outline-none rounded-t-lg" - /> - -
- {passMsg &&

{passMsg}

} -
+
+ + +
- {/* User Self Deletion (Not for Admin) */} - {user.role !== 'ADMIN' && ( -
-

{t('delete_account', lang)}

- {!showDeleteConfirm ? ( - - ) : ( -
-

{t('delete_account_confirm', lang)}

-
- - + + + + {/* WEIGHT TRACKER */} + + + + {showWeightTracker && ( +
+
+
+ + setTodayWeight(e.target.value)} + className="w-full bg-transparent text-on-surface focus:outline-none" + placeholder="Enter weight..." + /> +
+ +
+ +
+

History

+ {weightHistory.length === 0 ? ( +

No weight records yet.

+ ) : ( + weightHistory.map(record => ( +
+ {new Date(record.date).toLocaleDateString()} + {record.weight} kg +
+ )) + )}
)} -
- )} + - {/* ADMIN AREA */} - {user.role === 'ADMIN' && ( -
-
- -
-

{t('admin_area', lang)}

+ {/* EXERCISE MANAGER */} + + - {/* Create User */} -
-

{t('create_user', lang)}

- setNewUserEmail(e.target.value)} - type="email" - /> - setNewUserPass(e.target.value)} - type="text" - /> - - {createMsg &&

{createMsg}

} -
+ {showExercises && ( +
+ - {/* User List */} -
- + setExerciseNameFilter(e.target.value)} + icon={} + type="text" + autoFocus={false} + /> - {showUserList && ( -
- {allUsers.map(u => ( -
-
-
-
{u.email}
-
- {u.role} - {u.isBlocked && {t('block', lang)}} +
+ + setShowArchived(e.target.checked)} + className="accent-primary" + /> +
+ +
+ {exercises + .filter(e => showArchived || !e.isArchived) + .filter(e => e.name.toLowerCase().includes(exerciseNameFilter.toLowerCase())) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(ex => ( +
+
+
{ex.name}
+
+ {exerciseTypeLabels[ex.type]} + {ex.isUnilateral && `, ${t('unilateral', lang)}`}
-
- {u.role !== 'ADMIN' && ( - <> - - - - )} -
-
- - {u.role !== 'ADMIN' && ( -
-
- - setAdminPassResetInput({ ...adminPassResetInput, [u.id]: e.target.value })} - /> -
+
+
- )} -
- ))} +
+ ))} +
+
+ )} + + + {/* Change Password */} + +

{t('change_pass_btn', lang)}

+
+ setNewPassword(e.target.value)} + className="flex-1 bg-surface-container-high border-b border-outline-variant px-3 py-2 text-on-surface focus:outline-none rounded-t-lg" + /> + +
+ {passMsg &&

{passMsg}

} +
+ + {/* User Self Deletion (Not for Admin) */} + {user.role !== 'ADMIN' && ( + +

{t('delete_account', lang)}

+ {!showDeleteConfirm ? ( + + ) : ( +
+

{t('delete_account_confirm', lang)}

+
+ + +
)} -
-
- )} + + )} - {/* Edit Exercise Modal */} - {editingExercise && ( -
-
-

{t('edit', lang)}

+ {/* ADMIN AREA */} + {user.role === 'ADMIN' && ( + +
+ +
+

{t('admin_area', lang)}

+ + {/* Create User */} +
+

{t('create_user', lang)}

+ setNewUserEmail(e.target.value)} + type="email" + /> + setNewUserPass(e.target.value)} + type="text" + /> + + {createMsg &&

{createMsg}

} +
+ + {/* User List */} +
+ + + {showUserList && ( +
+ {allUsers.map(u => ( +
+
+
+
{u.email}
+
+ {u.role} + {u.isBlocked && {t('block', lang)}} +
+
+
+ {u.role !== 'ADMIN' && ( + <> + + + + )} +
+
+ + {u.role !== 'ADMIN' && ( +
+
+ + setAdminPassResetInput({ ...adminPassResetInput, [u.id]: e.target.value })} + /> +
+ +
+ )} +
+ ))} +
+ )} +
+
+ )} + + {/* Edit Exercise Modal */} + {editingExercise && ( + setEditingExercise(null)} + title={t('edit', lang)} + maxWidth="sm" + >
@@ -591,32 +599,32 @@ const Profile: React.FC = ({ user, onLogout, lang, onLanguageChang
)}
- - + +
-
-
- )} + + )} - {/* Create Exercise Modal */} - {isCreatingEx && ( - setIsCreatingEx(false)} - onSave={handleCreateExercise} - lang={lang} - existingExercises={exercises} - /> - )} + {/* Create Exercise Modal */} + {isCreatingEx && ( + setIsCreatingEx(false)} + onSave={handleCreateExercise} + lang={lang} + existingExercises={exercises} + /> + )} +
+ setSnackbar(prev => ({ ...prev, isOpen: false }))} + />
- setSnackbar(prev => ({ ...prev, isOpen: false }))} - />
); }; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..7c96daf --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,49 @@ + +import React from 'react'; +import { Loader2 } from 'lucide-react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive'; + size?: 'sm' | 'md' | 'lg' | 'icon'; + fullWidth?: boolean; + loading?: boolean; + children: React.ReactNode; +} + +export const Button = React.forwardRef( + ({ className = '', variant = 'primary', size = 'md', fullWidth = false, loading = false, children, disabled, ...props }, ref) => { + + const baseStyles = "inline-flex items-center justify-center rounded-full font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50"; + + const variants = { + primary: "bg-primary text-on-primary hover:bg-primary/90", + secondary: "bg-secondary-container text-on-secondary-container hover:bg-secondary-container/80", + outline: "border border-outline text-primary hover:bg-primary-container/10", + ghost: "text-on-surface hover:bg-surface-container-high", + destructive: "bg-error text-on-error hover:bg-error/90", + }; + + const sizes = { + sm: "h-8 px-3 text-xs", + md: "h-10 px-4 text-sm", + lg: "h-12 px-8 text-base", + icon: "h-10 w-10", + }; + + const width = fullWidth ? "w-full" : ""; + + return ( + + ); + } +); + +Button.displayName = "Button"; diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx new file mode 100644 index 0000000..723b5c7 --- /dev/null +++ b/src/components/ui/Card.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +interface CardProps extends React.HTMLAttributes { + children: React.ReactNode; + className?: string; + noPadding?: boolean; +} + +export const Card: React.FC = ({ className = '', children, noPadding = false, ...props }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 0000000..1dd0545 --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -0,0 +1,61 @@ + +import React, { useEffect, useState } from 'react'; +import { X } from 'lucide-react'; +import { createPortal } from 'react-dom'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; + maxWidth?: 'sm' | 'md' | 'lg' | 'xl'; +} + +export const Modal: React.FC = ({ isOpen, onClose, title, children, maxWidth = 'sm' }) => { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + if (!isMounted || !isOpen) return null; + + const maxWidthClasses = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-xl', + }; + + return createPortal( +
+
+
+
+

{title}

+ +
+
+ {children} +
+
+
, + document.body + ); +};