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

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

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

177
App.tsx Normal file
View File

@@ -0,0 +1,177 @@
import React, { useState, useEffect } from 'react';
import Navbar from './components/Navbar';
import Tracker from './components/Tracker';
import History from './components/History';
import Stats from './components/Stats';
import AICoach from './components/AICoach';
import Plans from './components/Plans';
import Login from './components/Login';
import Profile from './components/Profile';
import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types';
import { getSessions, saveSession, deleteSession } from './services/storage';
import { getCurrentUserProfile } from './services/auth';
import { getSystemLanguage } from './services/i18n';
function App() {
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [currentTab, setCurrentTab] = useState<TabView>('TRACK');
const [language, setLanguage] = useState<Language>('en');
const [sessions, setSessions] = useState<WorkoutSession[]>([]);
const [activeSession, setActiveSession] = useState<WorkoutSession | null>(null);
const [activePlan, setActivePlan] = useState<WorkoutPlan | null>(null);
useEffect(() => {
// Set initial language
setLanguage(getSystemLanguage());
}, []);
useEffect(() => {
if (currentUser) {
setSessions(getSessions(currentUser.id));
const profile = getCurrentUserProfile(currentUser.id);
if (profile?.language) {
setLanguage(profile.language);
}
} else {
setSessions([]);
}
}, [currentUser]);
const handleLogin = (user: User) => {
setCurrentUser(user);
setCurrentTab('TRACK');
};
const handleLogout = () => {
setCurrentUser(null);
setActiveSession(null);
setActivePlan(null);
};
const handleLanguageChange = (lang: Language) => {
setLanguage(lang);
};
const handleStartSession = (plan?: WorkoutPlan) => {
if (!currentUser) return;
// Get latest weight from profile or default
const profile = getCurrentUserProfile(currentUser.id);
const currentWeight = profile?.weight || 70;
const newSession: WorkoutSession = {
id: crypto.randomUUID(),
startTime: Date.now(),
userBodyWeight: currentWeight,
sets: [],
planId: plan?.id,
planName: plan?.name
};
setActivePlan(plan || null);
setActiveSession(newSession);
setCurrentTab('TRACK');
};
const handleEndSession = () => {
if (activeSession && currentUser) {
const finishedSession = { ...activeSession, endTime: Date.now() };
saveSession(currentUser.id, finishedSession);
setSessions(prev => [finishedSession, ...prev]);
setActiveSession(null);
setActivePlan(null);
}
};
const handleAddSet = (set: WorkoutSet) => {
if (activeSession) {
setActiveSession(prev => {
if (!prev) return null;
return {
...prev,
sets: [...prev.sets, set]
};
});
}
};
const handleRemoveSetFromActive = (setId: string) => {
if (activeSession) {
setActiveSession(prev => {
if (!prev) return null;
return {
...prev,
sets: prev.sets.filter(s => s.id !== setId)
};
});
}
};
const handleUpdateSession = (updatedSession: WorkoutSession) => {
if (!currentUser) return;
saveSession(currentUser.id, updatedSession);
setSessions(prev => prev.map(s => s.id === updatedSession.id ? updatedSession : s));
};
const handleDeleteSession = (sessionId: string) => {
if (!currentUser) return;
deleteSession(currentUser.id, sessionId);
setSessions(prev => prev.filter(s => s.id !== sessionId));
};
if (!currentUser) {
return <Login onLogin={handleLogin} language={language} onLanguageChange={handleLanguageChange} />;
}
return (
<div className="h-screen w-screen bg-surface text-on-surface font-sans flex flex-col md:flex-row overflow-hidden">
{/* Desktop Navigation Rail (Left) */}
<Navbar currentTab={currentTab} onTabChange={setCurrentTab} lang={language} />
{/* Main Content Area */}
<main className="flex-1 h-full relative w-full max-w-5xl mx-auto md:px-4">
<div className="h-full w-full pb-20 md:pb-0 bg-surface">
{currentTab === 'TRACK' && (
<Tracker
userId={currentUser.id}
activeSession={activeSession}
activePlan={activePlan}
onSessionStart={handleStartSession}
onSessionEnd={handleEndSession}
onSetAdded={handleAddSet}
onRemoveSet={handleRemoveSetFromActive}
lang={language}
/>
)}
{currentTab === 'PLANS' && (
<Plans userId={currentUser.id} onStartPlan={handleStartSession} lang={language} />
)}
{currentTab === 'HISTORY' && (
<History
sessions={sessions}
onUpdateSession={handleUpdateSession}
onDeleteSession={handleDeleteSession}
lang={language}
/>
)}
{currentTab === 'STATS' && <Stats sessions={sessions} lang={language} />}
{currentTab === 'AI_COACH' && <AICoach history={sessions} lang={language} />}
{currentTab === 'PROFILE' && (
<Profile
user={currentUser}
onLogout={handleLogout}
lang={language}
onLanguageChange={handleLanguageChange}
/>
)}
</div>
</main>
{/* Mobile Navigation (rendered inside Navbar component, fixed to bottom) */}
</div>
);
}
export default App;

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1S85Aj0cTtSgK3yfj_Ziq7_d7hRSxV70Q
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

