feat: Initial implementation of GymFlow fitness tracking application with workout, plan, and exercise management, stats, and AI coach features.
This commit is contained in:
144
components/AICoach.tsx
Normal file
144
components/AICoach.tsx
Normal 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
304
components/History.tsx
Normal 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
148
components/Login.tsx
Normal 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
82
components/Navbar.tsx
Normal 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
255
components/Plans.tsx
Normal 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
532
components/Profile.tsx
Normal 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
136
components/Stats.tsx
Normal 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
581
components/Tracker.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user