UI refactoring: Profile, History, and Plans Components

This commit is contained in:
AG
2025-12-07 23:59:33 +02:00
parent 57f7ad077e
commit a3a9aa7194
8 changed files with 829 additions and 667 deletions

Binary file not shown.

View File

@@ -1,10 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { toTitleCase } from '../utils/text'; 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 { ExerciseDef, ExerciseType, Language } from '../types';
import { t } from '../services/i18n'; import { t } from '../services/i18n';
import { generateId } from '../utils/uuid'; import { generateId } from '../utils/uuid';
import FilledInput from './FilledInput'; import FilledInput from './FilledInput';
import { Modal } from './ui/Modal';
import { Button } from './ui/Button';
interface ExerciseModalProps { interface ExerciseModalProps {
isOpen: boolean; isOpen: boolean;
@@ -58,97 +60,93 @@ const ExerciseModal: React.FC<ExerciseModalProps> = ({ isOpen, onClose, onSave,
setNewBwPercentage('100'); setNewBwPercentage('100');
setIsUnilateral(false); setIsUnilateral(false);
setError(''); setError('');
onClose(); onClose(); // Modal controls its own open state usually, but here checking prop
}; };
if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-4 backdrop-blur-sm"> <Modal
<div className="bg-surface-container w-full max-w-sm rounded-[28px] p-6 shadow-elevation-3 animate-in slide-in-from-bottom-10 duration-200"> isOpen={isOpen}
<div className="flex justify-between items-center mb-6"> onClose={onClose}
<h3 className="text-2xl font-normal text-on-surface">{t('create_exercise', lang)}</h3> title={t('create_exercise', lang)}
<button onClick={onClose} className="p-2 bg-surface-container-high rounded-full hover:bg-outline-variant/20"><X size={20} /></button> maxWidth="sm"
>
<div className="space-y-6">
<div>
<FilledInput
label={t('ex_name', lang)}
value={newName}
onChange={(e: any) => {
setNewName(e.target.value);
setError(''); // Clear error when user types
}}
type="text"
autoFocus
autocapitalize="words"
onBlur={() => setNewName(toTitleCase(newName))}
/>
{error && (
<p className="text-xs text-error mt-2 ml-3">{error}</p>
)}
</div> </div>
<div className="space-y-6"> <div>
<div> <label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label>
<FilledInput <div className="flex flex-wrap gap-2">
label={t('ex_name', lang)} {[
value={newName} { id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell },
onChange={(e: any) => { { id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User },
setNewName(e.target.value); { id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame },
setError(''); // Clear error when user types { id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon },
}} { id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp },
type="text" { id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler },
autoFocus { id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints },
autocapitalize="words" ].map((type) => (
onBlur={() => setNewName(toTitleCase(newName))} <button
/> key={type.id}
{error && ( onClick={() => setNewType(type.id)}
<p className="text-xs text-error mt-2 ml-3">{error}</p> className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${newType === type.id
)} ? 'bg-secondary-container text-on-secondary-container border-transparent'
: 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant'
}`}
>
<type.icon size={14} /> {type.label}
</button>
))}
</div> </div>
</div>
<div> {newType === ExerciseType.BODYWEIGHT && (
<label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label> <FilledInput
<div className="flex flex-wrap gap-2"> label={t('body_weight_percent', lang)}
{[ value={newBwPercentage}
{ id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell }, onChange={(e: any) => setNewBwPercentage(e.target.value)}
{ id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User }, icon={<Percent size={12} />}
{ 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) => (
<button
key={type.id}
onClick={() => setNewType(type.id)}
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${newType === type.id
? 'bg-secondary-container text-on-secondary-container border-transparent'
: 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant'
}`}
>
<type.icon size={14} /> {type.label}
</button>
))}
</div>
</div>
{newType === ExerciseType.BODYWEIGHT && ( <div className="flex items-center gap-3 px-1">
<FilledInput <input
label={t('body_weight_percent', lang)} type="checkbox"
value={newBwPercentage} id="isUnilateral"
onChange={(e: any) => setNewBwPercentage(e.target.value)} checked={isUnilateral}
icon={<Percent size={12} />} onChange={(e) => 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"
)} />
<label htmlFor="isUnilateral" className="text-sm text-on-surface cursor-pointer">
{t('unilateral_exercise', lang) || 'Unilateral exercise (separate left/right tracking)'}
</label>
</div>
<div className="flex items-center gap-3 px-1"> <div className="flex justify-end mt-4 pt-4">
<input <Button
type="checkbox" onClick={handleCreateExercise}
id="isUnilateral" fullWidth
checked={isUnilateral} >
onChange={(e) => setIsUnilateral(e.target.checked)} {t('create_btn', lang)}
className="w-5 h-5 rounded border-2 border-outline bg-surface-container-high checked:bg-primary checked:border-primary cursor-pointer" </Button>
/>
<label htmlFor="isUnilateral" className="text-sm text-on-surface cursor-pointer">
{t('unilateral_exercise', lang) || 'Unilateral exercise (separate left/right tracking)'}
</label>
</div>
<div className="flex justify-end mt-4">
<button
onClick={handleCreateExercise}
className="px-8 py-3 bg-primary text-on-primary rounded-full font-medium shadow-elevation-1"
>
{t('create_btn', lang)}
</button>
</div>
</div> </div>
</div> </div>
</div> </Modal>
); );
}; };

View File