144
components/AICoach.tsx Normal file
View File

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

304
components/History.tsx Normal file
View File

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

148
components/Login.tsx Normal file
View File

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

82
components/Navbar.tsx Normal file
View File

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

255
components/Plans.tsx Normal file
View File

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

532
components/Profile.tsx Normal file
View File

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

136
components/Stats.tsx Normal file
View File

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

581
components/Tracker.tsx Normal file
View File

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

101
index.html Normal file
View File

@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>GymFlow AI</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Roboto', 'sans-serif'],
},
colors: {
// Material 3 Dark Theme approximation
'surface': '#141218', // Very dark background
'surface-container-low': '#1D1B20',
'surface-container': '#211F26', // Cards
'surface-container-high': '#2B2930', // Input fields
'on-surface': '#E6E0E9',
'on-surface-variant': '#CAC4D0',
'primary': '#D0BCFF', // M3 Purple/Lavender tone often used as primary in dark mode, but keeping Emerald logic
// Let's map to Emerald for fitness vibe but M3 structure
'm3-primary': '#80D8FF', // Light Blue
'm3-on-primary': '#003355',
// Using the Emerald scheme from previous request but mapped to M3 tokens
'primary': '#6EE7B7', // Emerald 300 (Light for dark mode)
'on-primary': '#003828', // Emerald 900
'primary-container': '#00513B', // Emerald 800
'on-primary-container': '#6EE7B7', // Emerald 300
'secondary': '#CCC2DC',
'secondary-container': '#4A4458',
'on-secondary-container': '#E8DEF8',
'tertiary': '#EFB8C8',
'tertiary-container': '#633B48',
'error': '#F2B8B5',
'error-container': '#8C1D18',
'outline': '#938F99',
'outline-variant': '#49454F'
},
boxShadow: {
'elevation-1': '0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.30)',
'elevation-2': '0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.30)',
'elevation-3': '0px 4px 8px 3px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.30)',
}
}
}
}
</script>
<style>
body {
font-family: 'Roboto', sans-serif;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #49454F;
border-radius: 3px;
}
/* Hide number input arrows */
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>
<script type="importmap">
{
"imports": {
"recharts": "https://aistudiocdn.com/recharts@^3.4.1",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0",
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.554.0",
"react": "https://aistudiocdn.com/react@^19.2.0",
"react/": "https://aistudiocdn.com/react@^19.2.0/"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-surface text-on-surface h-screen overflow-hidden selection:bg-primary-container selection:text-on-primary-container">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

15
index.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "GymFlow AI",
"description": "Трекер тренировок с AI-аналитикой.",
"requestFramePermissions": []
}

2978
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "gymflow-ai",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"recharts": "^3.4.1",
"react-dom": "^19.2.0",
"@google/genai": "^1.30.0",
"lucide-react": "^0.554.0",
"react": "^19.2.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

127
services/auth.ts Normal file
View File

@@ -0,0 +1,127 @@
import { User, UserRole, UserProfile } from '../types';
import { deleteAllUserData } from './storage';
const USERS_KEY = 'gymflow_users';
interface StoredUser extends User {
password: string; // In a real app, this would be a hash
}
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@gymflow.ai';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
export const getUsers = (): StoredUser[] => {
try {
const data = localStorage.getItem(USERS_KEY);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
};
const saveUsers = (users: StoredUser[]) => {
localStorage.setItem(USERS_KEY, JSON.stringify(users));
};
export const login = (email: string, password: string): { success: boolean; user?: User; error?: string } => {
// 1. Check Admin
if (email === ADMIN_EMAIL && password === ADMIN_PASSWORD) {
return {
success: true,
user: {
id: 'admin_001',
email: ADMIN_EMAIL,
role: 'ADMIN',
isFirstLogin: false,
profile: { weight: 80 }
}
};
}
// 2. Check Users
const users = getUsers();
const found = users.find(u => u.email.toLowerCase() === email.toLowerCase());
if (found && found.password === password) {
if (found.isBlocked) {
return { success: false, error: 'Account is blocked' };
}
// Return user without password field
const { password, ...userSafe } = found;
return { success: true, user: userSafe };
}
return { success: false, error: 'Invalid credentials' };
};
export const createUser = (email: string, password: string): { success: boolean; error?: string } => {
const users = getUsers();
if (users.find(u => u.email.toLowerCase() === email.toLowerCase())) {
return { success: false, error: 'User already exists' };
}
const newUser: StoredUser = {
id: crypto.randomUUID(),
email,
password,
role: 'USER',
isFirstLogin: true,
profile: { weight: 70 }
};
users.push(newUser);
saveUsers(users);
return { success: true };
};
export const deleteUser = (userId: string) => {
let users = getUsers();
users = users.filter(u => u.id !== userId);
saveUsers(users);
deleteAllUserData(userId);
};
export const toggleBlockUser = (userId: string, block: boolean) => {
const users = getUsers();
const u = users.find(u => u.id === userId);
if (u) {
u.isBlocked = block;
saveUsers(users);
}
};
export const adminResetPassword = (userId: string, newPass: string) => {
const users = getUsers();
const u = users.find(u => u.id === userId);
if (u) {
u.password = newPass;
u.isFirstLogin = true; // Force them to change it
saveUsers(users);
}
};
export const updateUserProfile = (userId: string, profile: Partial<UserProfile>) => {
const users = getUsers();
const idx = users.findIndex(u => u.id === userId);
if (idx >= 0) {
users[idx].profile = { ...users[idx].profile, ...profile };
saveUsers(users);
}
};
export const changePassword = (userId: string, newPassword: string) => {
const users = getUsers();
const idx = users.findIndex(u => u.id === userId);
if (idx >= 0) {
users[idx].password = newPassword;
users[idx].isFirstLogin = false;
saveUsers(users);
}
};
export const getCurrentUserProfile = (userId: string): UserProfile | undefined => {
if (userId === 'admin_001') return { weight: 80 }; // Mock admin profile
const users = getUsers();
return users.find(u => u.id === userId)?.profile;
}

38
services/geminiService.ts Normal file
View File

@@ -0,0 +1,38 @@
import { GoogleGenAI, Chat } from "@google/genai";
import { WorkoutSession } from '../types';
const MODEL_ID = 'gemini-2.5-flash';
export const createFitnessChat = (history: WorkoutSession[]): Chat | null => {
const apiKey = process.env.API_KEY;
if (!apiKey) return null;
const ai = new GoogleGenAI({ apiKey });
// Summarize data to reduce token count while keeping relevant context
const summary = history.slice(0, 10).map(s => ({
date: new Date(s.startTime).toLocaleDateString('ru-RU'),
userWeight: s.userBodyWeight,
exercises: s.sets.map(set => `${set.exerciseName}: ${set.weight ? set.weight + 'кг' : ''}${set.reps ? ' x ' + set.reps + 'повт' : ''} ${set.distanceMeters ? set.distanceMeters + 'м' : ''}`).join(', ')
}));
const systemInstruction = `
Ты — опытный и поддерживающий фитнес-тренер.
Твоя задача — анализировать тренировки пользователя и давать краткие, полезные советы на русском языке.
Учитывай вес пользователя (userWeight в json), если он указан, при анализе прогресса в упражнениях с собственным весом.
Вот последние 10 тренировок пользователя (в формате JSON):
${JSON.stringify(summary)}
Если пользователь спрашивает о прогрессе, используй эти данные.
Отвечай емко, мотивирующе. Избегай длинных лекций, если не просили.
`;
return ai.chats.create({
model: MODEL_ID,
config: {
systemInstruction,
},
});
};

279
services/i18n.ts Normal file
View File

@@ -0,0 +1,279 @@
import { Language } from '../types';
export const getSystemLanguage = (): Language => {
if (typeof navigator === 'undefined') return 'en';
const lang = navigator.language.split('-')[0];
return lang === 'ru' ? 'ru' : 'en';
};
const translations = {
en: {
// Tabs
tab_tracker: 'Tracker',
tab_plans: 'Plans',
tab_history: 'History',
tab_stats: 'Stats',
tab_ai: 'AI Coach',
tab_profile: 'Profile',
// Auth
login_title: 'GymFlow AI',
login_email: 'Email',
login_password: 'Password',
login_btn: 'Login',
login_contact_admin: 'Contact administrator to get an account.',
login_error: 'Invalid email or password',
login_password_short: 'Password too short',
change_pass_title: 'Change Password',
change_pass_desc: 'This is your first login. Please set a new password.',
change_pass_new: 'New Password',
change_pass_save: 'Save & Login',
// Tracker
ready_title: 'Ready?',
ready_subtitle: 'Start your workout and break records.',
my_weight: 'My Weight (kg)',
change_in_profile: 'Change in profile',
free_workout: 'Free Workout',
or_choose_plan: 'Or choose a plan',
exercises_count: 'exercises',
prep_title: 'Preparation',
prep_no_instructions: 'No specific instructions.',
cancel: 'Cancel',
start: 'Start',
finish: 'Finish',
plan_completed: 'Plan Completed!',
step: 'Step',
of: 'of',
select_exercise: 'Select Exercise',
weight_kg: 'Weight (kg)',
reps: 'Reps',
time_sec: 'Time (sec)',
dist_m: 'Dist (m)',
height_cm: 'Height (cm)',
body_weight_percent: 'Body Weight',
log_set: 'Log Set',
prev: 'Prev',
history_section: 'History',
create_exercise: 'New Exercise',
ex_name: 'Name',
ex_type: 'Type',
create_btn: 'Create',
completed_session_sets: 'Completed in this session',
add_weight: 'Add. Weight',
// Types
type_strength: 'Strength',
type_bodyweight: 'Bodyweight',
type_cardio: 'Cardio',
type_static: 'Static',
type_height: 'Height',
type_dist: 'Length',
type_jump: 'Plyo',
// History
history_empty: 'History is empty. Finish your first workout!',
sets_count: 'Sets',
finished: 'Finished',
delete_workout: 'Delete workout?',
delete_confirm: 'This action cannot be undone.',
delete: 'Delete',
edit: 'Edit',
save: 'Save',
start_time: 'Start',
end_time: 'End',
max: 'Max',
upto: 'Up to',
// Plans
plans_empty: 'No plans created',
plan_editor: 'Plan Editor',
plan_name_ph: 'E.g. Leg Day',
plan_desc_ph: 'Describe preparation...',
exercises_list: 'Exercises',
weighted: 'Weighted',
add_exercise: 'Add Exercise',
my_plans: 'My Plans',
// Stats
progress: 'Progress',
volume_title: 'Work Volume',
volume_subtitle: 'Tonnage (kg * reps)',
sets_title: 'Number of Sets',
weight_title: 'Body Weight History',
not_enough_data: 'Not enough data for statistics. Complete a few workouts!',
// AI
ai_expert: 'AI Expert',
ai_intro: 'Hi! I am your AI coach. I analyzed your workouts. Ask me about progress, technique, or routine.',
ai_typing: 'Typing...',
ai_placeholder: 'Ask about workouts...',
ai_error: 'API Key not configured',
// Profile
profile_title: 'Profile',
logout: 'Logout',
personal_data: 'Personal Data',
height: 'Height (cm)',
birth_date: 'Birth Date',
gender: 'Gender',
male: 'Male',
female: 'Female',
other: 'Other',
save_profile: 'Save Profile',
change_pass_btn: 'Change Password',
admin_area: 'Manage Users',
create_user: 'Create User',
user_created: 'User created',
language: 'Language',
admin_users_list: 'Users List',
block: 'Block',
unblock: 'Unblock',
reset_pass: 'Reset Pass',
delete_account: 'Delete Account',
delete_account_confirm: 'Are you sure? All your data (sessions, plans) will be permanently deleted.',
user_deleted: 'User deleted',
pass_reset: 'Password reset',
manage_exercises: 'Manage Exercises',
archive: 'Archive',
unarchive: 'Unarchive',
show_archived: 'Show Archived',
},
ru: {
// Tabs
tab_tracker: 'Трекер',
tab_plans: 'Планы',
tab_history: 'История',
tab_stats: 'Статы',
tab_ai: 'AI Тренер',
tab_profile: 'Профиль',
// Auth
login_title: 'GymFlow AI',
login_email: 'Email',
login_password: 'Пароль',
login_btn: 'Войти',
login_contact_admin: 'Свяжитесь с администратором для получения учетной записи.',
login_error: 'Неверный email или пароль',
login_password_short: 'Пароль слишком короткий',
change_pass_title: 'Смена пароля',
change_pass_desc: 'Это ваш первый вход. Пожалуйста, установите новый пароль.',
change_pass_new: 'Новый пароль',
change_pass_save: 'Сохранить и войти',
// Tracker
ready_title: 'Готовы?',
ready_subtitle: 'Начните тренировку и побейте рекорды.',
my_weight: 'Мой вес (кг)',
change_in_profile: 'Можно изменить в профиле',
free_workout: 'Свободная тренировка',
or_choose_plan: 'Или выберите план',
exercises_count: 'упражнений',
prep_title: 'Подготовка',
prep_no_instructions: 'Нет особых указаний.',
cancel: 'Отмена',
start: 'Начать',
finish: 'Завершить',
plan_completed: 'План выполнен!',
step: 'Шаг',
of: 'из',
select_exercise: 'Выберите упражнение',
weight_kg: 'Вес (кг)',
reps: 'Повторения',
time_sec: 'Время (сек)',
dist_m: 'Дистанция (м)',
height_cm: 'Высота (см)',
body_weight_percent: 'Вес тела',
log_set: 'Записать подход',
prev: 'Предыдущий',
history_section: 'История',
create_exercise: 'Новое упражнение',
ex_name: 'Название',
ex_type: 'Тип упражнения',
create_btn: 'Создать',
completed_session_sets: 'Выполнено в этой тренировке',
add_weight: 'Доп. вес',
// Types
type_strength: 'Силовое',
type_bodyweight: 'Свой вес',
type_cardio: 'Кардио',
type_static: 'Статика',
type_height: 'Высота',
type_dist: 'Длина',
type_jump: 'Прыжки',
// History
history_empty: 'История пуста. Завершите свою первую тренировку!',
sets_count: 'Сетов',
finished: 'Завершено',
delete_workout: 'Удалить тренировку?',
delete_confirm: 'Это действие нельзя отменить.',
delete: 'Удалить',
edit: 'Редактировать',
save: 'Сохранить',
start_time: 'Начало',
end_time: 'Конец',
max: 'Макс',
upto: 'До',
// Plans
plans_empty: 'Нет созданных планов',
plan_editor: 'Редактор плана',
plan_name_ph: 'Например: День ног',
plan_desc_ph: 'Опишите подготовку...',
exercises_list: 'Упражнения',
weighted: 'С отягощением',
add_exercise: 'Добавить упражнение',
my_plans: 'Мои планы',
// Stats
progress: 'Прогресс',
volume_title: 'Объем работы',
volume_subtitle: 'Тоннаж (кг * повторения)',
sets_title: 'Количество сетов',
weight_title: 'История веса тела',
not_enough_data: 'Недостаточно данных для статистики. Проведите хотя бы пару тренировок!',
// AI
ai_expert: 'AI Эксперт',
ai_intro: 'Привет! Я твой AI-тренер. Я проанализировал твои тренировки. Спрашивай меня о прогрессе, технике или плане занятий.',
ai_typing: 'Печатает...',
ai_placeholder: 'Спроси о тренировках...',
ai_error: 'API ключ не настроен',
// Profile
profile_title: 'Профиль',
logout: 'Выйти',
personal_data: 'Личные данные',
height: 'Рост (см)',
birth_date: 'Дата рожд.',
gender: 'Пол',
male: 'Мужской',
female: 'Женский',
other: 'Другой',
save_profile: 'Сохранить профиль',
change_pass_btn: 'Сменить пароль',
admin_area: 'Управление пользователями',
create_user: 'Создать пользователя',
user_created: 'Пользователь создан',
language: 'Язык / Language',
admin_users_list: 'Список пользователей',
block: 'Блок',
unblock: 'Разблок',
reset_pass: 'Сброс пароля',
delete_account: 'Удалить аккаунт',
delete_account_confirm: 'Вы уверены? Все ваши данные (сессии, планы) будут безвозвратно удалены.',
user_deleted: 'Пользователь удален',
pass_reset: 'Пароль сброшен',
manage_exercises: 'Управление упражнениями',
archive: 'Архив',
unarchive: 'Вернуть',
show_archived: 'Показать архивные',
}
};
export const t = (key: keyof typeof translations['en'], lang: Language) => {
return translations[lang][key] || translations['en'][key] || key;
};

137
services/storage.ts Normal file
View File

@@ -0,0 +1,137 @@
import { WorkoutSession, ExerciseDef, ExerciseType, WorkoutSet, WorkoutPlan } from '../types';
import { updateUserProfile } from './auth';
// Helper to namespace keys
const getKey = (base: string, userId: string) => `${base}_${userId}`;
const SESSIONS_KEY = 'gymflow_sessions';
const EXERCISES_KEY = 'gymflow_exercises'; // Custom exercises are per user
const PLANS_KEY = 'gymflow_plans';
const DEFAULT_EXERCISES: ExerciseDef[] = [
{ id: 'bp', name: 'Жим лежа', type: ExerciseType.STRENGTH },
{ id: 'sq', name: 'Приседания со штангой', type: ExerciseType.STRENGTH },
{ id: 'dl', name: 'Становая тяга', type: ExerciseType.STRENGTH },
{ id: 'pu', name: 'Подтягивания', type: ExerciseType.BODYWEIGHT, bodyWeightPercentage: 100 },
{ id: 'run', name: 'Бег', type: ExerciseType.CARDIO },
{ id: 'plank', name: 'Планка', type: ExerciseType.STATIC, bodyWeightPercentage: 100 },
{ id: 'dip', name: 'Отжимания на брусьях', type: ExerciseType.BODYWEIGHT, bodyWeightPercentage: 100 },
{ id: 'pushup', name: 'Отжимания от пола', type: ExerciseType.BODYWEIGHT, bodyWeightPercentage: 65 },
{ id: 'air_sq', name: 'Приседания (свой вес)', type: ExerciseType.BODYWEIGHT, bodyWeightPercentage: 75 },
];
export const getSessions = (userId: string): WorkoutSession[] => {
try {
const data = localStorage.getItem(getKey(SESSIONS_KEY, userId));
return data ? JSON.parse(data) : [];
} catch (e) {
return [];
}
};
export const saveSession = (userId: string, session: WorkoutSession): void => {
const sessions = getSessions(userId);
const index = sessions.findIndex(s => s.id === session.id);
if (index >= 0) {
sessions[index] = session;
} else {
sessions.unshift(session);
}
localStorage.setItem(getKey(SESSIONS_KEY, userId), JSON.stringify(sessions));
// Auto-update user weight profile if present in session
if (session.userBodyWeight) {
updateUserProfile(userId, { weight: session.userBodyWeight });
}
};
export const deleteSession = (userId: string, id: string): void => {
let sessions = getSessions(userId);
sessions = sessions.filter(s => s.id !== id);
localStorage.setItem(getKey(SESSIONS_KEY, userId), JSON.stringify(sessions));
};
export const deleteAllUserData = (userId: string) => {
localStorage.removeItem(getKey(SESSIONS_KEY, userId));
localStorage.removeItem(getKey(EXERCISES_KEY, userId));
localStorage.removeItem(getKey(PLANS_KEY, userId));
};
export const getExercises = (userId: string): ExerciseDef[] => {
try {
const data = localStorage.getItem(getKey(EXERCISES_KEY, userId));
const savedExercises: ExerciseDef[] = data ? JSON.parse(data) : [];
// Create a map of saved exercises for easy lookup
const savedMap = new Map(savedExercises.map(ex => [ex.id, ex]));
// Start with defaults
const mergedExercises = DEFAULT_EXERCISES.map(defEx => {
// If user has a saved version of this default exercise (e.g. edited or archived), use that
if (savedMap.has(defEx.id)) {
const saved = savedMap.get(defEx.id)!;
savedMap.delete(defEx.id); // Remove from map so we don't add it again
return saved;
}
return defEx;
});
// Add remaining custom exercises (those that are not overrides of defaults)
return [...mergedExercises, ...Array.from(savedMap.values())];
} catch (e) {
return DEFAULT_EXERCISES;
}
};
export const saveExercise = (userId: string, exercise: ExerciseDef): void => {
try {
const data = localStorage.getItem(getKey(EXERCISES_KEY, userId));
let list: ExerciseDef[] = data ? JSON.parse(data) : [];
const index = list.findIndex(e => e.id === exercise.id);
if (index >= 0) {
list[index] = exercise;
} else {
list.push(exercise);
}
localStorage.setItem(getKey(EXERCISES_KEY, userId), JSON.stringify(list));
} catch {}
};
export const getLastSetForExercise = (userId: string, exerciseId: string): WorkoutSet | undefined => {
const sessions = getSessions(userId);
for (const session of sessions) {
for (let i = session.sets.length - 1; i >= 0; i--) {
if (session.sets[i].exerciseId === exerciseId) {
return session.sets[i];
}
}
}
return undefined;
}
export const getPlans = (userId: string): WorkoutPlan[] => {
try {
const data = localStorage.getItem(getKey(PLANS_KEY, userId));
return data ? JSON.parse(data) : [];
} catch {
return [];
}
};
export const savePlan = (userId: string, plan: WorkoutPlan): void => {
const plans = getPlans(userId);
const index = plans.findIndex(p => p.id === plan.id);
if (index >= 0) {
plans[index] = plan;
} else {
plans.push(plan);
}
localStorage.setItem(getKey(PLANS_KEY, userId), JSON.stringify(plans));
};
export const deletePlan = (userId: string, id: string): void => {
const plans = getPlans(userId).filter(p => p.id !== id);
localStorage.setItem(getKey(PLANS_KEY, userId), JSON.stringify(plans));
};

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

82
types.ts Normal file
View File

@@ -0,0 +1,82 @@
export enum ExerciseType {
STRENGTH = 'STRENGTH', // Reps, Weight
CARDIO = 'CARDIO', // Duration, Distance
STATIC = 'STATIC', // Duration, Weight (optional)
BODYWEIGHT = 'BODYWEIGHT', // Reps, Weighted (optional)
HIGH_JUMP = 'HIGH_JUMP', // Height
LONG_JUMP = 'LONG_JUMP', // Distance
PLYOMETRIC = 'PLYOMETRIC' // Reps
}
export interface WorkoutSet {
id: string;
exerciseId: string;
exerciseName: string;
type: ExerciseType;
reps?: number;
weight?: number;
durationSeconds?: number;
distanceMeters?: number;
height?: number;
bodyWeightPercentage?: number; // Percentage of bodyweight used (e.g. 65 for pushups)
timestamp: number;
}
export interface WorkoutSession {
id: string;
startTime: number;
endTime?: number;
sets: WorkoutSet[];
note?: string;
userBodyWeight?: number;
planId?: string; // Link to a plan if used
planName?: string;
}
export interface ExerciseDef {
id: string;
name: string;
type: ExerciseType;
defaultRestSeconds?: number;
bodyWeightPercentage?: number; // Default percentage
isArchived?: boolean;
}
export interface PlannedSet {
id: string;
exerciseId: string;
exerciseName: string; // Denormalized for easier display
exerciseType: ExerciseType;
isWeighted: boolean; // Prompt specifically asked for this flag
}
export interface WorkoutPlan {
id: string;
name: string;
description?: string; // Prep instructions
steps: PlannedSet[];
}
export type TabView = 'TRACK' | 'PLANS' | 'HISTORY' | 'STATS' | 'AI_COACH' | 'PROFILE';
export type UserRole = 'ADMIN' | 'USER';
export type Language = 'en' | 'ru';
export interface UserProfile {
weight?: number;
height?: number;
gender?: 'MALE' | 'FEMALE' | 'OTHER';
birthDate?: number; // timestamp
language?: Language;
}
export interface User {
id: string;
email: string;
role: UserRole;
profile: UserProfile;
isFirstLogin: boolean;
isBlocked?: boolean;
}

23
vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});