feat: Initial implementation of GymFlow fitness tracking application with workout, plan, and exercise management, stats, and AI coach features.

This commit is contained in:
aodulov
2025-11-19 07:39:39 +02:00
commit 10819cc6f5
23 changed files with 6241 additions and 0 deletions

144
components/AICoach.tsx Normal file
View File

@@ -0,0 +1,144 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Bot, User, Loader2, AlertTriangle } from 'lucide-react';
import { createFitnessChat } from '../services/geminiService';
import { WorkoutSession, Language } from '../types';
import { Chat, GenerateContentResponse } from '@google/genai';
import { t } from '../services/i18n';
interface AICoachProps {
history: WorkoutSession[];
lang: Language;
}
interface Message {
id: string;
role: 'user' | 'model';
text: string;
}
const AICoach: React.FC<AICoachProps> = ({ history, lang }) => {
const [messages, setMessages] = useState<Message[]>([
{ id: 'intro', role: 'model', text: t('ai_intro', lang) }
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const chatSessionRef = useRef<Chat | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
try {
const chat = createFitnessChat(history);
if (chat) {
chatSessionRef.current = chat;
} else {
setError(t('ai_error', lang));
}
} catch (err) {
setError("Failed to initialize AI");
}
}, [history, lang]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSend = async () => {
if (!input.trim() || !chatSessionRef.current || loading) return;
const userMsg: Message = { id: crypto.randomUUID(), role: 'user', text: input };
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
try {
const result: GenerateContentResponse = await chatSessionRef.current.sendMessage({ message: userMsg.text });
const text = result.text;
const aiMsg: Message = {
id: crypto.randomUUID(),
role: 'model',
text: text || "Error generating response."
};
setMessages(prev => [...prev, aiMsg]);
} catch (err) {
console.error(err);
setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'model', text: 'Connection error.' }]);
} finally {
setLoading(false);
}
};
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full p-6 text-center text-on-surface-variant">
<AlertTriangle size={48} className="text-error mb-4" />
<p>{error}</p>
</div>
)
}
return (
<div className="flex flex-col h-full bg-surface">
{/* Header */}
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center gap-3 z-10">
<div className="w-10 h-10 rounded-full bg-secondary-container flex items-center justify-center">
<Bot size={20} className="text-on-secondary-container" />
</div>
<h2 className="text-xl font-normal text-on-surface">{t('ai_expert', lang)}</h2>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4">
{messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] p-4 rounded-[20px] text-sm leading-relaxed shadow-sm ${
msg.role === 'user'
? 'bg-primary text-on-primary rounded-br-none'
: 'bg-surface-container-high text-on-surface border border-outline-variant/20 rounded-bl-none'
}`}>
{msg.text}
</div>
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="bg-surface-container-high px-4 py-3 rounded-[20px] rounded-bl-none flex gap-2 items-center text-on-surface-variant text-sm">
<Loader2 size={16} className="animate-spin" />
{t('ai_typing', lang)}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 bg-surface-container mt-auto">
<div className="flex gap-2 items-center bg-surface-container-high rounded-full border border-outline-variant px-2 py-1">
<input
type="text"
className="flex-1 bg-transparent border-none px-4 py-3 text-on-surface focus:outline-none placeholder-on-surface-variant"
placeholder={t('ai_placeholder', lang)}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
/>
<button
onClick={handleSend}
disabled={loading || !input.trim()}
className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-on-primary hover:opacity-90 disabled:opacity-50 transition-opacity shrink-0"
>
<Send size={18} />
</button>
</div>
</div>
</div>
);
};
export default AICoach;

304
components/History.tsx Normal file
View File

@@ -0,0 +1,304 @@
import React, { useState } from 'react';
import { Calendar, Clock, TrendingUp, Scale, 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';
interface HistoryProps {
sessions: WorkoutSession[];
onUpdateSession?: (session: WorkoutSession) => void;
onDeleteSession?: (sessionId: string) => void;
lang: Language;
}
const History: React.FC<HistoryProps> = ({ sessions, onUpdateSession, onDeleteSession, lang }) => {
const [editingSession, setEditingSession] = useState<WorkoutSession | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const calculateSessionWork = (session: WorkoutSession) => {
const bw = session.userBodyWeight || 70;
return session.sets.reduce((acc, set) => {
let w = 0;
if (set.type === ExerciseType.STRENGTH) {
w = (set.weight || 0) * (set.reps || 0);
}
if (set.type === ExerciseType.BODYWEIGHT) {
const percent = set.bodyWeightPercentage || 100;
const effectiveBw = bw * (percent / 100);
w = (effectiveBw + (set.weight || 0)) * (set.reps || 0);
}
return acc + Math.max(0, w);
}, 0);
};
const formatDateForInput = (timestamp: number) => {
const d = new Date(timestamp);
const pad = (n: number) => n < 10 ? '0' + n : n;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const parseDateFromInput = (value: string) => {
return new Date(value).getTime();
};
const handleSaveEdit = () => {
if (editingSession && onUpdateSession) {
onUpdateSession(editingSession);
setEditingSession(null);
}
};
const handleUpdateSet = (setId: string, field: keyof WorkoutSet, value: number) => {
if (!editingSession) return;
const updatedSets = editingSession.sets.map(s =>
s.id === setId ? { ...s, [field]: value } : s
);
setEditingSession({ ...editingSession, sets: updatedSets });
};
const handleDeleteSet = (setId: string) => {
if (!editingSession) return;
setEditingSession({
...editingSession,
sets: editingSession.sets.filter(s => s.id !== setId)
});
};
const handleConfirmDelete = () => {
if (deletingId && onDeleteSession) {
onDeleteSession(deletingId);
setDeletingId(null);
}
}
if (sessions.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant p-8 text-center">
<Clock size={48} className="mb-4 opacity-50" />
<p>{t('history_empty', lang)}</p>
</div>
);
}
return (
<div className="h-full flex flex-col bg-surface">
<div className="p-4 bg-surface-container shadow-elevation-1 z-10">
<h2 className="text-2xl font-normal text-on-surface">{t('tab_history', lang)}</h2>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-20">
{sessions.map((session) => {
const totalWork = calculateSessionWork(session);
return (
<div key={session.id} className="bg-surface-container rounded-xl p-5 shadow-elevation-1 border border-outline-variant/20">
<div className="flex justify-between items-start mb-4 border-b border-outline-variant pb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-tertiary-container text-on-tertiary-container flex items-center justify-center">
<Calendar size={20} />
</div>
<div>
<div className="font-medium text-on-surface text-lg">
{new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { weekday: 'long', day: 'numeric', month: 'long' })}
</div>
<div className="text-xs text-on-surface-variant flex items-center gap-2">
<span>{new Date(session.startTime).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
{session.userBodyWeight && <span className="px-2 py-0.5 rounded-full bg-surface-container-high text-on-surface">{session.userBodyWeight}kg</span>}
</div>
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => 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={() => 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 className="space-y-2">
{Array.from(new Set(session.sets.map(s => s.exerciseName))).slice(0, 4).map(exName => {
const sets = session.sets.filter(s => s.exerciseName === exName);
const count = sets.length;
const bestSet = sets[0];
let detail = "";
if (bestSet.type === ExerciseType.HIGH_JUMP) detail = `${t('max', lang)}: ${Math.max(...sets.map(s => s.height || 0))}cm`;
else if (bestSet.type === ExerciseType.LONG_JUMP) detail = `${t('max', lang)}: ${Math.max(...sets.map(s => s.distanceMeters || 0))}m`;
else if (bestSet.type === ExerciseType.STRENGTH) detail = `${t('upto', lang)} ${Math.max(...sets.map(s => s.weight || 0))}kg`;
return (
<div key={exName} className="flex justify-between text-sm items-center">
<span className="text-on-surface">{exName}</span>
<span className="text-on-surface-variant flex gap-2 items-center">
{detail && <span className="text-[10px] bg-surface-container-high px-2 py-0.5 rounded text-on-surface-variant">{detail}</span>}
<span>{count}</span>
</span>
</div>
);
})}
{new Set(session.sets.map(s => s.exerciseName)).size > 4 && (
<div className="text-xs text-center text-on-surface-variant mt-2">
+ ...
</div>
)}
</div>
<div className="mt-4 pt-3 border-t border-outline-variant flex justify-between items-center">
<div className="flex gap-4">
<span className="text-xs text-on-surface-variant">{t('sets_count', lang)}: <span className="text-on-surface font-medium">{session.sets.length}</span></span>
{totalWork > 0 && (
<span className="text-xs text-on-surface-variant flex items-center gap-1">
<Scale size={12} />
{(totalWork / 1000).toFixed(1)}t
</span>
)}
</div>
<div className="flex items-center gap-1 text-primary text-xs font-bold tracking-wide">
<TrendingUp size={14} />
{t('finished', lang)}
</div>
</div>
</div>
)})}
</div>
{/* DELETE CONFIRMATION DIALOG (MD3) */}
{deletingId && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-surface-container w-full max-w-xs rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-xl font-normal text-on-surface mb-2">{t('delete_workout', lang)}</h3>
<p className="text-sm text-on-surface-variant mb-8">{t('delete_confirm', lang)}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeletingId(null)}
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
>
{t('cancel', lang)}
</button>
<button
onClick={handleConfirmDelete}
className="px-4 py-2 rounded-full bg-error-container text-on-error-container font-medium"
>
{t('delete', lang)}
</button>
</div>
</div>
</div>
)}
{/* EDIT SESSION FULLSCREEN DIALOG */}
{editingSession && (
<div className="fixed inset-0 z-[60] bg-surface flex flex-col animate-in slide-in-from-bottom-10 duration-200">
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container shadow-elevation-1">
<button onClick={() => setEditingSession(null)} className="text-on-surface-variant hover:text-on-surface">
<X />
</button>
<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">
{t('save', lang)}
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* 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-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">
<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">
<h3 className="text-sm font-medium text-primary ml-1">{t('sets_count', lang)} ({editingSession.sets.length})</h3>
{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 className="flex justify-between items-center border-b border-outline-variant pb-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="font-medium text-on-surface text-sm">{set.exerciseName}</span>
</div>
<button onClick={() => handleDeleteSet(set.id)} className="text-on-surface-variant hover:text-error">
<X size={18} />
</button>
</div>
<div className="grid grid-cols-3 gap-2">
{(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && (
<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>
<input
type="number"
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
value={set.weight ?? ''}
onChange={(e) => handleUpdateSet(set.id, 'weight', parseFloat(e.target.value))}
/>
</div>
)}
{(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.PLYOMETRIC) && (
<div className="bg-surface-container-high rounded px-2 py-1">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Activity size={10}/> {t('reps', lang)}</label>
<input
type="number"
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
value={set.reps ?? ''}
onChange={(e) => handleUpdateSet(set.id, 'reps', parseFloat(e.target.value))}
/>
</div>
)}
{(set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && (
<div className="bg-surface-container-high rounded px-2 py-1">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Percent size={10}/> {t('body_weight_percent', lang)}</label>
<input
type="number"
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
value={set.bodyWeightPercentage ?? 100}
onChange={(e) => handleUpdateSet(set.id, 'bodyWeightPercentage', parseFloat(e.target.value))}
/>
</div>
)}
{/* Add other fields similarly styled if needed */}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default History;

148
components/Login.tsx Normal file
View File

@@ -0,0 +1,148 @@
import React, { useState } from 'react';
import { login, changePassword } from '../services/auth';
import { User, Language } from '../types';
import { Dumbbell, ArrowRight, Lock, Mail, Globe } from 'lucide-react';
import { t } from '../services/i18n';
interface LoginProps {
onLogin: (user: User) => void;
language: Language;
onLanguageChange: (lang: Language) => void;
}
const Login: React.FC<LoginProps> = ({ onLogin, language, onLanguageChange }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
// Force Password Change State
const [needsChange, setNeedsChange] = useState(false);
const [tempUser, setTempUser] = useState<User | null>(null);
const [newPassword, setNewPassword] = useState('');
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
const res = login(email, password);
if (res.success && res.user) {
if (res.user.isFirstLogin) {
setTempUser(res.user);
setNeedsChange(true);
} else {
onLogin(res.user);
}
} else {
setError(res.error || t('login_error', language));
}
};
const handleChangePassword = () => {
if (tempUser && newPassword.length >= 4) {
changePassword(tempUser.id, newPassword);
const updatedUser = { ...tempUser, isFirstLogin: false };
onLogin(updatedUser);
} else {
setError(t('login_password_short', language));
}
};
if (needsChange) {
return (
<div className="h-screen bg-surface flex items-center justify-center p-4">
<div className="w-full max-w-sm bg-surface-container p-8 rounded-[28px] shadow-elevation-2">
<h2 className="text-2xl text-on-surface mb-2">{t('change_pass_title', language)}</h2>
<p className="text-sm text-on-surface-variant mb-6">{t('change_pass_desc', language)}</p>
<div className="space-y-4">
<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('change_pass_new', language)}</label>
<input
type="password"
className="w-full bg-transparent text-lg text-on-surface focus:outline-none pt-1"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<button
onClick={handleChangePassword}
className="w-full py-3 bg-primary text-on-primary rounded-full font-medium"
>
{t('change_pass_save', language)}
</button>
</div>
</div>
</div>
)
}
return (
<div className="h-screen bg-surface flex flex-col items-center justify-center p-6 relative">
{/* Language Toggle */}
<div className="absolute top-6 right-6 flex items-center bg-surface-container rounded-full px-3 py-1 gap-2 border border-outline-variant/20">
<Globe size={16} className="text-on-surface-variant" />
<select
value={language}
onChange={(e) => onLanguageChange(e.target.value as Language)}
className="bg-transparent text-sm text-on-surface focus:outline-none appearance-none"
>
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
<div className="flex flex-col items-center mb-12">
<div className="w-20 h-20 bg-primary-container rounded-2xl flex items-center justify-center text-on-primary-container mb-4 shadow-elevation-2 transform rotate-3">
<Dumbbell size={40} />
</div>
<h1 className="text-3xl font-normal text-on-surface tracking-tight">{t('login_title', language)}</h1>
</div>
<form onSubmit={handleLogin} className="w-full max-w-sm space-y-6">
<div className="space-y-4">
<div className="group bg-surface-container-high rounded-t-lg border-b border-outline-variant focus-within:border-primary transition-colors">
<div className="flex items-center px-4 pt-4">
<Mail size={16} className="text-on-surface-variant mr-2" />
<label className="text-xs text-on-surface-variant font-medium">{t('login_email', language)}</label>
</div>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-transparent px-4 pb-3 pt-1 text-lg text-on-surface focus:outline-none"
placeholder="user@gymflow.ai"
/>
</div>
<div className="group bg-surface-container-high rounded-t-lg border-b border-outline-variant focus-within:border-primary transition-colors">
<div className="flex items-center px-4 pt-4">
<Lock size={16} className="text-on-surface-variant mr-2" />
<label className="text-xs text-on-surface-variant font-medium">{t('login_password', language)}</label>
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-transparent px-4 pb-3 pt-1 text-lg text-on-surface focus:outline-none"
/>
</div>
</div>
{error && <div className="text-error text-sm text-center bg-error-container/10 p-2 rounded-lg">{error}</div>}
<button
type="submit"
className="w-full py-4 bg-primary text-on-primary rounded-full font-medium text-lg shadow-elevation-1 flex items-center justify-center gap-2 hover:shadow-elevation-2 transition-all"
>
{t('login_btn', language)} <ArrowRight size={20} />
</button>
</form>
<p className="mt-8 text-xs text-on-surface-variant text-center max-w-xs">
{t('login_contact_admin', language)}
</p>
</div>
);
};
export default Login;

82
components/Navbar.tsx Normal file
View File

@@ -0,0 +1,82 @@
import React from 'react';
import { Dumbbell, History as HistoryIcon, BarChart2, MessageSquare, ClipboardList, User } from 'lucide-react';
import { TabView, Language } from '../types';
import { t } from '../services/i18n';
interface NavbarProps {
currentTab: TabView;
onTabChange: (tab: TabView) => void;
lang: Language;
}
const Navbar: React.FC<NavbarProps> = ({ currentTab, onTabChange, lang }) => {
const navItems = [
{ id: 'TRACK' as TabView, icon: Dumbbell, label: t('tab_tracker', lang) },
{ id: 'PLANS' as TabView, icon: ClipboardList, label: t('tab_plans', lang) },
{ id: 'HISTORY' as TabView, icon: HistoryIcon, label: t('tab_history', lang) },
{ id: 'STATS' as TabView, icon: BarChart2, label: t('tab_stats', lang) },
{ id: 'AI_COACH' as TabView, icon: MessageSquare, label: t('tab_ai', lang) },
{ id: 'PROFILE' as TabView, icon: User, label: t('tab_profile', lang) },
];
return (
<>
{/* MOBILE: Bottom Navigation Bar (MD3) */}
<div className="md:hidden fixed bottom-0 left-0 w-full bg-surface-container shadow-elevation-2 border-t border-white/5 pb-safe z-50 h-20">
<div className="flex justify-evenly items-center h-full px-1">
{navItems.map((item) => {
const isActive = currentTab === item.id;
return (
<button
key={item.id}
onClick={() => onTabChange(item.id)}
className="flex flex-col items-center justify-center w-full h-full gap-1 group min-w-0"
>
<div className={`px-4 py-1 rounded-full transition-all duration-200 ${
isActive ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant group-hover:bg-surface-container-high'
}`}>
<item.icon size={22} strokeWidth={isActive ? 2.5 : 2} />
</div>
<span className={`text-[10px] font-medium transition-colors truncate w-full text-center ${
isActive ? 'text-on-surface' : 'text-on-surface-variant'
}`}>
{item.label}
</span>
</button>
);
})}
</div>
</div>
{/* DESKTOP: Navigation Rail (MD3) */}
<div className="hidden md:flex flex-col w-20 h-full bg-surface-container border-r border-outline-variant items-center py-8 gap-8 z-50">
<div className="flex flex-col gap-6 w-full px-2">
{navItems.map((item) => {
const isActive = currentTab === item.id;
return (
<button
key={item.id}
onClick={() => onTabChange(item.id)}
className="flex flex-col items-center gap-1 group w-full"
>
<div className={`w-14 h-8 rounded-full flex items-center justify-center transition-colors duration-200 ${
isActive ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant group-hover:bg-surface-container-high'
}`}>
<item.icon size={24} />
</div>
<span className={`text-[11px] font-medium text-center ${
isActive ? 'text-on-surface' : 'text-on-surface-variant'
}`}>
{item.label}
</span>
</button>
);
})}
</div>
</div>
</>
);
};
export default Navbar;

255
components/Plans.tsx Normal file
View File

@@ -0,0 +1,255 @@
import React, { useState, useEffect } from 'react';
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Scale } from 'lucide-react';
import { WorkoutPlan, ExerciseDef, PlannedSet, Language } from '../types';
import { getPlans, savePlan, deletePlan, getExercises } from '../services/storage';
import { t } from '../services/i18n';
interface PlansProps {
userId: string;
onStartPlan: (plan: WorkoutPlan) => void;
lang: Language;
}
const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [steps, setSteps] = useState<PlannedSet[]>([]);
const [availableExercises, setAvailableExercises] = useState<ExerciseDef[]>([]);
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
useEffect(() => {
setPlans(getPlans(userId));
// Filter out archived exercises
setAvailableExercises(getExercises(userId).filter(e => !e.isArchived));
}, [userId]);
const handleCreateNew = () => {
setEditId(crypto.randomUUID());
setName('');
setDescription('');
setSteps([]);
setIsEditing(true);
};
const handleSave = () => {
if (!name.trim() || !editId) return;
const newPlan: WorkoutPlan = { id: editId, name, description, steps };
savePlan(userId, newPlan);
setPlans(getPlans(userId));
setIsEditing(false);
};
const handleDelete = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (confirm(t('delete_confirm', lang))) {
deletePlan(userId, id);
setPlans(getPlans(userId));
}
};
const addStep = (ex: ExerciseDef) => {
const newStep: PlannedSet = {
id: crypto.randomUUID(),
exerciseId: ex.id,
exerciseName: ex.name,
exerciseType: ex.type,
isWeighted: false
};
setSteps([...steps, newStep]);
setShowExerciseSelector(false);
};
const toggleWeighted = (stepId: string) => {
setSteps(steps.map(s => s.id === stepId ? { ...s, isWeighted: !s.isWeighted } : s));
};
const removeStep = (stepId: string) => {
setSteps(steps.filter(s => s.id !== stepId));
};
const moveStep = (index: number, direction: 'up' | 'down') => {
if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === steps.length - 1) return;
const newSteps = [...steps];
const targetIndex = direction === 'up' ? index - 1 : index + 1;
[newSteps[index], newSteps[targetIndex]] = [newSteps[targetIndex], newSteps[index]];
setSteps(newSteps);
};
if (isEditing) {
return (
<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">
<button onClick={() => setIsEditing(false)} className="p-2 text-on-surface-variant hover:bg-white/5 rounded-full"><X /></button>
<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">
{t('save', lang)}
</button>
</div>
<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">
<label className="text-[10px] text-on-surface-variant font-medium">{t('ex_name', lang)}</label>
<input
className="w-full bg-transparent text-xl text-on-surface focus:outline-none pt-1 pb-2"
placeholder={t('plan_name_ph', lang)}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<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>
<textarea
className="w-full bg-transparent text-base text-on-surface focus:outline-none pt-1 pb-2 min-h-[80px]"
placeholder={t('plan_desc_ph', lang)}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center px-2">
<label className="text-sm text-primary font-medium">{t('exercises_list', lang)}</label>
</div>
<div className="space-y-2">
{steps.map((step, idx) => (
<div key={step.id} className="bg-surface-container rounded-xl p-3 flex items-center gap-3 shadow-elevation-1">
<div className="flex flex-col gap-1">
{idx > 0 && (
<button onClick={() => moveStep(idx, 'up')} className="p-1 text-on-surface-variant hover:bg-white/5 rounded">
<ArrowUp size={16} />
</button>
)}
{idx < steps.length - 1 && (
<button onClick={() => moveStep(idx, 'down')} className="p-1 text-on-surface-variant hover:bg-white/5 rounded">
<ArrowDown size={16} />
</button>
)}
</div>
<div className="w-8 h-8 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xs font-bold shrink-0">
{idx + 1}
</div>
<div className="flex-1">
<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">
<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" />}
</div>
<input
type="checkbox"
checked={step.isWeighted}
onChange={() => toggleWeighted(step.id)}
className="hidden"
/>
<span className="text-xs text-on-surface-variant">{t('weighted', lang)}</span>
</label>
</div>
<button onClick={() => removeStep(step.id)} className="text-on-surface-variant hover:text-error p-2">
<X size={20} />
</button>
</div>
))}
</div>
<button
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"
>
<Plus size={20} />
{t('add_exercise', lang)}
</button>
</div>
</div>
{showExerciseSelector && (
<div className="absolute 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">
<span className="font-medium text-on-surface">{t('select_exercise', lang)}</span>
<button onClick={() => setShowExerciseSelector(false)}><X /></button>
</div>
<div className="flex-1 overflow-y-auto p-2">
{availableExercises.map(ex => (
<button
key={ex.id}
onClick={() => addStep(ex)}
className="w-full text-left p-4 border-b border-outline-variant hover:bg-surface-container-high text-on-surface flex justify-between"
>
<span>{ex.name}</span>
<span className="text-xs bg-secondary-container text-on-secondary-container px-2 py-1 rounded-full">{ex.type}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
return (
<div className="h-full flex flex-col bg-surface relative">
<div className="p-4 bg-surface-container shadow-elevation-1 z-10">
<h2 className="text-2xl font-normal text-on-surface">{t('my_plans', lang)}</h2>
</div>
<div className="flex-1 p-4 overflow-y-auto space-y-4 pb-24">
{plans.length === 0 ? (
<div className="text-center text-on-surface-variant mt-20 flex flex-col items-center">
<div className="w-16 h-16 bg-surface-container-high rounded-full flex items-center justify-center mb-4">
<List size={32} />
</div>
<p className="text-lg">{t('plans_empty', lang)}</p>
</div>
) : (
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">
<div className="flex justify-between items-start mb-2">
<h3 className="text-xl font-normal text-on-surface">{plan.name}</h3>
<button
onClick={(e) => handleDelete(plan.id, e)}
className="text-on-surface-variant hover:text-error p-2 rounded-full hover:bg-white/5"
>
<Trash2 size={20} />
</button>
</div>
<p className="text-on-surface-variant text-sm line-clamp-2 mb-4 min-h-[1.25rem]">
{plan.description || t('prep_no_instructions', lang)}
</p>
<div className="flex items-center justify-between">
<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)}
</div>
<button
onClick={() => onStartPlan(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"
>
<PlayCircle size={18} />
{t('start', lang)}
</button>
</div>
</div>
))
)}
</div>
{/* FAB */}
<button
onClick={handleCreateNew}
className="absolute bottom-6 right-6 w-14 h-14 bg-primary-container text-on-primary-container rounded-[16px] shadow-elevation-3 flex items-center justify-center hover:bg-primary hover:text-on-primary transition-colors z-20"
>
<Plus size={28} />
</button>
</div>
);
};
export default Plans;

532
components/Profile.tsx Normal file
View File

@@ -0,0 +1,532 @@
import React, { useState, useEffect } from 'react';
import { User, Language, ExerciseDef, ExerciseType } from '../types';
import { createUser, changePassword, updateUserProfile, getCurrentUserProfile, getUsers, deleteUser, toggleBlockUser, adminResetPassword } from '../services/auth';
import { getExercises, saveExercise } from '../services/storage';
import { User as UserIcon, LogOut, Save, Shield, UserPlus, Lock, Calendar, Ruler, Scale, PersonStanding, Globe, ChevronDown, ChevronUp, Trash2, Ban, KeyRound, Dumbbell, Archive, ArchiveRestore, Pencil, X, Plus, Percent } from 'lucide-react';
import { t } from '../services/i18n';
interface ProfileProps {
user: User;
onLogout: () => void;
lang: Language;
onLanguageChange: (lang: Language) => void;
}
const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChange }) => {
// Profile Data
const [weight, setWeight] = useState<string>('');
const [height, setHeight] = useState<string>('');
const [birthDate, setBirthDate] = useState<string>('');
const [gender, setGender] = useState<string>('MALE');
// Admin: Create User
const [newUserEmail, setNewUserEmail] = useState('');
const [newUserPass, setNewUserPass] = useState('');
const [createMsg, setCreateMsg] = useState('');
// Admin: User List
const [showUserList, setShowUserList] = useState(false);
const [allUsers, setAllUsers] = useState<any[]>([]);
const [adminPassResetInput, setAdminPassResetInput] = useState<{[key:string]: string}>({});
// Change Password
const [newPassword, setNewPassword] = useState('');
const [passMsg, setPassMsg] = useState('');
// Account Deletion
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Exercise Management
const [showExercises, setShowExercises] = useState(false);
const [exercises, setExercises] = useState<ExerciseDef[]>([]);
const [showArchived, setShowArchived] = useState(false);
const [editingExercise, setEditingExercise] = useState<ExerciseDef | null>(null);
const [isCreatingEx, setIsCreatingEx] = useState(false);
// New exercise form
const [newExName, setNewExName] = useState('');
const [newExType, setNewExType] = useState<ExerciseType>(ExerciseType.STRENGTH);
const [newExBw, setNewExBw] = useState('100');
useEffect(() => {
const p = getCurrentUserProfile(user.id);
if (p) {
if (p.weight) setWeight(p.weight.toString());
if (p.height) setHeight(p.height.toString());
if (p.gender) setGender(p.gender);
if (p.birthDate) setBirthDate(new Date(p.birthDate).toISOString().split('T')[0]);
}
if (user.role === 'ADMIN') {
refreshUserList();
}
refreshExercises();
}, [user.id, user.role]);
const refreshUserList = () => {
setAllUsers(getUsers());
};
const refreshExercises = () => {
setExercises(getExercises(user.id));
};
const handleSaveProfile = () => {
updateUserProfile(user.id, {
weight: parseFloat(weight) || undefined,
height: parseFloat(height) || undefined,
gender: gender as any,
birthDate: birthDate ? new Date(birthDate).getTime() : undefined,
language: lang
});
alert('Saved');
};
const handleChangePassword = () => {
if (newPassword.length < 4) {
setPassMsg('Password too short');
return;
}
changePassword(user.id, newPassword);
setPassMsg('Password changed');
setNewPassword('');
};
const handleCreateUser = () => {
const res = createUser(newUserEmail, newUserPass);
if (res.success) {
setCreateMsg(`${t('user_created', lang)}: ${newUserEmail}`);
setNewUserEmail('');
setNewUserPass('');
refreshUserList();
} else {
setCreateMsg(res.error || 'Error');
}
};
const handleAdminDeleteUser = (uid: string) => {
if (confirm(t('delete_confirm', lang))) {
deleteUser(uid);
refreshUserList();
}
};
const handleAdminBlockUser = (uid: string, isBlocked: boolean) => {
toggleBlockUser(uid, isBlocked);
refreshUserList();
};
const handleAdminResetPass = (uid: string) => {
const pass = adminPassResetInput[uid];
if (pass && pass.length >= 4) {
adminResetPassword(uid, pass);
alert(t('pass_reset', lang));
setAdminPassResetInput({...adminPassResetInput, [uid]: ''});
}
};
const handleDeleteMyAccount = () => {
deleteUser(user.id);
onLogout();
};
// Exercise Management Handlers
const handleArchiveExercise = (ex: ExerciseDef, archive: boolean) => {
const updated = { ...ex, isArchived: archive };
saveExercise(user.id, updated);
refreshExercises();
};
const handleSaveExerciseEdit = () => {
if (editingExercise && editingExercise.name) {
saveExercise(user.id, editingExercise);
setEditingExercise(null);
refreshExercises();
}
};
const handleCreateExercise = () => {
if (newExName.trim()) {
const newEx: ExerciseDef = {
id: crypto.randomUUID(),
name: newExName.trim(),
type: newExType,
bodyWeightPercentage: newExType === ExerciseType.BODYWEIGHT ? parseFloat(newExBw) : undefined
};
saveExercise(user.id, newEx);
setNewExName('');
setIsCreatingEx(false);
refreshExercises();
}
};
return (
<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">
<h2 className="text-xl font-normal text-on-surface flex items-center gap-2">
<UserIcon size={20} />
{t('profile_title', lang)}
</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">
<LogOut size={16} /> {t('logout', lang)}
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6 pb-24">
{/* User Info Card */}
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
<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">
{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>
<h3 className="text-sm font-bold text-primary mb-4">{t('personal_data', lang)}</h3>
<div className="grid grid-cols-2 gap-4 mb-4">
<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>
{/* EXERCISE MANAGER */}
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
<button
onClick={() => setShowExercises(!showExercises)}
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>
<div className="flex items-center justify-end gap-2">
<label className="text-xs text-on-surface-variant">{t('show_archived', lang)}</label>
<input
type="checkbox"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
className="accent-primary"
/>
</div>
<div className="space-y-2">
{exercises
.filter(e => showArchived || !e.isArchived)
.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">{ex.type}</div>
</div>
<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>
{/* Change Password */}
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
<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} 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) */}
{user.role !== 'ADMIN' && (
<div className="bg-surface-container rounded-xl p-4 border 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)} className="text-xs px-3 py-1 bg-surface-container-high rounded-full">{t('cancel', lang)}</button>
<button onClick={handleDeleteMyAccount} className="text-xs px-3 py-1 bg-error text-on-error rounded-full">{t('delete', lang)}</button>
</div>
</div>
)}
</div>
)}
{/* ADMIN AREA */}
{user.role === 'ADMIN' && (
<div className="bg-surface-container rounded-xl p-4 border border-primary/30 relative overflow-hidden">
<div className="absolute top-0 right-0 p-2 bg-primary/10 rounded-bl-xl">
<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>
<input
type="email"
placeholder="Email"
value={newUserEmail}
onChange={(e) => setNewUserEmail(e.target.value)}
className="w-full bg-surface-container-high border-b border-outline-variant px-3 py-2 text-on-surface focus:outline-none rounded-t-lg"
/>
<input
type="text"
placeholder={t('login_password', lang)}
value={newUserPass}
onChange={(e) => setNewUserPass(e.target.value)}
className="w-full bg-surface-container-high border-b border-outline-variant px-3 py-2 text-on-surface focus:outline-none rounded-t-lg"
/>
<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-on-surface-variant text-center">{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)}
className="text-xs bg-secondary-container text-on-secondary-container px-3 py-2 rounded font-medium"
>
{t('reset_pass', lang)}
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Edit Exercise Modal */}
{editingExercise && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-surface-container w-full max-w-sm rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-xl font-normal text-on-surface mb-4">{t('edit', lang)}</h3>
<div className="space-y-4">
<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>
<input
value={editingExercise.name}
onChange={(e) => setEditingExercise({...editingExercise, name: e.target.value})}
className="w-full bg-transparent text-on-surface focus:outline-none"
/>
</div>
{editingExercise.type === ExerciseType.BODYWEIGHT && (
<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('body_weight_percent', lang)}</label>
<input
type="number"
value={editingExercise.bodyWeightPercentage || 100}
onChange={(e) => setEditingExercise({...editingExercise, bodyWeightPercentage: parseFloat(e.target.value)})}
className="w-full bg-transparent text-on-surface focus:outline-none"
/>
</div>
)}
<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={handleSaveExerciseEdit} className="px-4 py-2 rounded-full bg-primary text-on-primary font-medium">{t('save', lang)}</button>
</div>
</div>
</div>
</div>
)}
{/* Create Exercise Modal */}
{isCreatingEx && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-surface-container w-full max-w-sm rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-xl font-normal text-on-surface mb-4">{t('create_exercise', lang)}</h3>
<div className="space-y-4">
<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>
<input
value={newExName}
onChange={(e) => setNewExName(e.target.value)}
className="w-full bg-transparent text-on-surface focus:outline-none"
autoFocus
/>
</div>
<div>
<label className="text-[10px] text-on-surface-variant font-medium mb-2 block">{t('ex_type', lang)}</label>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setNewExType(ExerciseType.STRENGTH)}
className={`px-3 py-1 rounded-full text-xs border ${newExType === ExerciseType.STRENGTH ? 'bg-primary text-on-primary border-transparent' : 'border-outline text-on-surface-variant'}`}
>
{t('type_strength', lang)}
</button>
<button
onClick={() => setNewExType(ExerciseType.BODYWEIGHT)}
className={`px-3 py-1 rounded-full text-xs border ${newExType === ExerciseType.BODYWEIGHT ? 'bg-primary text-on-primary border-transparent' : 'border-outline text-on-surface-variant'}`}
>
{t('type_bodyweight', lang)}
</button>
<button
onClick={() => setNewExType(ExerciseType.CARDIO)}
className={`px-3 py-1 rounded-full text-xs border ${newExType === ExerciseType.CARDIO ? 'bg-primary text-on-primary border-transparent' : 'border-outline text-on-surface-variant'}`}
>
{t('type_cardio', lang)}
</button>
</div>
</div>
{newExType === ExerciseType.BODYWEIGHT && (
<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('body_weight_percent', lang)}</label>
<input
type="number"
value={newExBw}
onChange={(e) => setNewExBw(e.target.value)}
className="w-full bg-transparent text-on-surface focus:outline-none"
/>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setIsCreatingEx(false)} className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
<button onClick={handleCreateExercise} className="px-4 py-2 rounded-full bg-primary text-on-primary font-medium">{t('create_btn', lang)}</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default Profile;

136
components/Stats.tsx Normal file
View File

@@ -0,0 +1,136 @@
import React, { useMemo } from 'react';
import { WorkoutSession, ExerciseType, Language } from '../types';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
import { t } from '../services/i18n';
interface StatsProps {
sessions: WorkoutSession[];
lang: Language;
}
const Stats: React.FC<StatsProps> = ({ sessions, lang }) => {
const volumeData = useMemo(() => {
const data = [...sessions].reverse().map(session => {
const sessionWeight = session.userBodyWeight || 70;
const work = session.sets.reduce((acc, set) => {
let setWork = 0;
const reps = set.reps || 0;
const weight = set.weight || 0;
if (set.type === ExerciseType.STRENGTH) {
setWork = weight * reps;
} else if (set.type === ExerciseType.BODYWEIGHT) {
const percentage = set.bodyWeightPercentage || 100;
const effectiveBw = sessionWeight * (percentage / 100);
setWork = (effectiveBw + weight) * reps;
} else if (set.type === ExerciseType.STATIC) {
setWork = 0;
}
return acc + Math.max(0, setWork);
}, 0);
return {
date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
work: Math.round(work)
};
}).filter(d => d.work > 0);
return data;
}, [sessions, lang]);
const setsData = useMemo(() => {
return [...sessions].reverse().map(session => ({
date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
sets: session.sets.length
}));
}, [sessions, lang]);
const weightData = useMemo(() => {
return [...sessions].reverse()
.filter(s => s.userBodyWeight)
.map(session => ({
date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
weight: session.userBodyWeight
}));
}, [sessions, lang]);
if (sessions.length < 2) {
return (
<div className="p-8 text-center text-on-surface-variant flex flex-col items-center justify-center h-full">
<p>{t('not_enough_data', lang)}</p>
</div>
)
}
return (
<div className="h-full overflow-y-auto p-4 space-y-6 pb-24 bg-surface">
<h2 className="text-3xl font-normal text-on-surface mb-2 pl-2">{t('progress', lang)}</h2>
{/* Volume Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<div className="flex justify-between items-end mb-6">
<div>
<h3 className="text-title-medium font-medium text-on-surface">{t('volume_title', lang)}</h3>
<p className="text-xs text-on-surface-variant mt-1">{t('volume_subtitle', lang)}</p>
</div>
</div>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={volumeData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(val) => `${(val/1000).toFixed(1)}k`} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
itemStyle={{ color: '#D0BCFF' }}
formatter={(val: number) => [`${val.toLocaleString()} kg`, t('volume_title', lang)]}
/>
<Line type="monotone" dataKey="work" stroke="#D0BCFF" strokeWidth={3} dot={{r: 4, fill: '#D0BCFF'}} activeDot={{r: 6}} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Sets Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sets_title', lang)}</h3>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={setsData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
cursor={{fill: 'rgba(255,255,255,0.05)'}}
/>
<Bar dataKey="sets" fill="#CCC2DC" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Body Weight Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('weight_title', lang)}</h3>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={weightData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis domain={['auto', 'auto']} stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
itemStyle={{ color: '#6EE7B7' }}
formatter={(val: number) => [`${val} kg`, t('weight_kg', lang)]}
/>
<Line type="monotone" dataKey="weight" stroke="#6EE7B7" strokeWidth={3} dot={{r: 4, fill: '#6EE7B7'}} activeDot={{r: 6}} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
};
export default Stats;

581
components/Tracker.tsx Normal file
View File

@@ -0,0 +1,581 @@
import React, { useState, useEffect } from 'react';
import { Plus, Activity, ChevronDown, ChevronUp, Dumbbell, PlayCircle, CheckCircle, User, Scale, X, Flame, Timer as TimerIcon, ArrowUp, ArrowRight, Footprints, Ruler, CheckSquare, Trash2, Percent } from 'lucide-react';
import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan, Language } from '../types';
import { getExercises, getLastSetForExercise, saveExercise, getPlans } from '../services/storage';
import { getCurrentUserProfile } from '../services/auth';
import { t } from '../services/i18n';
interface TrackerProps {
userId: string;
activeSession: WorkoutSession | null;
activePlan: WorkoutPlan | null;
onSessionStart: (plan?: WorkoutPlan) => void;
onSessionEnd: () => void;
onSetAdded: (set: WorkoutSet) => void;
onRemoveSet: (setId: string) => void;
lang: Language;
}
const Tracker: React.FC<TrackerProps> = ({ userId, activeSession, activePlan, onSessionStart, onSessionEnd, onSetAdded, onRemoveSet, lang }) => {
const [exercises, setExercises] = useState<ExerciseDef[]>([]);
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
const [selectedExercise, setSelectedExercise] = useState<ExerciseDef | null>(null);
// Timer State
const [elapsedTime, setElapsedTime] = useState<string>('00:00:00');
// Form State
const [weight, setWeight] = useState<string>('');
const [reps, setReps] = useState<string>('');
const [duration, setDuration] = useState<string>('');
const [distance, setDistance] = useState<string>('');
const [height, setHeight] = useState<string>('');
const [bwPercentage, setBwPercentage] = useState<string>('100');
// User Weight State
const [userBodyWeight, setUserBodyWeight] = useState<string>('70');
// Create Exercise State
const [isCreating, setIsCreating] = useState(false);
const [newName, setNewName] = useState('');
const [newType, setNewType] = useState<ExerciseType>(ExerciseType.STRENGTH);
const [newBwPercentage, setNewBwPercentage] = useState<string>('100');
// Plan Execution State
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [showPlanPrep, setShowPlanPrep] = useState<WorkoutPlan | null>(null);
const [showPlanList, setShowPlanList] = useState(false);
useEffect(() => {
// Filter out archived exercises for the selector
setExercises(getExercises(userId).filter(e => !e.isArchived));
setPlans(getPlans(userId));
if (activeSession?.userBodyWeight) {
setUserBodyWeight(activeSession.userBodyWeight.toString());
} else {
const profile = getCurrentUserProfile(userId);
setUserBodyWeight(profile?.weight ? profile.weight.toString() : '70');
}
}, [activeSession, userId]);
// Timer Logic
useEffect(() => {
let interval: number;
if (activeSession) {
const updateTimer = () => {
const diff = Math.floor((Date.now() - activeSession.startTime) / 1000);
const h = Math.floor(diff / 3600);
const m = Math.floor((diff % 3600) / 60);
const s = diff % 60;
setElapsedTime(`${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`);
};
updateTimer();
interval = window.setInterval(updateTimer, 1000);
}
return () => clearInterval(interval);
}, [activeSession]);
useEffect(() => {
if (activeSession && activePlan && exercises.length > 0 && activePlan.steps.length > 0) {
if (currentStepIndex < activePlan.steps.length) {
const step = activePlan.steps[currentStepIndex];
if (step) {
const exDef = exercises.find(e => e.id === step.exerciseId);
if (exDef) {
if (!selectedExercise || selectedExercise.id !== exDef.id) {
setSelectedExercise(exDef);
}
}
}
}
}
}, [activeSession, activePlan, currentStepIndex, exercises]);
useEffect(() => {
if (selectedExercise) {
setBwPercentage(selectedExercise.bodyWeightPercentage ? selectedExercise.bodyWeightPercentage.toString() : '100');
const lastSet = getLastSetForExercise(userId, selectedExercise.id);
if (lastSet) {
if (lastSet.weight !== undefined) setWeight(lastSet.weight.toString());
if (lastSet.reps !== undefined) setReps(lastSet.reps.toString());
if (lastSet.durationSeconds !== undefined) setDuration(lastSet.durationSeconds.toString());
if (lastSet.distanceMeters !== undefined) setDistance(lastSet.distanceMeters.toString());
if (lastSet.height !== undefined) setHeight(lastSet.height.toString());
} else {
setWeight(''); setReps(''); setDuration(''); setDistance(''); setHeight('');
}
}
}, [selectedExercise, userId]);
const handleStart = (plan?: WorkoutPlan) => {
if (plan && plan.description) {
setShowPlanPrep(plan);
} else {
onSessionStart(plan);
}
};
const confirmPlanStart = () => {
if (showPlanPrep) {
onSessionStart(showPlanPrep);
setShowPlanPrep(null);
}
}
const handleAddSet = () => {
if (!activeSession || !selectedExercise) return;
const newSet: WorkoutSet = {
id: crypto.randomUUID(),
exerciseId: selectedExercise.id,
exerciseName: selectedExercise.name,
type: selectedExercise.type,
timestamp: Date.now(),
...(weight && { weight: parseFloat(weight) }),
...(reps && { reps: parseInt(reps) }),
...(duration && { durationSeconds: parseInt(duration) }),
...(distance && { distanceMeters: parseFloat(distance) }),
...(height && { height: parseFloat(height) }),
...((selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && { bodyWeightPercentage: parseFloat(bwPercentage) || 100 })
};
onSetAdded(newSet);
if (activePlan) {
const currentStep = activePlan.steps[currentStepIndex];
if (currentStep && currentStep.exerciseId === selectedExercise.id) {
const nextIndex = currentStepIndex + 1;
setCurrentStepIndex(nextIndex);
}
}
};
const handleCreateExercise = () => {
if (!newName.trim()) return;
const newEx: ExerciseDef = {
id: crypto.randomUUID(),
name: newName.trim(),
type: newType,
...(newType === ExerciseType.BODYWEIGHT && { bodyWeightPercentage: parseFloat(newBwPercentage) || 100 })
};
saveExercise(userId, newEx);
const updatedList = getExercises(userId).filter(e => !e.isArchived);
setExercises(updatedList);
setSelectedExercise(newEx);
setNewName('');
setNewType(ExerciseType.STRENGTH);
setNewBwPercentage('100');
setIsCreating(false);
};
const jumpToStep = (index: number) => {
if (!activePlan) return;
setCurrentStepIndex(index);
setShowPlanList(false);
};
const isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length;
const FilledInput = ({ label, value, onChange, type = "number", icon, autoFocus, step }: any) => (
<div className="relative group bg-surface-container-high rounded-t-lg border-b border-outline-variant hover:bg-white/5 focus-within:border-primary transition-colors">
<label className="absolute top-2 left-4 text-[10px] font-medium text-on-surface-variant flex items-center gap-1">
{icon} {label}
</label>
<input
type={type}
step={step}
inputMode="decimal"
autoFocus={autoFocus}
className="w-full pt-6 pb-2 px-4 bg-transparent text-2xl text-on-surface focus:outline-none placeholder-transparent"
placeholder="0"
value={value}
onChange={onChange}
/>
</div>
);
const exerciseTypeLabels: Record<ExerciseType, string> = {
[ExerciseType.STRENGTH]: t('type_strength', lang),
[ExerciseType.BODYWEIGHT]: t('type_bodyweight', lang),
[ExerciseType.CARDIO]: t('type_cardio', lang),
[ExerciseType.STATIC]: t('type_static', lang),
[ExerciseType.HIGH_JUMP]: t('type_height', lang),
[ExerciseType.LONG_JUMP]: t('type_dist', lang),
[ExerciseType.PLYOMETRIC]: t('type_jump', lang),
};
if (!activeSession) {
return (
<div className="flex flex-col h-full p-4 md:p-8 overflow-y-auto relative">
<div className="flex-1 flex flex-col items-center justify-center space-y-12">
<div className="flex flex-col items-center gap-4">
<div className="w-24 h-24 rounded-full bg-surface-container-high flex items-center justify-center text-primary shadow-elevation-1">
<Dumbbell size={40} />
</div>
<div className="text-center">
<h1 className="text-3xl font-normal text-on-surface">{t('ready_title', lang)}</h1>
<p className="text-on-surface-variant text-sm">{t('ready_subtitle', lang)}</p>
</div>
</div>
<div className="w-full max-w-sm bg-surface-container rounded-2xl p-6 flex flex-col items-center gap-4 shadow-elevation-1">
<label className="text-xs text-on-surface-variant font-bold tracking-wide flex items-center gap-2">
<User size={14} />
{t('my_weight', lang)}
</label>
<div className="flex items-center gap-4">
<input
type="number"
step="0.1"
className="text-5xl font-normal text-on-surface tabular-nums bg-transparent text-center w-full focus:outline-none"
value={userBodyWeight}
onChange={(e) => setUserBodyWeight(e.target.value)}
/>
</div>
<p className="text-[10px] text-on-surface-variant">{t('change_in_profile', lang)}</p>
</div>
<div className="w-full max-w-xs">
<button
onClick={() => handleStart()}
className="w-full h-16 rounded-full bg-primary text-on-primary font-medium text-lg shadow-elevation-2 hover:shadow-elevation-3 active:shadow-elevation-1 transition-all flex items-center justify-center gap-2"
>
<PlayCircle size={24} />
{t('free_workout', lang)}
</button>
</div>
{plans.length > 0 && (
<div className="w-full max-w-md mt-8">
<h3 className="text-sm text-on-surface-variant font-medium px-4 mb-3">{t('or_choose_plan', lang)}</h3>
<div className="grid grid-cols-1 gap-3">
{plans.map(plan => (
<button
key={plan.id}
onClick={() => handleStart(plan)}
className="flex items-center justify-between p-4 bg-surface-container rounded-xl hover:bg-surface-container-high transition-colors border border-outline-variant/20"
>
<div className="text-left">
<div className="text-base font-medium text-on-surface">{plan.name}</div>
<div className="text-xs text-on-surface-variant">{plan.steps.length} {t('exercises_count', lang)}</div>
</div>
<div className="w-10 h-10 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center">
<ArrowRight size={20} />
</div>
</button>
))}
</div>
</div>
)}
</div>
{showPlanPrep && (
<div className="absolute inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-2xl font-normal text-on-surface mb-4">{showPlanPrep.name}</h3>
<div className="bg-surface-container-high p-4 rounded-xl text-on-surface-variant text-sm mb-8">
<div className="text-xs font-bold text-primary mb-2">{t('prep_title', lang)}</div>
{showPlanPrep.description || t('prep_no_instructions', lang)}
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setShowPlanPrep(null)} className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
<button onClick={confirmPlanStart} className="px-6 py-2.5 rounded-full bg-primary text-on-primary font-medium">{t('start', lang)}</button>
</div>
</div>
</div>
)}
</div>
);
}
return (
<div className="flex flex-col h-full max-h-full overflow-hidden relative bg-surface">
<div className="px-4 py-3 bg-surface-container shadow-elevation-1 z-20 flex justify-between items-center">
<div className="flex flex-col">
<h2 className="text-title-medium text-on-surface flex items-center gap-2 font-medium">
<span className="w-2 h-2 rounded-full bg-error animate-pulse"/>
{activePlan ? activePlan.name : t('free_workout', lang)}
</h2>
<span className="text-xs text-on-surface-variant font-mono mt-0.5 flex items-center gap-2">
<span className="bg-surface-container-high px-1.5 py-0.5 rounded text-on-surface font-bold">{elapsedTime}</span>
{activeSession.userBodyWeight ? `${activeSession.userBodyWeight}kg` : ''}
</span>
</div>
<button
onClick={onSessionEnd}
className="px-5 py-2 rounded-full bg-error-container text-on-error-container text-sm font-medium hover:opacity-90 transition-opacity"
>
{t('finish', lang)}
</button>
</div>
{activePlan && (
<div className="bg-surface-container-low border-b border-outline-variant">
<button
onClick={() => setShowPlanList(!showPlanList)}
className="w-full px-4 py-3 flex justify-between items-center"
>
<div className="flex flex-col items-start">
{isPlanFinished ? (
<div className="flex items-center gap-2 text-primary">
<CheckSquare size={18} />
<span className="font-bold">{t('plan_completed', lang)}</span>
</div>
) : (
<>
<span className="text-[10px] text-primary font-medium tracking-wider">{t('step', lang)} {currentStepIndex + 1} {t('of', lang)} {activePlan.steps.length}</span>
<div className="font-medium text-on-surface flex items-center gap-2">
{activePlan.steps[currentStepIndex].exerciseName}
{activePlan.steps[currentStepIndex].isWeighted && <Scale size={12} className="text-primary" />}
</div>
</>
)}
</div>
{showPlanList ? <ChevronUp size={20} className="text-on-surface-variant"/> : <ChevronDown size={20} className="text-on-surface-variant"/>}
</button>
{showPlanList && (
<div className="max-h-48 overflow-y-auto bg-surface-container-high p-2 space-y-1 animate-in slide-in-from-top-2">
{activePlan.steps.map((step, idx) => (
<button
key={step.id}
onClick={() => jumpToStep(idx)}
className={`w-full text-left px-4 py-3 rounded-full text-sm flex items-center justify-between transition-colors ${
idx === currentStepIndex
? 'bg-primary-container text-on-primary-container font-medium'
: idx < currentStepIndex
? 'text-on-surface-variant opacity-50'
: 'text-on-surface hover:bg-white/5'
}`}
>
<span>{idx+1}. {step.exerciseName}</span>
{step.isWeighted && <Scale size={14} />}
</button>
))}
</div>
)}
</div>
)}
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-6">
<div className="relative">
<select
className="w-full p-4 pr-12 bg-transparent border border-outline rounded-lg text-on-surface appearance-none focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary text-lg font-normal"
value={selectedExercise?.id || ''}
onChange={(e) => setSelectedExercise(exercises.find(ex => ex.id === e.target.value) || null)}
>
<option value="" disabled>{t('select_exercise', lang)}</option>
{exercises.map(ex => (
<option key={ex.id} value={ex.id} className="bg-surface-container text-on-surface">{ex.name}</option>
))}
</select>
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 text-on-surface-variant pointer-events-none" size={24} />
<button
onClick={() => setIsCreating(true)}
className="absolute right-12 top-1/2 -translate-y-1/2 p-2 text-primary hover:bg-primary-container/20 rounded-full"
>
<Plus size={24} />
</button>
</div>
{selectedExercise && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-300 space-y-6">
<div className="grid grid-cols-2 gap-4">
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={selectedExercise.type === ExerciseType.BODYWEIGHT ? t('add_weight', lang) : t('weight_kg', lang)}
value={weight}
step="0.1"
onChange={(e: any) => setWeight(e.target.value)}
icon={<Scale size={10} />}
autoFocus={activePlan && !isPlanFinished && activePlan.steps[currentStepIndex]?.isWeighted && (selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STRENGTH)}
/>
)}
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && (
<FilledInput
label={t('reps', lang)}
value={reps}
onChange={(e: any) => setReps(e.target.value)}
icon={<Activity size={10} />}
type="number"
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={t('time_sec', lang)}
value={duration}
onChange={(e: any) => setDuration(e.target.value)}
icon={<TimerIcon size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && (
<FilledInput
label={t('dist_m', lang)}
value={distance}
onChange={(e: any) => setDistance(e.target.value)}
icon={<ArrowRight size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.HIGH_JUMP) && (
<FilledInput
label={t('height_cm', lang)}
value={height}
onChange={(e: any) => setHeight(e.target.value)}
icon={<ArrowUp size={10} />}
/>
)}
</div>
{(selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
<div className="flex items-center gap-4 px-2">
<div className="flex items-center gap-2 text-on-surface-variant">
<Percent size={16} />
<span className="text-xs font-medium">{t('body_weight_percent', lang)}</span>
</div>
<input
type="number"
className="w-20 border-b border-outline-variant bg-transparent text-center text-on-surface focus:border-primary focus:outline-none"
value={bwPercentage}
onChange={(e) => setBwPercentage(e.target.value)}
/>
<span className="text-on-surface-variant text-sm">%</span>
</div>
)}
<button
onClick={handleAddSet}
className="w-full h-14 bg-primary-container text-on-primary-container font-medium text-lg rounded-full shadow-elevation-2 hover:shadow-elevation-3 active:scale-[0.98] transition-all flex items-center justify-center gap-2"
>
<CheckCircle size={24} />
<span>{t('log_set', lang)}</span>
</button>
<div className="flex justify-center">
<div className="bg-surface-container px-4 py-2 rounded-full border border-outline-variant/20 text-xs text-on-surface-variant">
{t('prev', lang)}: <span className="text-on-surface font-medium ml-1">
{getLastSetForExercise(userId, selectedExercise.id) ? (
<>
{getLastSetForExercise(userId, selectedExercise.id)?.weight ? `${getLastSetForExercise(userId, selectedExercise.id)?.weight}kg × ` : ''}
{getLastSetForExercise(userId, selectedExercise.id)?.reps ? `${getLastSetForExercise(userId, selectedExercise.id)?.reps}` : ''}
{getLastSetForExercise(userId, selectedExercise.id)?.distanceMeters ? `${getLastSetForExercise(userId, selectedExercise.id)?.distanceMeters}m` : ''}
{getLastSetForExercise(userId, selectedExercise.id)?.height ? `${getLastSetForExercise(userId, selectedExercise.id)?.height}cm` : ''}
{getLastSetForExercise(userId, selectedExercise.id)?.durationSeconds ? `${getLastSetForExercise(userId, selectedExercise.id)?.durationSeconds}s` : ''}
</>
) : '—'}
</span>
</div>
</div>
</div>
)}
{activeSession.sets.length > 0 && (
<div className="pt-4">
<h3 className="text-sm text-primary font-medium px-2 mb-3 tracking-wide">{t('history_section', lang)}</h3>
<div className="flex flex-col gap-2">
{[...activeSession.sets].reverse().map((set, idx) => {
const setNumber = activeSession.sets.length - idx;
return (
<div key={set.id} className="flex justify-between items-center p-4 bg-surface-container rounded-xl shadow-elevation-1 animate-in fade-in slide-in-from-bottom-2">
<div className="flex items-center gap-4">
<div className="w-8 h-8 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center text-xs font-bold">
{setNumber}
</div>
<div>
<div className="text-base font-medium text-on-surface">{set.exerciseName}</div>
<div className="text-sm text-on-surface-variant">
{set.weight !== undefined && `${set.weight}kg `}
{set.reps !== undefined && `x ${set.reps}`}
{set.distanceMeters !== undefined && `${set.distanceMeters}m`}
{set.durationSeconds !== undefined && `${set.durationSeconds}s`}
{set.height !== undefined && `${set.height}cm`}
</div>
</div>
</div>
<button
onClick={() => onRemoveSet(set.id)}
className="p-2 text-on-surface-variant hover:text-error hover:bg-error-container/10 rounded-full transition-colors"
>
<Trash2 size={20} />
</button>
</div>
);
})}
</div>
</div>
)}
</div>
{isCreating && (
<div className="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-4 backdrop-blur-sm">
<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">
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-normal text-on-surface">{t('create_exercise', lang)}</h3>
<button onClick={() => setIsCreating(false)} className="p-2 bg-surface-container-high rounded-full hover:bg-outline-variant/20"><X size={20} /></button>
</div>
<div className="space-y-6">
<FilledInput
label={t('ex_name', lang)}
value={newName}
onChange={(e: any) => setNewName(e.target.value)}
type="text"
autoFocus
/>
<div>
<label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label>
<div className="flex flex-wrap gap-2">
{[
{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) => (
<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 && (
<FilledInput
label={t('body_weight_percent', lang)}
value={newBwPercentage}
onChange={(e: any) => setNewBwPercentage(e.target.value)}
icon={<Percent size={12}/>}
/>
)}
<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>
);
};
export default Tracker;