@@ -1,9 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Calendar, Clock, TrendingUp, Gauge, Pencil, Trash2, X, Save, ArrowRight, ArrowUp, Timer, Activity, Dumbbell, Percent } from 'lucide-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 { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types';
import { t } from '../services/i18n'; import { t } from '../services/i18n';
import { useSession } from '../context/SessionContext'; 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 { interface HistoryProps {
lang: Language; lang: Language;
@@ -116,8 +119,6 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
} }
if (sessions.length === 0) { if (sessions.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant p-8 text-center"> <div className="flex flex-col items-center justify-center h-full text-on-surface-variant p-8 text-center">
@@ -129,257 +130,268 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
return ( return (
<div className="h-full flex flex-col bg-surface"> <div className="h-full flex flex-col bg-surface">
<div className="p-4 bg-surface-container shadow-elevation-1 z-10"> <div className="p-4 bg-surface-container shadow-elevation-1 z-10 shrink-0">
<h2 className="text-2xl font-normal text-on-surface">{t('tab_history', lang)}</h2> <h2 className="text-2xl font-normal text-on-surface">{t('tab_history', lang)}</h2>
</div> </div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-20"> <div className="flex-1 overflow-y-auto p-4 pb-20">
{/* Regular Workout Sessions */} <div className="max-w-2xl mx-auto space-y-4">
{sessions.filter(s => s.type === 'STANDARD').map((session) => { {/* Regular Workout Sessions */}
const totalWork = calculateSessionWork(session); {sessions.filter(s => s.type === 'STANDARD').map((session) => {
const totalWork = calculateSessionWork(session);
return ( return (
<div <Card
key={session.id} key={session.id}
className="bg-surface-container rounded-xl p-5 shadow-elevation-1 border border-outline-variant/20 cursor-pointer hover:bg-surface-container-high transition-colors" className="cursor-pointer hover:bg-surface-container-high transition-colors"
onClick={() => setEditingSession(JSON.parse(JSON.stringify(session)))} onClick={() => setEditingSession(JSON.parse(JSON.stringify(session)))}
> >
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<span className="font-medium text-on-surface text-lg"> <span className="font-medium text-on-surface text-lg">
{new Date(session.startTime).toISOString().split('T')[0]} {new Date(session.startTime).toISOString().split('T')[0]}
</span> </span>
<span className="text-sm text-on-surface-variant">
{new Date(session.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{session.endTime && (
<span className="text-sm text-on-surface-variant"> <span className="text-sm text-on-surface-variant">
{formatDuration(session.startTime, session.endTime)} {new Date(session.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span> </span>
)} {session.endTime && (
<span className="text-sm text-on-surface-variant"> <span className="text-sm text-on-surface-variant">
{session.planName || t('no_plan', lang)} {formatDuration(session.startTime, session.endTime)}
</span> </span>
{session.userBodyWeight && ( )}
<span className="px-2 py-0.5 rounded-full bg-surface-container-high text-on-surface text-xs"> <span className="text-sm text-on-surface-variant">
{session.userBodyWeight}kg {session.planName || t('no_plan', lang)}
</span> </span>
)} {session.userBodyWeight && (
<span className="px-2 py-0.5 rounded-full bg-surface-container-high text-on-surface text-xs">
{session.userBodyWeight}kg
</span>
)}
</div>
<div className="mt-2 text-xs text-on-surface-variant flex items-center">
<span className="inline-flex items-center">
{t('sets_count', lang)}: <span className="text-on-surface font-medium ml-1">{session.sets.length}</span>
</span>
{totalWork > 0 && (
<span className="ml-4 inline-flex items-center gap-1">
<Gauge size={12} />
{(totalWork / 1000).toFixed(1)}t
</span>
)}
</div>
</div> </div>
<div className="mt-2 text-xs text-on-surface-variant flex items-center">
<span className="inline-flex items-center"> <div className="flex gap-1">
{t('sets_count', lang)}: <span className="text-on-surface font-medium ml-1">{session.sets.length}</span> <Button
</span> onClick={(e) => {
{totalWork > 0 && ( e.stopPropagation();
<span className="ml-4 inline-flex items-center gap-1"> setEditingSession(JSON.parse(JSON.stringify(session)));
<Gauge size={12} /> }}
{(totalWork / 1000).toFixed(1)}t variant="ghost"
</span> size="icon"
)} className="text-on-surface-variant hover:text-primary"
>
<Pencil size={20} />
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
setDeletingId(session.id);
}}
variant="ghost"
size="icon"
className="text-on-surface-variant hover:text-error"
>
<Trash2 size={20} />
</Button>
</div> </div>
</div> </div>
</Card>
<div className="flex gap-1">
<button
onClick={(e) => {
e.stopPropagation();
setEditingSession(JSON.parse(JSON.stringify(session)));
}}
className="p-2 text-on-surface-variant hover:text-primary hover:bg-surface-container-high rounded-full transition-colors"
>
<Pencil size={20} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setDeletingId(session.id);
}}
className="p-2 text-on-surface-variant hover:text-error hover:bg-surface-container-high rounded-full transition-colors"
>
<Trash2 size={20} />
</button>
</div>
</div>
</div>
)
})}
{/* Quick Log Sessions */}
{sessions.filter(s => s.type === 'QUICK_LOG').length > 0 && (
<div className="mt-8">
<h3 className="text-xl font-medium text-on-surface mb-4 px-2">{t('quick_log', lang)}</h3>
{Object.entries(
sessions
.filter(s => s.type === 'QUICK_LOG')
.reduce((groups: Record<string, WorkoutSession[]>, 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]) => (
<div key={date} className="mb-4">
<div className="text-sm text-on-surface-variant px-2 mb-2 font-medium">{date}</div>
<div className="space-y-2">
{daySessions.flatMap(session => session.sets).map((set, idx) => (
<div
key={set.id}
className="bg-surface-container-low rounded-xl p-4 border border-outline-variant/10 flex justify-between items-center"
>
<div className="flex-1">
<div className="font-medium text-on-surface">
{set.exerciseName}
{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}
</div>
<div className="text-sm text-on-surface-variant mt-1">
{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}`}
</div>
<div className="text-xs text-on-surface-variant mt-1">
{new Date(set.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => {
// Find the session this set belongs to and open edit mode
const parentSession = daySessions.find(s => s.sets.some(st => st.id === set.id));
if (parentSession) {
setEditingSession(JSON.parse(JSON.stringify(parentSession)));
}
}}
className="p-2 text-on-surface-variant hover:text-primary hover:bg-surface-container-high rounded-full transition-colors"
>
<Pencil size={18} />
</button>
<button
onClick={() => {
// Find the session and set up for deletion
const parentSession = daySessions.find(s => s.sets.some(st => st.id === set.id));
if (parentSession) {
setDeletingSetInfo({ sessionId: parentSession.id, setId: set.id });
}
}}
className="p-2 text-on-surface-variant hover:text-error hover:bg-surface-container-high rounded-full transition-colors"
>
<Trash2 size={18} />
</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* Quick Log Sessions */}
{sessions.filter(s => s.type === 'QUICK_LOG').length > 0 && (
<div className="mt-8">
<h3 className="text-xl font-medium text-on-surface mb-4 px-2">{t('quick_log', lang)}</h3>
{(Object.entries(
sessions
.filter(s => s.type === 'QUICK_LOG')
.reduce<Record<string, WorkoutSession[]>>((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]) => (
<div key={date} className="mb-4">
<div className="text-sm text-on-surface-variant px-2 mb-2 font-medium">{date}</div>
<div className="space-y-2">
{daySessions
.reduce<WorkoutSet[]>((acc, session) => acc.concat(session.sets), [])
.map((set, idx) => (
<Card
key={set.id}
className="bg-surface-container-low flex justify-between items-center"
>
<div className="flex-1">
<div className="font-medium text-on-surface">
{set.exerciseName}
{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}
</div>
<div className="text-sm text-on-surface-variant mt-1">
{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}`}
</div>
<div className="text-xs text-on-surface-variant mt-1">
{new Date(set.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
<div className="flex gap-1">
<Button
onClick={() => {
// Find the session this set belongs to and open edit mode
const parentSession = daySessions.find(s => s.sets.some(st => st.id === set.id));
if (parentSession) {
setEditingSession(JSON.parse(JSON.stringify(parentSession)));
}
}}
variant="ghost"
size="icon"
className="h-8 w-8"
>
<Pencil size={16} />
</Button>
<Button
onClick={() => {
// Find the session and set up for deletion
const parentSession = daySessions.find(s => s.sets.some(st => st.id === set.id));
if (parentSession) {
setDeletingSetInfo({ sessionId: parentSession.id, setId: set.id });
}
}}
variant="ghost"
size="icon"
className="h-8 w-8 text-error hover:text-error"
>
<Trash2 size={16} />
</Button>
</div>
</Card>
))}
</div>
</div>
))}
</div>
)}
</div>
</div> </div>
{/* DELETE CONFIRMATION DIALOG (MD3) */} {/* DELETE CONFIRMATION MODAL */}
{(deletingId || deletingSetInfo) && ( <Modal
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> isOpen={!!(deletingId || deletingSetInfo)}
<div className="bg-surface-container w-full max-w-xs rounded-[28px] p-6 shadow-elevation-3"> onClose={() => {
<h3 className="text-xl font-normal text-on-surface mb-2"> setDeletingId(null);
{deletingId ? t('delete_workout', lang) : t('delete_set', lang) || 'Delete Set'} setDeletingSetInfo(null);
</h3> }}
<p className="text-sm text-on-surface-variant mb-8"> title={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?'} maxWidth="sm"
</p> >
<div className="flex justify-end gap-2"> <div className="space-y-6">
<button <p className="text-sm text-on-surface-variant">
onClick={() => { {deletingId ? t('delete_confirm', lang) : t('delete_set_confirm', lang) || 'Are you sure you want to delete this set?'}
setDeletingId(null); </p>
setDeletingSetInfo(null); <div className="flex justify-end gap-2">
}} <Button
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5" onClick={() => {
> setDeletingId(null);
{t('cancel', lang)} setDeletingSetInfo(null);
</button> }}
<button variant="ghost"
onClick={handleConfirmDelete} size="sm"
className="px-4 py-2 rounded-full bg-error-container text-on-error-container font-medium" >
> {t('cancel', lang)}
{t('delete', lang)} </Button>
</button> <Button
</div> onClick={handleConfirmDelete}
variant="destructive"
size="sm"
>
{t('delete', lang)}
</Button>
</div> </div>
</div> </div>
)} </Modal>
{/* EDIT SESSION FULLSCREEN DIALOG */} {/* EDIT SESSION MODAL */}
{editingSession && ( {editingSession && (
<div className="fixed inset-0 z-[60] bg-surface flex flex-col animate-in slide-in-from-bottom-10 duration-200"> <Modal
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container shadow-elevation-1"> isOpen={!!editingSession}
<button onClick={() => setEditingSession(null)} className="text-on-surface-variant hover:text-on-surface"> onClose={() => setEditingSession(null)}
<X /> title={t('edit', lang)}
</button> maxWidth="lg"
<h2 className="text-lg font-medium text-on-surface">{t('edit', lang)}</h2> >
<button onClick={handleSaveEdit} className="text-primary font-medium flex items-center gap-2"> <div className="space-y-6">
{t('save', lang)}
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Meta Info */} {/* Meta Info */}
<div className="bg-surface-container p-4 rounded-xl border border-outline-variant/20 space-y-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-3">
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
<label className="text-[10px] text-on-surface-variant font-bold block">{t('start_time', lang)}</label>
<input
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"
/>
</div>
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
<label className="text-[10px] text-on-surface-variant font-bold block">{t('end_time', lang)}</label>
<input
type="datetime-local"
value={editingSession.endTime ? formatDateForInput(editingSession.endTime) : ''}
onChange={(e) => setEditingSession({ ...editingSession, endTime: parseDateFromInput(e.target.value) })}
className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1"
/>
</div>
</div>
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant"> <div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
<label className="text-[10px] text-on-surface-variant font-bold block">{t('weight_kg', lang)}</label> <label className="text-[10px] text-on-surface-variant font-bold block">{t('start_time', lang)}</label>
<input <input
type="number" type="datetime-local"
value={editingSession.userBodyWeight || ''} value={formatDateForInput(editingSession.startTime)}
onChange={(e) => setEditingSession({ ...editingSession, userBodyWeight: parseFloat(e.target.value) })} onChange={(e) => setEditingSession({ ...editingSession, startTime: parseDateFromInput(e.target.value) })}
className="w-full bg-transparent text-on-surface focus:outline-none text-lg mt-1" className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1"
/> />
</div> </div>
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
<label className="text-[10px] text-on-surface-variant font-bold block">{t('end_time', lang)}</label>
<input
type="datetime-local"
value={editingSession.endTime ? formatDateForInput(editingSession.endTime) : ''}
onChange={(e) => setEditingSession({ ...editingSession, endTime: parseDateFromInput(e.target.value) })}
className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1"
/>
</div>
</div>
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
<label className="text-[10px] text-on-surface-variant font-bold block">{t('weight_kg', lang)}</label>
<input
type="number"
value={editingSession.userBodyWeight || ''}
onChange={(e) => setEditingSession({ ...editingSession, userBodyWeight: parseFloat(e.target.value) })}
className="w-full bg-transparent text-on-surface focus:outline-none text-lg mt-1"
/>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-medium text-primary ml-1">{t('sets_count', lang)} ({editingSession.sets.length})</h3> <h3 className="text-sm font-medium text-primary ml-1">{t('sets_count', lang)} ({editingSession.sets.length})</h3>
{editingSession.sets.map((set, idx) => ( {editingSession.sets.map((set, idx) => (
<div key={set.id} className="bg-surface-container p-3 rounded-xl border border-outline-variant/20 flex flex-col gap-3 shadow-sm"> <div key={set.id} className="bg-surface-container-low p-3 rounded-xl border border-outline-variant/20 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-outline-variant pb-2"> <div className="flex justify-between items-center border-b border-outline-variant/50 pb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-secondary-container text-on-secondary-container text-xs font-bold flex items-center justify-center">{idx + 1}</span> <span className="w-6 h-6 rounded-full bg-secondary-container text-on-secondary-container text-xs font-bold flex items-center justify-center">{idx + 1}</span>
<span className="font-medium text-on-surface text-sm">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase(), lang)}</span>}</span> <span className="font-medium text-on-surface text-sm">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</span>
</div> </div>
<button <Button
onClick={() => handleDeleteSet(set.id)} onClick={() => handleDeleteSet(set.id)}
className="text-on-surface-variant hover:text-error p-1 rounded hover:bg-error-container/10 transition-colors" variant="ghost"
size="icon"
className="h-8 w-8 text-on-surface-variant hover:text-error hover:bg-error-container/10"
title={t('delete', lang)} title={t('delete', lang)}
> >
<Trash2 size={18} /> <Trash2 size={16} />
</button> </Button>
</div> </div>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && ( {(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && (
<div className="bg-surface-container-high rounded px-2 py-1"> <div className="bg-surface-container-high rounded px-2 py-1">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Dumbbell size={10} /> {t('weight_kg', lang)}</label> <label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Dumbbell size={10} /> {t('weight_kg', lang)}</label>
@@ -450,11 +462,16 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
</div> </div>
))} ))}
</div> </div>
<div className="flex justify-end pt-4 border-t border-outline-variant">
<Button onClick={handleSaveEdit}>
<Save size={16} className="mr-2" />
{t('save', lang)}
</Button>
</div>
</div> </div>
</div> </Modal>
)} )}
</div> </div>
); );
}; };

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react'; 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 { WorkoutPlan, ExerciseDef, PlannedSet, Language, ExerciseType } from '../types';
import { getExercises, saveExercise } from '../services/storage'; import { getExercises, saveExercise } from '../services/storage';
import { t } from '../services/i18n'; import { t } from '../services/i18n';
@@ -11,6 +10,8 @@ import { useActiveWorkout } from '../context/ActiveWorkoutContext';
import FilledInput from './FilledInput'; import FilledInput from './FilledInput';
import { toTitleCase } from '../utils/text'; import { toTitleCase } from '../utils/text';
import { Button } from './ui/Button';
import { Card } from './ui/Card';
interface PlansProps { interface PlansProps {
lang: Language; lang: Language;
@@ -162,26 +163,25 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
if (isEditing) { if (isEditing) {
return ( return (
<div className="h-full flex flex-col bg-surface"> <div className="h-full flex flex-col bg-surface">
<div className="px-4 py-3 bg-surface-container border-b border-outline-variant flex justify-between items-center"> <div className="px-4 py-3 bg-surface-container border-b border-outline-variant flex justify-between items-center shrink-0">
<button onClick={() => setIsEditing(false)} className="p-2 text-on-surface-variant hover:bg-white/5 rounded-full"><X /></button> <Button onClick={() => setIsEditing(false)} variant="ghost" size="icon">
<X size={20} />
</Button>
<h2 className="text-title-medium font-medium text-on-surface">{t('plan_editor', lang)}</h2> <h2 className="text-title-medium font-medium text-on-surface">{t('plan_editor', lang)}</h2>
<button onClick={handleSave} className="p-2 text-primary font-medium"> <Button onClick={handleSave} variant="ghost" className="text-primary font-medium hover:bg-primary-container/10">
{t('save', lang)} {t('save', lang)}
</button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto p-4 space-y-6"> <div className="flex-1 overflow-y-auto p-4 space-y-6">
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2"> <FilledInput
<label className="text-[10px] text-on-surface-variant font-medium">{t('ex_name', lang)}</label> label={t('ex_name', lang)}
<input value={name}
className="w-full bg-transparent text-xl text-on-surface focus:outline-none pt-1 pb-2" onChange={(e: any) => setName(e.target.value)}
placeholder={t('plan_name_ph', lang)} type="text"
value={name} autocapitalize="words"
onChange={(e) => setName(e.target.value)} onBlur={() => setName(toTitleCase(name))}
autoCapitalize="words" />
onBlur={() => setName(toTitleCase(name))}
/>
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2"> <div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2">
<label className="text-[10px] text-on-surface-variant font-medium">{t('prep_title', lang)}</label> <label className="text-[10px] text-on-surface-variant font-medium">{t('prep_title', lang)}</label>
@@ -200,9 +200,9 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
<div className="space-y-2"> <div className="space-y-2">
{steps.map((step, idx) => ( {steps.map((step, idx) => (
<div <Card
key={step.id} key={step.id}
className={`bg-surface-container rounded-xl p-3 flex items-center gap-3 shadow-elevation-1 cursor-move transition-all hover:bg-surface-container-high ${draggingIndex === idx ? 'opacity-50 ring-2 ring-primary bg-surface-container-high' : ''}`} className={`flex items-center gap-3 transition-all hover:bg-surface-container-high ${draggingIndex === idx ? 'opacity-50 ring-2 ring-primary bg-surface-container-high' : ''}`}
draggable draggable
onDragStart={() => onDragStart(idx)} onDragStart={() => onDragStart(idx)}
onDragEnter={() => onDragEnter(idx)} onDragEnter={() => onDragEnter(idx)}
@@ -221,7 +221,7 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
<div className="text-base font-medium text-on-surface">{step.exerciseName}</div> <div className="text-base font-medium text-on-surface">{step.exerciseName}</div>
<label className="flex items-center gap-2 mt-1 cursor-pointer w-fit"> <label className="flex items-center gap-2 mt-1 cursor-pointer w-fit">
<div className={`w-4 h-4 border rounded flex items-center justify-center ${step.isWeighted ? 'bg-primary border-primary' : 'border-outline'}`}> <div className={`w-4 h-4 border rounded flex items-center justify-center ${step.isWeighted ? 'bg-primary border-primary' : 'border-outline'}`}>
{step.isWeighted && <Scale size={10} className="text-on-primary" />} {step.isWeighted && <Dumbbell size={10} className="text-on-primary" />}
</div> </div>
<input <input
type="checkbox" type="checkbox"
@@ -232,32 +232,36 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
<span className="text-xs text-on-surface-variant">{t('weighted', lang)}</span> <span className="text-xs text-on-surface-variant">{t('weighted', lang)}</span>
</label> </label>
</div> </div>
<button onClick={() => removeStep(step.id)} className="text-on-surface-variant hover:text-error p-2"> <Button onClick={() => removeStep(step.id)} variant="ghost" size="icon" className="text-on-surface-variant hover:text-error hover:bg-error/10">
<X size={20} /> <X size={20} />
</button> </Button>
</div> </Card>
))} ))}
</div> </div>
<button <Button
onClick={() => setShowExerciseSelector(true)} onClick={() => setShowExerciseSelector(true)}
className="w-full py-4 rounded-full border border-outline text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary-container/10 transition-all" variant="outline"
fullWidth
className="py-6 rounded-full border border-outline text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary-container/10 transition-all h-auto"
> >
<Plus size={20} /> <Plus size={20} />
{t('add_exercise', lang)} {t('add_exercise', lang)}
</button> </Button>
</div> </div>
</div> </div>
{showExerciseSelector && ( {showExerciseSelector && (
<div className="fixed inset-0 bg-surface z-50 flex flex-col animate-in slide-in-from-bottom-full duration-200"> <div className="fixed inset-0 bg-surface z-50 flex flex-col animate-in slide-in-from-bottom-full duration-200">
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container"> <div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container shrink-0">
<span className="font-medium text-on-surface">{t('select_exercise', lang)}</span> <span className="font-medium text-on-surface">{t('select_exercise', lang)}</span>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => setIsCreatingExercise(true)} className="p-2 text-primary hover:bg-primary-container/20 rounded-full"> <Button onClick={() => setIsCreatingExercise(true)} variant="ghost" size="icon" className="text-primary hover:bg-primary-container/20">
<Plus size={20} /> <Plus size={20} />
</button> </Button>
<button onClick={() => setShowExerciseSelector(false)}><X /></button> <Button onClick={() => setShowExerciseSelector(false)} variant="ghost" size="icon">
<X size={20} />
</Button>
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto p-2">
@@ -278,9 +282,11 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
{isCreatingExercise && ( {isCreatingExercise && (
<div className="fixed inset-0 bg-surface z-[60] flex flex-col animate-in slide-in-from-bottom-full duration-200"> <div className="fixed inset-0 bg-surface z-[60] flex flex-col animate-in slide-in-from-bottom-full duration-200">
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container"> <div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container shrink-0">
<h3 className="text-title-medium font-medium text-on-surface">{t('create_exercise', lang)}</h3> <h3 className="text-title-medium font-medium text-on-surface">{t('create_exercise', lang)}</h3>
<button onClick={() => setIsCreatingExercise(false)} className="p-2 text-on-surface-variant hover:bg-white/5 rounded-full"><X /></button> <Button onClick={() => setIsCreatingExercise(false)} variant="ghost" size="icon" className="text-on-surface-variant hover:bg-white/5">
<X size={20} />
</Button>
</div> </div>
<div className="p-4 space-y-6 overflow-y-auto flex-1"> <div className="p-4 space-y-6 overflow-y-auto flex-1">
@@ -330,13 +336,14 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
)} )}
<div className="flex justify-end mt-4"> <div className="flex justify-end mt-4">
<button <Button
onClick={handleCreateExercise} onClick={handleCreateExercise}
className="w-full h-14 bg-primary text-on-primary rounded-full font-medium shadow-elevation-1 flex items-center justify-center gap-2" fullWidth
size="lg"
> >
<CheckCircle size={20} /> <CheckCircle size={20} className="mr-2" />
{t('create_btn', lang)} {t('create_btn', lang)}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
@@ -349,7 +356,7 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
return ( return (
<div className="h-full flex flex-col bg-surface relative"> <div className="h-full flex flex-col bg-surface relative">
<div className="p-4 bg-surface-container shadow-elevation-1 z-10"> <div className="p-4 bg-surface-container shadow-elevation-1 z-10 shrink-0">
<h2 className="text-2xl font-normal text-on-surface">{t('my_plans', lang)}</h2> <h2 className="text-2xl font-normal text-on-surface">{t('my_plans', lang)}</h2>
</div> </div>
@@ -363,23 +370,27 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
</div> </div>
) : ( ) : (
plans.map(plan => ( plans.map(plan => (
<div key={plan.id} className="bg-surface-container rounded-xl p-4 shadow-elevation-1 border border-outline-variant/20 relative overflow-hidden"> <Card key={plan.id} className="relative overflow-hidden">
<div className="flex justify-between items-start mb-2"> <div className="flex justify-between items-start mb-2">
<h3 className="text-xl font-normal text-on-surface">{plan.name}</h3> <h3 className="text-xl font-normal text-on-surface">{plan.name}</h3>
<button <Button
onClick={(e) => handleDelete(plan.id, e)} onClick={(e) => handleDelete(plan.id, e)}
className="text-on-surface-variant hover:text-error p-2 rounded-full hover:bg-white/5" variant="ghost"
size="icon"
className="text-on-surface-variant hover:text-error hover:bg-white/5"
> >
<Trash2 size={20} /> <Trash2 size={20} />
</button> </Button>
</div> </div>
<div className="absolute top-4 right-14"> <div className="absolute top-4 right-14">
<button <Button
onClick={(e) => { e.stopPropagation(); handleEdit(plan); }} onClick={(e) => { e.stopPropagation(); handleEdit(plan); }}
className="text-on-surface-variant hover:text-primary p-2 rounded-full hover:bg-white/5" variant="ghost"
size="icon"
className="text-on-surface-variant hover:text-primary hover:bg-white/5"
> >
<Edit2 size={20} /> <Edit2 size={20} />
</button> </Button>
</div> </div>
<p className="text-on-surface-variant text-sm line-clamp-2 mb-4 min-h-[1.25rem]"> <p className="text-on-surface-variant text-sm line-clamp-2 mb-4 min-h-[1.25rem]">
{plan.description || t('prep_no_instructions', lang)} {plan.description || t('prep_no_instructions', lang)}
@@ -388,15 +399,15 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
<div className="text-xs font-medium text-primary bg-primary-container/20 px-3 py-1 rounded-full"> <div className="text-xs font-medium text-primary bg-primary-container/20 px-3 py-1 rounded-full">
{plan.steps.length} {t('exercises_count', lang)} {plan.steps.length} {t('exercises_count', lang)}
</div> </div>
<button <Button
onClick={() => startSession(plan)} onClick={() => startSession(plan)}
className="flex items-center gap-2 bg-primary text-on-primary px-5 py-2 rounded-full text-sm font-medium hover:shadow-elevation-2 transition-all" className="flex items-center gap-2"
> >
<PlayCircle size={18} /> <PlayCircle size={18} />
{t('start', lang)} {t('start', lang)}
</button> </Button>
</div> </div>
</div> </Card>
)) ))
)} )}
</div> </div>

View File

@@ -9,6 +9,9 @@ import ExerciseModal from './ExerciseModal';
import FilledInput from './FilledInput'; import FilledInput from './FilledInput';
import { t } from '../services/i18n'; import { t } from '../services/i18n';
import Snackbar from './Snackbar'; import Snackbar from './Snackbar';
import { Button } from './ui/Button';
import { Card } from './ui/Card';
import { Modal } from './ui/Modal';
interface ProfileProps { interface ProfileProps {
user: User; user: User;
@@ -238,338 +241,343 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
return ( return (
<div className="h-full flex flex-col bg-surface"> <div className="h-full flex flex-col bg-surface">
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center justify-between z-10"> <div className="p-4 bg-surface-container shadow-elevation-1 flex items-center justify-between z-10 shrink-0">
<h2 className="text-xl font-normal text-on-surface flex items-center gap-2"> <h2 className="text-xl font-normal text-on-surface flex items-center gap-2">
<UserIcon size={20} /> <UserIcon size={20} />
{t('profile_title', lang)} {t('profile_title', lang)}
</h2> </h2>
<button onClick={onLogout} className="text-error flex items-center gap-1 text-sm font-medium hover:bg-error-container/10 px-3 py-1 rounded-full"> <Button onClick={onLogout} variant="ghost" size="sm" className="text-error hover:bg-error-container/10">
<LogOut size={16} /> {t('logout', lang)} <LogOut size={16} className="mr-1" /> {t('logout', lang)}
</button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto p-4 space-y-6 pb-24"> <div className="flex-1 overflow-y-auto p-4 space-y-6 pb-24">
<div className="max-w-2xl mx-auto space-y-6">
{/* User Info Card */} {/* User Info Card */}
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20"> <Card>
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<div className="w-14 h-14 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xl font-bold"> <div className="w-14 h-14 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xl font-bold">
{user.email[0].toUpperCase()} {user.email[0].toUpperCase()}
</div>
<div>
<div className="text-lg font-medium text-on-surface">{user.email}</div>
<div className="text-xs text-on-surface-variant bg-surface-container-high px-2 py-1 rounded w-fit mt-1 flex items-center gap-1">
{user.role === 'ADMIN' && <Shield size={10} />}
{user.role}
</div> </div>
</div> <div>
</div> <div className="text-lg font-medium text-on-surface">{user.email}</div>
<div className="text-xs text-on-surface-variant bg-surface-container-high px-2 py-1 rounded w-fit mt-1 flex items-center gap-1">
<h3 className="text-sm font-bold text-primary mb-4">{t('personal_data', lang)}</h3> {user.role === 'ADMIN' && <Shield size={10} />}
<div className="grid grid-cols-2 gap-4 mb-4"> {user.role}
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Scale size={10} /> {t('weight_kg', lang)}</label>
<input
type="number"
step="0.1"
value={weight}
onChange={(e) => setWeight(e.target.value)}
className="w-full bg-transparent text-on-surface focus:outline-none"
/>
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Ruler size={10} /> {t('height', lang)}</label>
<input type="number" value={height} onChange={(e) => setHeight(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none" />
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Calendar size={10} /> {t('birth_date', lang)}</label>
<input type="date" value={birthDate} onChange={(e) => setBirthDate(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1" />
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><PersonStanding size={10} /> {t('gender', lang)}</label>
<select value={gender} onChange={(e) => setGender(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1 bg-surface-container-high">
<option value="MALE">{t('male', lang)}</option>
<option value="FEMALE">{t('female', lang)}</option>
<option value="OTHER">{t('other', lang)}</option>
</select>
</div>
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2 mb-4">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Globe size={10} /> {t('language', lang)}</label>
<select value={lang} onChange={(e) => onLanguageChange(e.target.value as Language)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1 bg-surface-container-high">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
<button onClick={handleSaveProfile} className="w-full py-2 rounded-full border border-outline text-primary text-sm font-medium hover:bg-primary-container/10 flex justify-center gap-2 items-center">
<Save size={16} /> {t('save_profile', lang)}
</button>
</div>
{/* WEIGHT TRACKER */}
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
<button
onClick={() => setShowWeightTracker(!showWeightTracker)}
className="w-full flex justify-between items-center text-sm font-bold text-primary"
>
<span className="flex items-center gap-2"><Scale size={14} /> Weight Tracker</span>
{showWeightTracker ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{showWeightTracker && (
<div className="mt-4 space-y-4">
<div className="flex gap-2 items-end">
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2 flex-1">
<label className="text-[10px] text-on-surface-variant font-medium">Today's Weight (kg)</label>
<input
type="number"
step="0.1"
value={todayWeight}
onChange={(e) => setTodayWeight(e.target.value)}
className="w-full bg-transparent text-on-surface focus:outline-none"
placeholder="Enter weight..."
/>
</div> </div>
<button
onClick={handleLogWeight}
className="bg-primary text-on-primary px-4 py-3 rounded-lg font-medium text-sm mb-[1px]"
>
Log
</button>
</div>
<div className="space-y-2 max-h-60 overflow-y-auto">
<h4 className="text-xs font-medium text-on-surface-variant">History</h4>
{weightHistory.length === 0 ? (
<p className="text-xs text-on-surface-variant italic">No weight records yet.</p>
) : (
weightHistory.map(record => (
<div key={record.id} className="flex justify-between items-center p-3 bg-surface-container-high rounded-lg">
<span className="text-sm text-on-surface">{new Date(record.date).toLocaleDateString()}</span>
<span className="text-sm font-bold text-primary">{record.weight} kg</span>
</div>
))
)}
</div> </div>
</div> </div>
)}
</div>
{/* EXERCISE MANAGER */} <h3 className="text-sm font-bold text-primary mb-4">{t('personal_data', lang)}</h3>
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20"> <div className="grid grid-cols-2 gap-4 mb-4">
<button <div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
onClick={() => setShowExercises(!showExercises)} <label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Scale size={10} /> {t('weight_kg', lang)}</label>
className="w-full flex justify-between items-center text-sm font-bold text-primary"
>
<span className="flex items-center gap-2"><Dumbbell size={14} /> {t('manage_exercises', lang)}</span>
{showExercises ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{showExercises && (
<div className="mt-4 space-y-4">
<button
onClick={() => setIsCreatingEx(true)}
className="w-full py-2 border border-outline border-dashed rounded-lg text-sm text-on-surface-variant hover:bg-surface-container-high flex items-center justify-center gap-2"
>
<Plus size={16} /> {t('create_exercise', lang)}
</button>
<FilledInput
label={t('filter_by_name', lang) || 'Filter by name'}
value={exerciseNameFilter}
onChange={(e: any) => setExerciseNameFilter(e.target.value)}
icon={<i className="hidden" />} // No icon needed or maybe use Search icon? Profile doesn't import Search. I'll omit icon if optional.
type="text"
autoFocus={false}
/>
<div className="flex items-center justify-end gap-2">
<label className="text-xs text-on-surface-variant">{t('show_archived', lang)}</label>
<input <input
type="checkbox" type="number"
checked={showArchived} step="0.1"
onChange={(e) => setShowArchived(e.target.checked)} value={weight}
className="accent-primary" onChange={(e) => setWeight(e.target.value)}
className="w-full bg-transparent text-on-surface focus:outline-none"
/> />
</div> </div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<div className="space-y-2"> <label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Ruler size={10} /> {t('height', lang)}</label>
{exercises <input type="number" value={height} onChange={(e) => setHeight(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none" />
.filter(e => showArchived || !e.isArchived) </div>
.filter(e => e.name.toLowerCase().includes(exerciseNameFilter.toLowerCase())) <div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
.sort((a, b) => a.name.localeCompare(b.name)) <label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Calendar size={10} /> {t('birth_date', lang)}</label>
.map(ex => ( <input type="date" value={birthDate} onChange={(e) => setBirthDate(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1" />
<div key={ex.id} className={`p-3 rounded-lg flex justify-between items-center border border-outline-variant/20 ${ex.isArchived ? 'bg-surface-container-low opacity-60' : 'bg-surface-container-high'}`}> </div>
<div className="overflow-hidden mr-2"> <div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<div className="font-medium text-sm text-on-surface truncate">{ex.name}</div> <label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><PersonStanding size={10} /> {t('gender', lang)}</label>
<div className="text-xs text-on-surface-variant"> <select value={gender} onChange={(e) => setGender(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1 bg-surface-container-high">
{exerciseTypeLabels[ex.type]} <option value="MALE">{t('male', lang)}</option>
{ex.isUnilateral && `, ${t('unilateral', lang)}`} <option value="FEMALE">{t('female', lang)}</option>
</div> <option value="OTHER">{t('other', lang)}</option>
</div> </select>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => setEditingExercise(ex)} className="p-2 text-on-surface-variant hover:text-primary hover:bg-white/5 rounded-full">
<Pencil size={16} />
</button>
<button
onClick={() => handleArchiveExercise(ex, !ex.isArchived)}
className={`p-2 rounded-full hover:bg-white/5 ${ex.isArchived ? 'text-primary' : 'text-on-surface-variant'}`}
title={ex.isArchived ? t('unarchive', lang) : t('archive', lang)}
>
{ex.isArchived ? <ArchiveRestore size={16} /> : <Archive size={16} />}
</button>
</div>
</div>
))}
</div> </div>
</div> </div>
)}
</div>
{/* Change Password */} <div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2 mb-4">
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20"> <label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Globe size={10} /> {t('language', lang)}</label>
<h3 className="text-sm font-bold text-primary mb-4 flex items-center gap-2"><Lock size={14} /> {t('change_pass_btn', lang)}</h3> <select value={lang} onChange={(e) => onLanguageChange(e.target.value as Language)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1 bg-surface-container-high">
<div className="flex gap-2"> <option value="en">English</option>
<input <option value="ru">Русский</option>
type="password" </select>
placeholder={t('change_pass_new', lang)} </div>
value={newPassword}
onChange={(e) => 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"
/>
<button onClick={handleChangePassword} className="bg-secondary-container text-on-secondary-container px-4 rounded-lg font-medium text-sm">OK</button>
</div>
{passMsg && <p className="text-xs text-primary mt-2">{passMsg}</p>}
</div>
{/* User Self Deletion (Not for Admin) */} <Button onClick={handleSaveProfile} variant="outline" fullWidth>
{user.role !== 'ADMIN' && ( <Save size={16} className="mr-2" /> {t('save_profile', lang)}
<div className="bg-surface-container rounded-xl p-4 border border-error/30"> </Button>
<h3 className="text-sm font-bold text-error mb-2 flex items-center gap-2"><Trash2 size={14} /> {t('delete_account', lang)}</h3> </Card>
{!showDeleteConfirm ? (
<button onClick={() => setShowDeleteConfirm(true)} className="text-error text-sm hover:underline"> {/* WEIGHT TRACKER */}
{t('delete', lang)} <Card>
</button> <button
) : ( onClick={() => setShowWeightTracker(!showWeightTracker)}
<div className="space-y-2"> className="w-full flex justify-between items-center text-sm font-bold text-primary"
<p className="text-xs text-error">{t('delete_account_confirm', lang)}</p> >
<div className="flex gap-2"> <span className="flex items-center gap-2"><Scale size={14} /> Weight Tracker</span>
<button onClick={() => setShowDeleteConfirm(false)} className="text-xs px-3 py-1 bg-surface-container-high rounded-full">{t('cancel', lang)}</button> {showWeightTracker ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
<button onClick={handleDeleteMyAccount} className="text-xs px-3 py-1 bg-error text-on-error rounded-full">{t('delete', lang)}</button> </button>
{showWeightTracker && (
<div className="mt-4 space-y-4">
<div className="flex gap-2 items-end">
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2 flex-1">
<label className="text-[10px] text-on-surface-variant font-medium">Today's Weight (kg)</label>
<input
type="number"
step="0.1"
value={todayWeight}
onChange={(e) => setTodayWeight(e.target.value)}
className="w-full bg-transparent text-on-surface focus:outline-none"
placeholder="Enter weight..."
/>
</div>
<Button
onClick={handleLogWeight}
className="mb-[1px]"
>
Log
</Button>
</div>
<div className="space-y-2 max-h-60 overflow-y-auto">
<h4 className="text-xs font-medium text-on-surface-variant">History</h4>
{weightHistory.length === 0 ? (
<p className="text-xs text-on-surface-variant italic">No weight records yet.</p>
) : (
weightHistory.map(record => (
<div key={record.id} className="flex justify-between items-center p-3 bg-surface-container-high rounded-lg">
<span className="text-sm text-on-surface">{new Date(record.date).toLocaleDateString()}</span>
<span className="text-sm font-bold text-primary">{record.weight} kg</span>
</div>
))
)}
</div> </div>
</div> </div>
)} )}
</div> </Card>
)}
{/* ADMIN AREA */} {/* EXERCISE MANAGER */}
{user.role === 'ADMIN' && ( <Card>
<div className="bg-surface-container rounded-xl p-4 border border-primary/30 relative overflow-hidden"> <button
<div className="absolute top-0 right-0 p-2 bg-primary/10 rounded-bl-xl"> onClick={() => setShowExercises(!showExercises)}
<Shield size={16} className="text-primary" /> className="w-full flex justify-between items-center text-sm font-bold text-primary"
</div> >
<h3 className="text-sm font-bold text-primary mb-4 flex items-center gap-2"><UserPlus size={14} /> {t('admin_area', lang)}</h3> <span className="flex items-center gap-2"><Dumbbell size={14} /> {t('manage_exercises', lang)}</span>
{showExercises ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{/* Create User */} {showExercises && (
<div className="space-y-3 mb-6"> <div className="mt-4 space-y-4">
<h4 className="text-xs font-medium text-on-surface-variant">{t('create_user', lang)}</h4> <button
<FilledInput onClick={() => setIsCreatingEx(true)}
label="Email" className="w-full py-2 border border-outline border-dashed rounded-lg text-sm text-on-surface-variant hover:bg-surface-container-high flex items-center justify-center gap-2"
value={newUserEmail} >
onChange={(e) => setNewUserEmail(e.target.value)} <Plus size={16} /> {t('create_exercise', lang)}
type="email" </button>
/>
<FilledInput
label={t('login_password', lang)}
value={newUserPass}
onChange={(e) => setNewUserPass(e.target.value)}
type="text"
/>
<button onClick={handleCreateUser} className="w-full py-2 bg-primary text-on-primary rounded-full text-sm font-medium">
{t('create_btn', lang)}
</button>
{createMsg && <p className="text-xs text-error text-center font-medium">{createMsg}</p>}
</div>
{/* User List */} <FilledInput
<div className="border-t border-outline-variant pt-4"> label={t('filter_by_name', lang) || 'Filter by name'}
<button value={exerciseNameFilter}
onClick={() => setShowUserList(!showUserList)} onChange={(e: any) => setExerciseNameFilter(e.target.value)}
className="w-full flex justify-between items-center text-sm font-medium text-on-surface" icon={<i className="hidden" />}
> type="text"
<span>{t('admin_users_list', lang)} ({allUsers.length})</span> autoFocus={false}
{showUserList ? <ChevronUp size={16} /> : <ChevronDown size={16} />} />
</button>
{showUserList && ( <div className="flex items-center justify-end gap-2">
<div className="mt-4 space-y-4"> <label className="text-xs text-on-surface-variant">{t('show_archived', lang)}</label>
{allUsers.map(u => ( <input
<div key={u.id} className="bg-surface-container-high p-3 rounded-lg space-y-3"> type="checkbox"
<div className="flex justify-between items-center"> checked={showArchived}
<div className="overflow-hidden"> onChange={(e) => setShowArchived(e.target.checked)}
<div className="font-medium text-sm text-on-surface truncate">{u.email}</div> className="accent-primary"
<div className="text-xs text-on-surface-variant flex gap-2"> />
<span>{u.role}</span> </div>
{u.isBlocked && <span className="text-error font-bold flex items-center gap-1"><Ban size={10} /> {t('block', lang)}</span>}
<div className="space-y-2">
{exercises
.filter(e => showArchived || !e.isArchived)
.filter(e => e.name.toLowerCase().includes(exerciseNameFilter.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name))
.map(ex => (
<div key={ex.id} className={`p-3 rounded-lg flex justify-between items-center border border-outline-variant/20 ${ex.isArchived ? 'bg-surface-container-low opacity-60' : 'bg-surface-container-high'}`}>
<div className="overflow-hidden mr-2">
<div className="font-medium text-sm text-on-surface truncate">{ex.name}</div>
<div className="text-xs text-on-surface-variant">
{exerciseTypeLabels[ex.type]}
{ex.isUnilateral && `, ${t('unilateral', lang)}`}
</div> </div>
</div> </div>
<div className="flex gap-1"> <div className="flex items-center gap-1 shrink-0">
{u.role !== 'ADMIN' && ( <button onClick={() => setEditingExercise(ex)} className="p-2 text-on-surface-variant hover:text-primary hover:bg-white/5 rounded-full">
<> <Pencil size={16} />
<button </button>
onClick={() => handleAdminBlockUser(u.id, !u.isBlocked)}
className={`p-2 rounded-full ${u.isBlocked ? 'bg-primary/20 text-primary' : 'text-on-surface-variant hover:bg-white/10'}`}
title={u.isBlocked ? t('unblock', lang) : t('block', lang)}
>
<Ban size={16} />
</button>
<button
onClick={() => handleAdminDeleteUser(u.id)}
className="p-2 text-on-surface-variant hover:text-error hover:bg-error/10 rounded-full"
title={t('delete', lang)}
>
<Trash2 size={16} />
</button>
</>
)}
</div>
</div>
{u.role !== 'ADMIN' && (
<div className="flex gap-2 items-center">
<div className="flex-1 flex items-center bg-surface-container rounded px-2 border border-outline-variant/20">
<KeyRound size={12} className="text-on-surface-variant mr-2" />
<input
type="text"
placeholder={t('change_pass_new', lang)}
className="bg-transparent text-xs py-2 w-full focus:outline-none text-on-surface"
value={adminPassResetInput[u.id] || ''}
onChange={(e) => setAdminPassResetInput({ ...adminPassResetInput, [u.id]: e.target.value })}
/>
</div>
<button <button
onClick={() => handleAdminResetPass(u.id)} onClick={() => handleArchiveExercise(ex, !ex.isArchived)}
className="text-xs bg-secondary-container text-on-secondary-container px-3 py-2 rounded font-medium" className={`p-2 rounded-full hover:bg-white/5 ${ex.isArchived ? 'text-primary' : 'text-on-surface-variant'}`}
title={ex.isArchived ? t('unarchive', lang) : t('archive', lang)}
> >
{t('reset_pass', lang)} {ex.isArchived ? <ArchiveRestore size={16} /> : <Archive size={16} />}
</button> </button>
</div> </div>
)} </div>
</div> ))}
))} </div>
</div>
)}
</Card>
{/* Change Password */}
<Card>
<h3 className="text-sm font-bold text-primary mb-4 flex items-center gap-2"><Lock size={14} /> {t('change_pass_btn', lang)}</h3>
<div className="flex gap-2">
<input
type="password"
placeholder={t('change_pass_new', lang)}
value={newPassword}
onChange={(e) => 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"
/>
<Button onClick={handleChangePassword} size="sm" variant="secondary">OK</Button>
</div>
{passMsg && <p className="text-xs text-primary mt-2">{passMsg}</p>}
</Card>
{/* User Self Deletion (Not for Admin) */}
{user.role !== 'ADMIN' && (
<Card className="border-error/30">
<h3 className="text-sm font-bold text-error mb-2 flex items-center gap-2"><Trash2 size={14} /> {t('delete_account', lang)}</h3>
{!showDeleteConfirm ? (
<button onClick={() => setShowDeleteConfirm(true)} className="text-error text-sm hover:underline">
{t('delete', lang)}
</button>
) : (
<div className="space-y-2">
<p className="text-xs text-error">{t('delete_account_confirm', lang)}</p>
<div className="flex gap-2">
<Button onClick={() => setShowDeleteConfirm(false)} size="sm" variant="ghost">{t('cancel', lang)}</Button>
<Button onClick={handleDeleteMyAccount} size="sm" variant="destructive">{t('delete', lang)}</Button>
</div>
</div> </div>
)} )}
</div> </Card>
</div> )}
)}
{/* Edit Exercise Modal */} {/* ADMIN AREA */}
{editingExercise && ( {user.role === 'ADMIN' && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <Card className="border-primary/30 relative overflow-hidden">
<div className="bg-surface-container w-full max-w-sm rounded-[28px] p-6 shadow-elevation-3"> <div className="absolute top-0 right-0 p-2 bg-primary/10 rounded-bl-xl">
<h3 className="text-xl font-normal text-on-surface mb-4">{t('edit', lang)}</h3> <Shield size={16} className="text-primary" />
</div>
<h3 className="text-sm font-bold text-primary mb-4 flex items-center gap-2"><UserPlus size={14} /> {t('admin_area', lang)}</h3>
{/* Create User */}
<div className="space-y-3 mb-6">
<h4 className="text-xs font-medium text-on-surface-variant">{t('create_user', lang)}</h4>
<FilledInput
label="Email"
value={newUserEmail}
onChange={(e) => setNewUserEmail(e.target.value)}
type="email"
/>
<FilledInput
label={t('login_password', lang)}
value={newUserPass}
onChange={(e) => setNewUserPass(e.target.value)}
type="text"
/>
<Button onClick={handleCreateUser} fullWidth>
{t('create_btn', lang)}
</Button>
{createMsg && <p className="text-xs text-error text-center font-medium">{createMsg}</p>}
</div>
{/* User List */}
<div className="border-t border-outline-variant pt-4">
<button
onClick={() => setShowUserList(!showUserList)}
className="w-full flex justify-between items-center text-sm font-medium text-on-surface"
>
<span>{t('admin_users_list', lang)} ({allUsers.length})</span>
{showUserList ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{showUserList && (
<div className="mt-4 space-y-4">
{allUsers.map(u => (
<div key={u.id} className="bg-surface-container-high p-3 rounded-lg space-y-3">
<div className="flex justify-between items-center">
<div className="overflow-hidden">
<div className="font-medium text-sm text-on-surface truncate">{u.email}</div>
<div className="text-xs text-on-surface-variant flex gap-2">
<span>{u.role}</span>
{u.isBlocked && <span className="text-error font-bold flex items-center gap-1"><Ban size={10} /> {t('block', lang)}</span>}
</div>
</div>
<div className="flex gap-1">
{u.role !== 'ADMIN' && (
<>
<button
onClick={() => handleAdminBlockUser(u.id, !u.isBlocked)}
className={`p-2 rounded-full ${u.isBlocked ? 'bg-primary/20 text-primary' : 'text-on-surface-variant hover:bg-white/10'}`}
title={u.isBlocked ? t('unblock', lang) : t('block', lang)}
>
<Ban size={16} />
</button>
<button
onClick={() => handleAdminDeleteUser(u.id)}
className="p-2 text-on-surface-variant hover:text-error hover:bg-error/10 rounded-full"
title={t('delete', lang)}
>
<Trash2 size={16} />
</button>
</>
)}
</div>
</div>
{u.role !== 'ADMIN' && (
<div className="flex gap-2 items-center">
<div className="flex-1 flex items-center bg-surface-container rounded px-2 border border-outline-variant/20">
<KeyRound size={12} className="text-on-surface-variant mr-2" />
<input
type="text"
placeholder={t('change_pass_new', lang)}
className="bg-transparent text-xs py-2 w-full focus:outline-none text-on-surface"
value={adminPassResetInput[u.id] || ''}
onChange={(e) => setAdminPassResetInput({ ...adminPassResetInput, [u.id]: e.target.value })}
/>
</div>
<Button
onClick={() => handleAdminResetPass(u.id)}
size="sm"
variant="secondary"
>
{t('reset_pass', lang)}
</Button>
</div>
)}
</div>
))}
</div>
)}
</div>
</Card>
)}
{/* Edit Exercise Modal */}
{editingExercise && (
<Modal
isOpen={!!editingExercise}
onClose={() => setEditingExercise(null)}
title={t('edit', lang)}
maxWidth="sm"
>
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2"> <div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant font-medium">{t('ex_name', lang)}</label> <label className="text-[10px] text-on-surface-variant font-medium">{t('ex_name', lang)}</label>
@@ -591,32 +599,32 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
</div> </div>
)} )}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<button onClick={() => setEditingExercise(null)} className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button> <Button onClick={() => setEditingExercise(null)} variant="ghost">{t('cancel', lang)}</Button>
<button onClick={handleSaveExerciseEdit} className="px-4 py-2 rounded-full bg-primary text-on-primary font-medium">{t('save', lang)}</button> <Button onClick={handleSaveExerciseEdit}>{t('save', lang)}</Button>
</div> </div>
</div> </div>
</div> </Modal>
</div> )}
)}
{/* Create Exercise Modal */} {/* Create Exercise Modal */}
{isCreatingEx && ( {isCreatingEx && (
<ExerciseModal <ExerciseModal
isOpen={isCreatingEx} isOpen={isCreatingEx}
onClose={() => setIsCreatingEx(false)} onClose={() => setIsCreatingEx(false)}
onSave={handleCreateExercise} onSave={handleCreateExercise}
lang={lang} lang={lang}
existingExercises={exercises} existingExercises={exercises}
/> />
)} )}
</div>
<Snackbar
isOpen={snackbar.isOpen}
message={snackbar.message}
type={snackbar.type}
onClose={() => setSnackbar(prev => ({ ...prev, isOpen: false }))}
/>
</div> </div>
<Snackbar
isOpen={snackbar.isOpen}
message={snackbar.message}
type={snackbar.type}
onClose={() => setSnackbar(prev => ({ ...prev, isOpen: false }))}
/>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
size?: 'sm' | 'md' | 'lg' | 'icon';
fullWidth?: boolean;
loading?: boolean;
children: React.ReactNode;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ 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
ref={ref}
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${width} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</button>
);
}
);
Button.displayName = "Button";

View File

@@ -0,0 +1,18 @@
import React from 'react';
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
className?: string;
noPadding?: boolean;
}
export const Card: React.FC<CardProps> = ({ className = '', children, noPadding = false, ...props }) => {
return (
<div
className={`bg-surface-container rounded-xl border border-outline-variant/20 shadow-elevation-1 overflow-hidden ${!noPadding ? 'p-4' : ''} ${className}`}
{...props}
>
{children}
</div>
);
};

View File

@@ -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<ModalProps> = ({ 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(
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 sm:p-6">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
<div className={`relative bg-surface-container w-full ${maxWidthClasses[maxWidth]} rounded-[28px] shadow-elevation-3 animate-in slide-in-from-bottom-10 zoom-in-95 duration-200 flex flex-col max-h-[90vh]`}>
<div className="flex items-center justify-between p-6 pb-2 shrink-0">
<h3 className="text-xl font-normal text-on-surface">{title}</h3>
<button
onClick={onClose}
className="p-2 -mr-2 text-on-surface-variant hover:text-on-surface hover:bg-surface-container-high rounded-full transition-colors"
>
<X size={20} />
</button>
</div>
<div className="p-6 pt-2 overflow-y-auto">
{children}
</div>
</div>
</div>,
document.body
);
};