feat: Initial implementation of GymFlow fitness tracking application with workout, plan, and exercise management, stats, and AI coach features.
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
177
App.tsx
Normal 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
20
README.md
Normal 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
144
components/AICoach.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Bot, User, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { createFitnessChat } from '../services/geminiService';
|
||||
import { WorkoutSession, Language } from '../types';
|
||||
import { Chat, GenerateContentResponse } from '@google/genai';
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
interface AICoachProps {
|
||||
history: WorkoutSession[];
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'model';
|
||||
text: string;
|
||||
}
|
||||
|
||||
const AICoach: React.FC<AICoachProps> = ({ history, lang }) => {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ id: 'intro', role: 'model', text: t('ai_intro', lang) }
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const chatSessionRef = useRef<Chat | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const chat = createFitnessChat(history);
|
||||
if (chat) {
|
||||
chatSessionRef.current = chat;
|
||||
} else {
|
||||
setError(t('ai_error', lang));
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to initialize AI");
|
||||
}
|
||||
}, [history, lang]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !chatSessionRef.current || loading) return;
|
||||
|
||||
const userMsg: Message = { id: crypto.randomUUID(), role: 'user', text: input };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result: GenerateContentResponse = await chatSessionRef.current.sendMessage({ message: userMsg.text });
|
||||
const text = result.text;
|
||||
|
||||
const aiMsg: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'model',
|
||||
text: text || "Error generating response."
|
||||
};
|
||||
setMessages(prev => [...prev, aiMsg]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'model', text: 'Connection error.' }]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-6 text-center text-on-surface-variant">
|
||||
<AlertTriangle size={48} className="text-error mb-4" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface">
|
||||
{/* Header */}
|
||||
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center gap-3 z-10">
|
||||
<div className="w-10 h-10 rounded-full bg-secondary-container flex items-center justify-center">
|
||||
<Bot size={20} className="text-on-secondary-container" />
|
||||
</div>
|
||||
<h2 className="text-xl font-normal text-on-surface">{t('ai_expert', lang)}</h2>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] p-4 rounded-[20px] text-sm leading-relaxed shadow-sm ${
|
||||
msg.role === 'user'
|
||||
? 'bg-primary text-on-primary rounded-br-none'
|
||||
: 'bg-surface-container-high text-on-surface border border-outline-variant/20 rounded-bl-none'
|
||||
}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-surface-container-high px-4 py-3 rounded-[20px] rounded-bl-none flex gap-2 items-center text-on-surface-variant text-sm">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t('ai_typing', lang)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 bg-surface-container mt-auto">
|
||||
<div className="flex gap-2 items-center bg-surface-container-high rounded-full border border-outline-variant px-2 py-1">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-transparent border-none px-4 py-3 text-on-surface focus:outline-none placeholder-on-surface-variant"
|
||||
placeholder={t('ai_placeholder', lang)}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-on-primary hover:opacity-90 disabled:opacity-50 transition-opacity shrink-0"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AICoach;
|
||||
304
components/History.tsx
Normal file
304
components/History.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, Clock, TrendingUp, Scale, Pencil, Trash2, X, Save, ArrowRight, ArrowUp, Timer, Activity, Dumbbell, Percent } from 'lucide-react';
|
||||
import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types';
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
interface HistoryProps {
|
||||
sessions: WorkoutSession[];
|
||||
onUpdateSession?: (session: WorkoutSession) => void;
|
||||
onDeleteSession?: (sessionId: string) => void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const History: React.FC<HistoryProps> = ({ sessions, onUpdateSession, onDeleteSession, lang }) => {
|
||||
const [editingSession, setEditingSession] = useState<WorkoutSession | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const calculateSessionWork = (session: WorkoutSession) => {
|
||||
const bw = session.userBodyWeight || 70;
|
||||
return session.sets.reduce((acc, set) => {
|
||||
let w = 0;
|
||||
if (set.type === ExerciseType.STRENGTH) {
|
||||
w = (set.weight || 0) * (set.reps || 0);
|
||||
}
|
||||
if (set.type === ExerciseType.BODYWEIGHT) {
|
||||
const percent = set.bodyWeightPercentage || 100;
|
||||
const effectiveBw = bw * (percent / 100);
|
||||
w = (effectiveBw + (set.weight || 0)) * (set.reps || 0);
|
||||
}
|
||||
return acc + Math.max(0, w);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const formatDateForInput = (timestamp: number) => {
|
||||
const d = new Date(timestamp);
|
||||
const pad = (n: number) => n < 10 ? '0' + n : n;
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
const parseDateFromInput = (value: string) => {
|
||||
return new Date(value).getTime();
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editingSession && onUpdateSession) {
|
||||
onUpdateSession(editingSession);
|
||||
setEditingSession(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSet = (setId: string, field: keyof WorkoutSet, value: number) => {
|
||||
if (!editingSession) return;
|
||||
const updatedSets = editingSession.sets.map(s =>
|
||||
s.id === setId ? { ...s, [field]: value } : s
|
||||
);
|
||||
setEditingSession({ ...editingSession, sets: updatedSets });
|
||||
};
|
||||
|
||||
const handleDeleteSet = (setId: string) => {
|
||||
if (!editingSession) return;
|
||||
setEditingSession({
|
||||
...editingSession,
|
||||
sets: editingSession.sets.filter(s => s.id !== setId)
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (deletingId && onDeleteSession) {
|
||||
onDeleteSession(deletingId);
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant p-8 text-center">
|
||||
<Clock size={48} className="mb-4 opacity-50" />
|
||||
<p>{t('history_empty', lang)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-surface">
|
||||
<div className="p-4 bg-surface-container shadow-elevation-1 z-10">
|
||||
<h2 className="text-2xl font-normal text-on-surface">{t('tab_history', lang)}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-20">
|
||||
{sessions.map((session) => {
|
||||
const totalWork = calculateSessionWork(session);
|
||||
|
||||
return (
|
||||
<div key={session.id} className="bg-surface-container rounded-xl p-5 shadow-elevation-1 border border-outline-variant/20">
|
||||
<div className="flex justify-between items-start mb-4 border-b border-outline-variant pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-tertiary-container text-on-tertiary-container flex items-center justify-center">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-on-surface text-lg">
|
||||
{new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</div>
|
||||
<div className="text-xs text-on-surface-variant flex items-center gap-2">
|
||||
<span>{new Date(session.startTime).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
|
||||
{session.userBodyWeight && <span className="px-2 py-0.5 rounded-full bg-surface-container-high text-on-surface">{session.userBodyWeight}kg</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setEditingSession(JSON.parse(JSON.stringify(session)))}
|
||||
className="p-2 text-on-surface-variant hover:text-primary hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<Pencil size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingId(session.id)}
|
||||
className="p-2 text-on-surface-variant hover:text-error hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{Array.from(new Set(session.sets.map(s => s.exerciseName))).slice(0, 4).map(exName => {
|
||||
const sets = session.sets.filter(s => s.exerciseName === exName);
|
||||
const count = sets.length;
|
||||
const bestSet = sets[0];
|
||||
let detail = "";
|
||||
if (bestSet.type === ExerciseType.HIGH_JUMP) detail = `${t('max', lang)}: ${Math.max(...sets.map(s => s.height || 0))}cm`;
|
||||
else if (bestSet.type === ExerciseType.LONG_JUMP) detail = `${t('max', lang)}: ${Math.max(...sets.map(s => s.distanceMeters || 0))}m`;
|
||||
else if (bestSet.type === ExerciseType.STRENGTH) detail = `${t('upto', lang)} ${Math.max(...sets.map(s => s.weight || 0))}kg`;
|
||||
|
||||
return (
|
||||
<div key={exName} className="flex justify-between text-sm items-center">
|
||||
<span className="text-on-surface">{exName}</span>
|
||||
<span className="text-on-surface-variant flex gap-2 items-center">
|
||||
{detail && <span className="text-[10px] bg-surface-container-high px-2 py-0.5 rounded text-on-surface-variant">{detail}</span>}
|
||||
<span>{count}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{new Set(session.sets.map(s => s.exerciseName)).size > 4 && (
|
||||
<div className="text-xs text-center text-on-surface-variant mt-2">
|
||||
+ ...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-outline-variant flex justify-between items-center">
|
||||
<div className="flex gap-4">
|
||||
<span className="text-xs text-on-surface-variant">{t('sets_count', lang)}: <span className="text-on-surface font-medium">{session.sets.length}</span></span>
|
||||
{totalWork > 0 && (
|
||||
<span className="text-xs text-on-surface-variant flex items-center gap-1">
|
||||
<Scale size={12} />
|
||||
{(totalWork / 1000).toFixed(1)}t
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-primary text-xs font-bold tracking-wide">
|
||||
<TrendingUp size={14} />
|
||||
{t('finished', lang)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)})}
|
||||
</div>
|
||||
|
||||
{/* DELETE CONFIRMATION DIALOG (MD3) */}
|
||||
{deletingId && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-surface-container w-full max-w-xs rounded-[28px] p-6 shadow-elevation-3">
|
||||
<h3 className="text-xl font-normal text-on-surface mb-2">{t('delete_workout', lang)}</h3>
|
||||
<p className="text-sm text-on-surface-variant mb-8">{t('delete_confirm', lang)}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setDeletingId(null)}
|
||||
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
|
||||
>
|
||||
{t('cancel', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmDelete}
|
||||
className="px-4 py-2 rounded-full bg-error-container text-on-error-container font-medium"
|
||||
>
|
||||
{t('delete', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EDIT SESSION FULLSCREEN DIALOG */}
|
||||
{editingSession && (
|
||||
<div className="fixed inset-0 z-[60] bg-surface flex flex-col animate-in slide-in-from-bottom-10 duration-200">
|
||||
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container shadow-elevation-1">
|
||||
<button onClick={() => setEditingSession(null)} className="text-on-surface-variant hover:text-on-surface">
|
||||
<X />
|
||||
</button>
|
||||
<h2 className="text-lg font-medium text-on-surface">{t('edit', lang)}</h2>
|
||||
<button onClick={handleSaveEdit} className="text-primary font-medium flex items-center gap-2">
|
||||
{t('save', lang)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Meta Info */}
|
||||
<div className="bg-surface-container p-4 rounded-xl border border-outline-variant/20 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
|
||||
<label className="text-[10px] text-on-surface-variant font-bold block">{t('start_time', lang)}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formatDateForInput(editingSession.startTime)}
|
||||
onChange={(e) => setEditingSession({...editingSession, startTime: parseDateFromInput(e.target.value)})}
|
||||
className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
|
||||
<label className="text-[10px] text-on-surface-variant font-bold block">{t('end_time', lang)}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={editingSession.endTime ? formatDateForInput(editingSession.endTime) : ''}
|
||||
onChange={(e) => setEditingSession({...editingSession, endTime: parseDateFromInput(e.target.value)})}
|
||||
className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
|
||||
<label className="text-[10px] text-on-surface-variant font-bold block">{t('weight_kg', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingSession.userBodyWeight || ''}
|
||||
onChange={(e) => setEditingSession({...editingSession, userBodyWeight: parseFloat(e.target.value)})}
|
||||
className="w-full bg-transparent text-on-surface focus:outline-none text-lg mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-primary ml-1">{t('sets_count', lang)} ({editingSession.sets.length})</h3>
|
||||
{editingSession.sets.map((set, idx) => (
|
||||
<div key={set.id} className="bg-surface-container p-3 rounded-xl border border-outline-variant/20 flex flex-col gap-3 shadow-sm">
|
||||
<div className="flex justify-between items-center border-b border-outline-variant pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-secondary-container text-on-secondary-container text-xs font-bold flex items-center justify-center">{idx + 1}</span>
|
||||
<span className="font-medium text-on-surface text-sm">{set.exerciseName}</span>
|
||||
</div>
|
||||
<button onClick={() => handleDeleteSet(set.id)} className="text-on-surface-variant hover:text-error">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Dumbbell size={10}/> {t('weight_kg', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.weight ?? ''}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'weight', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.PLYOMETRIC) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Activity size={10}/> {t('reps', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.reps ?? ''}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'reps', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Percent size={10}/> {t('body_weight_percent', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.bodyWeightPercentage ?? 100}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'bodyWeightPercentage', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Add other fields similarly styled if needed */}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default History;
|
||||
148
components/Login.tsx
Normal file
148
components/Login.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { login, changePassword } from '../services/auth';
|
||||
import { User, Language } from '../types';
|
||||
import { Dumbbell, ArrowRight, Lock, Mail, Globe } from 'lucide-react';
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: (user: User) => void;
|
||||
language: Language;
|
||||
onLanguageChange: (lang: Language) => void;
|
||||
}
|
||||
|
||||
const Login: React.FC<LoginProps> = ({ onLogin, language, onLanguageChange }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Force Password Change State
|
||||
const [needsChange, setNeedsChange] = useState(false);
|
||||
const [tempUser, setTempUser] = useState<User | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
|
||||
const handleLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const res = login(email, password);
|
||||
if (res.success && res.user) {
|
||||
if (res.user.isFirstLogin) {
|
||||
setTempUser(res.user);
|
||||
setNeedsChange(true);
|
||||
} else {
|
||||
onLogin(res.user);
|
||||
}
|
||||
} else {
|
||||
setError(res.error || t('login_error', language));
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = () => {
|
||||
if (tempUser && newPassword.length >= 4) {
|
||||
changePassword(tempUser.id, newPassword);
|
||||
const updatedUser = { ...tempUser, isFirstLogin: false };
|
||||
onLogin(updatedUser);
|
||||
} else {
|
||||
setError(t('login_password_short', language));
|
||||
}
|
||||
};
|
||||
|
||||
if (needsChange) {
|
||||
return (
|
||||
<div className="h-screen bg-surface flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm bg-surface-container p-8 rounded-[28px] shadow-elevation-2">
|
||||
<h2 className="text-2xl text-on-surface mb-2">{t('change_pass_title', language)}</h2>
|
||||
<p className="text-sm text-on-surface-variant mb-6">{t('change_pass_desc', language)}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant font-medium">{t('change_pass_new', language)}</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full bg-transparent text-lg text-on-surface focus:outline-none pt-1"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleChangePassword}
|
||||
className="w-full py-3 bg-primary text-on-primary rounded-full font-medium"
|
||||
>
|
||||
{t('change_pass_save', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-surface flex flex-col items-center justify-center p-6 relative">
|
||||
|
||||
{/* Language Toggle */}
|
||||
<div className="absolute top-6 right-6 flex items-center bg-surface-container rounded-full px-3 py-1 gap-2 border border-outline-variant/20">
|
||||
<Globe size={16} className="text-on-surface-variant" />
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => onLanguageChange(e.target.value as Language)}
|
||||
className="bg-transparent text-sm text-on-surface focus:outline-none appearance-none"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center mb-12">
|
||||
<div className="w-20 h-20 bg-primary-container rounded-2xl flex items-center justify-center text-on-primary-container mb-4 shadow-elevation-2 transform rotate-3">
|
||||
<Dumbbell size={40} />
|
||||
</div>
|
||||
<h1 className="text-3xl font-normal text-on-surface tracking-tight">{t('login_title', language)}</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="w-full max-w-sm space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="group bg-surface-container-high rounded-t-lg border-b border-outline-variant focus-within:border-primary transition-colors">
|
||||
<div className="flex items-center px-4 pt-4">
|
||||
<Mail size={16} className="text-on-surface-variant mr-2" />
|
||||
<label className="text-xs text-on-surface-variant font-medium">{t('login_email', language)}</label>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-transparent px-4 pb-3 pt-1 text-lg text-on-surface focus:outline-none"
|
||||
placeholder="user@gymflow.ai"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="group bg-surface-container-high rounded-t-lg border-b border-outline-variant focus-within:border-primary transition-colors">
|
||||
<div className="flex items-center px-4 pt-4">
|
||||
<Lock size={16} className="text-on-surface-variant mr-2" />
|
||||
<label className="text-xs text-on-surface-variant font-medium">{t('login_password', language)}</label>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-transparent px-4 pb-3 pt-1 text-lg text-on-surface focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-error text-sm text-center bg-error-container/10 p-2 rounded-lg">{error}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-4 bg-primary text-on-primary rounded-full font-medium text-lg shadow-elevation-1 flex items-center justify-center gap-2 hover:shadow-elevation-2 transition-all"
|
||||
>
|
||||
{t('login_btn', language)} <ArrowRight size={20} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-8 text-xs text-on-surface-variant text-center max-w-xs">
|
||||
{t('login_contact_admin', language)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
82
components/Navbar.tsx
Normal file
82
components/Navbar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Dumbbell, History as HistoryIcon, BarChart2, MessageSquare, ClipboardList, User } from 'lucide-react';
|
||||
import { TabView, Language } from '../types';
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
interface NavbarProps {
|
||||
currentTab: TabView;
|
||||
onTabChange: (tab: TabView) => void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ currentTab, onTabChange, lang }) => {
|
||||
const navItems = [
|
||||
{ id: 'TRACK' as TabView, icon: Dumbbell, label: t('tab_tracker', lang) },
|
||||
{ id: 'PLANS' as TabView, icon: ClipboardList, label: t('tab_plans', lang) },
|
||||
{ id: 'HISTORY' as TabView, icon: HistoryIcon, label: t('tab_history', lang) },
|
||||
{ id: 'STATS' as TabView, icon: BarChart2, label: t('tab_stats', lang) },
|
||||
{ id: 'AI_COACH' as TabView, icon: MessageSquare, label: t('tab_ai', lang) },
|
||||
{ id: 'PROFILE' as TabView, icon: User, label: t('tab_profile', lang) },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* MOBILE: Bottom Navigation Bar (MD3) */}
|
||||
<div className="md:hidden fixed bottom-0 left-0 w-full bg-surface-container shadow-elevation-2 border-t border-white/5 pb-safe z-50 h-20">
|
||||
<div className="flex justify-evenly items-center h-full px-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = currentTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
className="flex flex-col items-center justify-center w-full h-full gap-1 group min-w-0"
|
||||
>
|
||||
<div className={`px-4 py-1 rounded-full transition-all duration-200 ${
|
||||
isActive ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant group-hover:bg-surface-container-high'
|
||||
}`}>
|
||||
<item.icon size={22} strokeWidth={isActive ? 2.5 : 2} />
|
||||
</div>
|
||||
<span className={`text-[10px] font-medium transition-colors truncate w-full text-center ${
|
||||
isActive ? 'text-on-surface' : 'text-on-surface-variant'
|
||||
}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DESKTOP: Navigation Rail (MD3) */}
|
||||
<div className="hidden md:flex flex-col w-20 h-full bg-surface-container border-r border-outline-variant items-center py-8 gap-8 z-50">
|
||||
<div className="flex flex-col gap-6 w-full px-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = currentTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
className="flex flex-col items-center gap-1 group w-full"
|
||||
>
|
||||
<div className={`w-14 h-8 rounded-full flex items-center justify-center transition-colors duration-200 ${
|
||||
isActive ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant group-hover:bg-surface-container-high'
|
||||
}`}>
|
||||
<item.icon size={24} />
|
||||
</div>
|
||||
<span className={`text-[11px] font-medium text-center ${
|
||||
isActive ? 'text-on-surface' : 'text-on-surface-variant'
|
||||
}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
255
components/Plans.tsx
Normal file
255
components/Plans.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Scale } from 'lucide-react';
|
||||
import { WorkoutPlan, ExerciseDef, PlannedSet, Language } from '../types';
|
||||
import { getPlans, savePlan, deletePlan, getExercises } from '../services/storage';
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
interface PlansProps {
|
||||
userId: string;
|
||||
onStartPlan: (plan: WorkoutPlan) => void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [steps, setSteps] = useState<PlannedSet[]>([]);
|
||||
|
||||
const [availableExercises, setAvailableExercises] = useState<ExerciseDef[]>([]);
|
||||
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPlans(getPlans(userId));
|
||||
// Filter out archived exercises
|
||||
setAvailableExercises(getExercises(userId).filter(e => !e.isArchived));
|
||||
}, [userId]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setEditId(crypto.randomUUID());
|
||||
setName('');
|
||||
setDescription('');
|
||||
setSteps([]);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim() || !editId) return;
|
||||
const newPlan: WorkoutPlan = { id: editId, name, description, steps };
|
||||
savePlan(userId, newPlan);
|
||||
setPlans(getPlans(userId));
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (confirm(t('delete_confirm', lang))) {
|
||||
deletePlan(userId, id);
|
||||
setPlans(getPlans(userId));
|
||||
}
|
||||
};
|
||||
|
||||
const addStep = (ex: ExerciseDef) => {
|
||||
const newStep: PlannedSet = {
|
||||
id: crypto.randomUUID(),
|
||||
exerciseId: ex.id,
|
||||
exerciseName: ex.name,
|
||||
exerciseType: ex.type,
|
||||
isWeighted: false
|
||||
};
|
||||
setSteps([...steps, newStep]);
|
||||
setShowExerciseSelector(false);
|
||||
};
|
||||
|
||||
const toggleWeighted = (stepId: string) => {
|
||||
setSteps(steps.map(s => s.id === stepId ? { ...s, isWeighted: !s.isWeighted } : s));
|
||||
};
|
||||
|
||||
const removeStep = (stepId: string) => {
|
||||
setSteps(steps.filter(s => s.id !== stepId));
|
||||
};
|
||||
|
||||
const moveStep = (index: number, direction: 'up' | 'down') => {
|
||||
if (direction === 'up' && index === 0) return;
|
||||
if (direction === 'down' && index === steps.length - 1) return;
|
||||
const newSteps = [...steps];
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
[newSteps[index], newSteps[targetIndex]] = [newSteps[targetIndex], newSteps[index]];
|
||||
setSteps(newSteps);
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-surface">
|
||||
<div className="px-4 py-3 bg-surface-container border-b border-outline-variant flex justify-between items-center">
|
||||
<button onClick={() => setIsEditing(false)} className="p-2 text-on-surface-variant hover:bg-white/5 rounded-full"><X /></button>
|
||||
<h2 className="text-title-medium font-medium text-on-surface">{t('plan_editor', lang)}</h2>
|
||||
<button onClick={handleSave} className="p-2 text-primary font-medium">
|
||||
{t('save', lang)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant font-medium">{t('ex_name', lang)}</label>
|
||||
<input
|
||||
className="w-full bg-transparent text-xl text-on-surface focus:outline-none pt-1 pb-2"
|
||||
placeholder={t('plan_name_ph', lang)}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant font-medium">{t('prep_title', lang)}</label>
|
||||
<textarea
|
||||
className="w-full bg-transparent text-base text-on-surface focus:outline-none pt-1 pb-2 min-h-[80px]"
|
||||
placeholder={t('plan_desc_ph', lang)}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center px-2">
|
||||
<label className="text-sm text-primary font-medium">{t('exercises_list', lang)}</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => (
|
||||
<div key={step.id} className="bg-surface-container rounded-xl p-3 flex items-center gap-3 shadow-elevation-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
{idx > 0 && (
|
||||
<button onClick={() => moveStep(idx, 'up')} className="p-1 text-on-surface-variant hover:bg-white/5 rounded">
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
)}
|
||||
{idx < steps.length - 1 && (
|
||||
<button onClick={() => moveStep(idx, 'down')} className="p-1 text-on-surface-variant hover:bg-white/5 rounded">
|
||||
<ArrowDown size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-8 h-8 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xs font-bold shrink-0">
|
||||
{idx + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium text-on-surface">{step.exerciseName}</div>
|
||||
<label className="flex items-center gap-2 mt-1 cursor-pointer w-fit">
|
||||
<div className={`w-4 h-4 border rounded flex items-center justify-center ${step.isWeighted ? 'bg-primary border-primary' : 'border-outline'}`}>
|
||||
{step.isWeighted && <Scale size={10} className="text-on-primary" />}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={step.isWeighted}
|
||||
onChange={() => toggleWeighted(step.id)}
|
||||
className="hidden"
|
||||
/>
|
||||
<span className="text-xs text-on-surface-variant">{t('weighted', lang)}</span>
|
||||
</label>
|
||||
</div>
|
||||
<button onClick={() => removeStep(step.id)} className="text-on-surface-variant hover:text-error p-2">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowExerciseSelector(true)}
|
||||
className="w-full py-4 rounded-full border border-outline text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary-container/10 transition-all"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{t('add_exercise', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showExerciseSelector && (
|
||||
<div className="absolute inset-0 bg-surface z-50 flex flex-col animate-in slide-in-from-bottom-full duration-200">
|
||||
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container">
|
||||
<span className="font-medium text-on-surface">{t('select_exercise', lang)}</span>
|
||||
<button onClick={() => setShowExerciseSelector(false)}><X /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{availableExercises.map(ex => (
|
||||
<button
|
||||
key={ex.id}
|
||||
onClick={() => addStep(ex)}
|
||||
className="w-full text-left p-4 border-b border-outline-variant hover:bg-surface-container-high text-on-surface flex justify-between"
|
||||
>
|
||||
<span>{ex.name}</span>
|
||||
<span className="text-xs bg-secondary-container text-on-secondary-container px-2 py-1 rounded-full">{ex.type}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-surface relative">
|
||||
<div className="p-4 bg-surface-container shadow-elevation-1 z-10">
|
||||
<h2 className="text-2xl font-normal text-on-surface">{t('my_plans', lang)}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4 overflow-y-auto space-y-4 pb-24">
|
||||
{plans.length === 0 ? (
|
||||
<div className="text-center text-on-surface-variant mt-20 flex flex-col items-center">
|
||||
<div className="w-16 h-16 bg-surface-container-high rounded-full flex items-center justify-center mb-4">
|
||||
<List size={32} />
|
||||
</div>
|
||||
<p className="text-lg">{t('plans_empty', lang)}</p>
|
||||
</div>
|
||||
) : (
|
||||
plans.map(plan => (
|
||||
<div key={plan.id} className="bg-surface-container rounded-xl p-4 shadow-elevation-1 border border-outline-variant/20 relative overflow-hidden">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="text-xl font-normal text-on-surface">{plan.name}</h3>
|
||||
<button
|
||||
onClick={(e) => handleDelete(plan.id, e)}
|
||||
className="text-on-surface-variant hover:text-error p-2 rounded-full hover:bg-white/5"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-on-surface-variant text-sm line-clamp-2 mb-4 min-h-[1.25rem]">
|
||||
{plan.description || t('prep_no_instructions', lang)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-primary bg-primary-container/20 px-3 py-1 rounded-full">
|
||||
{plan.steps.length} {t('exercises_count', lang)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onStartPlan(plan)}
|
||||
className="flex items-center gap-2 bg-primary text-on-primary px-5 py-2 rounded-full text-sm font-medium hover:shadow-elevation-2 transition-all"
|
||||
>
|
||||
<PlayCircle size={18} />
|
||||
{t('start', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* FAB */}
|
||||
<button
|
||||
onClick={handleCreateNew}
|
||||
className="absolute bottom-6 right-6 w-14 h-14 bg-primary-container text-on-primary-container rounded-[16px] shadow-elevation-3 flex items-center justify-center hover:bg-primary hover:text-on-primary transition-colors z-20"
|
||||
>
|
||||
<Plus size={28} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Plans;
|
||||
532
components/Profile.tsx
Normal file
532
components/Profile.tsx
Normal file
@@ -0,0 +1,532 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { User, Language, ExerciseDef, ExerciseType } from '../types';
|
||||
import { createUser, changePassword, updateUserProfile, getCurrentUserProfile, getUsers, deleteUser, toggleBlockUser, adminResetPassword } from '../services/auth';
|
||||
import { getExercises, saveExercise } from '../services/storage';
|
||||
import { User as UserIcon, LogOut, Save, Shield, UserPlus, Lock, Calendar, Ruler, Scale, PersonStanding, Globe, ChevronDown, ChevronUp, Trash2, Ban, KeyRound, Dumbbell, Archive, ArchiveRestore, Pencil, X, Plus, Percent } from 'lucide-react';
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
interface ProfileProps {
|
||||
user: User;
|
||||
onLogout: () => void;
|
||||
lang: Language;
|
||||
onLanguageChange: (lang: Language) => void;
|
||||
}
|
||||
|
||||
const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChange }) => {
|
||||
// Profile Data
|
||||
const [weight, setWeight] = useState<string>('');
|
||||
const [height, setHeight] = useState<string>('');
|
||||
const [birthDate, setBirthDate] = useState<string>('');
|
||||
const [gender, setGender] = useState<string>('MALE');
|
||||
|
||||
// Admin: Create User
|
||||
const [newUserEmail, setNewUserEmail] = useState('');
|
||||
const [newUserPass, setNewUserPass] = useState('');
|
||||
const [createMsg, setCreateMsg] = useState('');
|
||||
|
||||
// Admin: User List
|
||||
const [showUserList, setShowUserList] = useState(false);
|
||||
const [allUsers, setAllUsers] = useState<any[]>([]);
|
||||
const [adminPassResetInput, setAdminPassResetInput] = useState<{[key:string]: string}>({});
|
||||
|
||||
// Change Password
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [passMsg, setPassMsg] = useState('');
|
||||
|
||||
// Account Deletion
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
// Exercise Management
|
||||
const [showExercises, setShowExercises] = useState(false);
|
||||
const [exercises, setExercises] = useState<ExerciseDef[]>([]);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [editingExercise, setEditingExercise] = useState<ExerciseDef | null>(null);
|
||||
const [isCreatingEx, setIsCreatingEx] = useState(false);
|
||||
// New exercise form
|
||||
const [newExName, setNewExName] = useState('');
|
||||
const [newExType, setNewExType] = useState<ExerciseType>(ExerciseType.STRENGTH);
|
||||
const [newExBw, setNewExBw] = useState('100');
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const p = getCurrentUserProfile(user.id);
|
||||
if (p) {
|
||||
if (p.weight) setWeight(p.weight.toString());
|
||||
if (p.height) setHeight(p.height.toString());
|
||||
if (p.gender) setGender(p.gender);
|
||||
if (p.birthDate) setBirthDate(new Date(p.birthDate).toISOString().split('T')[0]);
|
||||
}
|
||||
|
||||
if (user.role === 'ADMIN') {
|
||||
refreshUserList();
|
||||
}
|
||||
refreshExercises();
|
||||
}, [user.id, user.role]);
|
||||
|
||||
const refreshUserList = () => {
|
||||
setAllUsers(getUsers());
|
||||
};
|
||||
|
||||
const refreshExercises = () => {
|
||||
setExercises(getExercises(user.id));
|
||||
};
|
||||
|
||||
const handleSaveProfile = () => {
|
||||
updateUserProfile(user.id, {
|
||||
weight: parseFloat(weight) || undefined,
|
||||
height: parseFloat(height) || undefined,
|
||||
gender: gender as any,
|
||||
birthDate: birthDate ? new Date(birthDate).getTime() : undefined,
|
||||
language: lang
|
||||
});
|
||||
alert('Saved');
|
||||
};
|
||||
|
||||
const handleChangePassword = () => {
|
||||
if (newPassword.length < 4) {
|
||||
setPassMsg('Password too short');
|
||||
return;
|
||||
}
|
||||
changePassword(user.id, newPassword);
|
||||
setPassMsg('Password changed');
|
||||
setNewPassword('');
|
||||
};
|
||||
|
||||
const handleCreateUser = () => {
|
||||
const res = createUser(newUserEmail, newUserPass);
|
||||
if (res.success) {
|
||||
setCreateMsg(`${t('user_created', lang)}: ${newUserEmail}`);
|
||||
setNewUserEmail('');
|
||||
setNewUserPass('');
|
||||
refreshUserList();
|
||||
} else {
|
||||
setCreateMsg(res.error || 'Error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdminDeleteUser = (uid: string) => {
|
||||
if (confirm(t('delete_confirm', lang))) {
|
||||
deleteUser(uid);
|
||||
refreshUserList();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdminBlockUser = (uid: string, isBlocked: boolean) => {
|
||||
toggleBlockUser(uid, isBlocked);
|
||||
refreshUserList();
|
||||
};
|
||||
|
||||
const handleAdminResetPass = (uid: string) => {
|
||||
const pass = adminPassResetInput[uid];
|
||||
if (pass && pass.length >= 4) {
|
||||
adminResetPassword(uid, pass);
|
||||
alert(t('pass_reset', lang));
|
||||
setAdminPassResetInput({...adminPassResetInput, [uid]: ''});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMyAccount = () => {
|
||||
deleteUser(user.id);
|
||||
onLogout();
|
||||
};
|
||||
|
||||
// Exercise Management Handlers
|
||||
const handleArchiveExercise = (ex: ExerciseDef, archive: boolean) => {
|
||||
const updated = { ...ex, isArchived: archive };
|
||||
saveExercise(user.id, updated);
|
||||
refreshExercises();
|
||||
};
|
||||
|
||||
const handleSaveExerciseEdit = () => {
|
||||
if (editingExercise && editingExercise.name) {
|
||||
saveExercise(user.id, editingExercise);
|
||||
setEditingExercise(null);
|
||||
refreshExercises();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateExercise = () => {
|
||||
if (newExName.trim()) {
|
||||
const newEx: ExerciseDef = {
|
||||
id: crypto.randomUUID(),
|
||||
name: newExName.trim(),
|
||||
type: newExType,
|
||||
bodyWeightPercentage: newExType === ExerciseType.BODYWEIGHT ? parseFloat(newExBw) : undefined
|
||||
};
|
||||
saveExercise(user.id, newEx);
|
||||
setNewExName('');
|
||||
setIsCreatingEx(false);
|
||||
refreshExercises();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-surface">
|
||||
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center justify-between z-10">
|
||||
<h2 className="text-xl font-normal text-on-surface flex items-center gap-2">
|
||||
<UserIcon size={20} />
|
||||
{t('profile_title', lang)}
|
||||
</h2>
|
||||
<button onClick={onLogout} className="text-error flex items-center gap-1 text-sm font-medium hover:bg-error-container/10 px-3 py-1 rounded-full">
|
||||
<LogOut size={16} /> {t('logout', lang)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6 pb-24">
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-14 h-14 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xl font-bold">
|
||||
{user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-medium text-on-surface">{user.email}</div>
|
||||
<div className="text-xs text-on-surface-variant bg-surface-container-high px-2 py-1 rounded w-fit mt-1 flex items-center gap-1">
|
||||
{user.role === 'ADMIN' && <Shield size={10} />}
|
||||
{user.role}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-bold text-primary mb-4">{t('personal_data', lang)}</h3>
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Scale size={10}/> {t('weight_kg', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
className="w-full bg-transparent text-on-surface focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Ruler size={10}/> {t('height', lang)}</label>
|
||||
<input type="number" value={height} onChange={(e) => setHeight(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none" />
|
||||
</div>
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Calendar size={10}/> {t('birth_date', lang)}</label>
|
||||
<input type="date" value={birthDate} onChange={(e) => setBirthDate(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1" />
|
||||
</div>
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><PersonStanding size={10}/> {t('gender', lang)}</label>
|
||||
<select value={gender} onChange={(e) => setGender(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1 bg-surface-container-high">
|
||||
<option value="MALE">{t('male', lang)}</option>
|
||||
<option value="FEMALE">{t('female', lang)}</option>
|
||||
<option value="OTHER">{t('other', lang)}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2 mb-4">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Globe size={10}/> {t('language', lang)}</label>
|
||||
<select value={lang} onChange={(e) => onLanguageChange(e.target.value as Language)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1 bg-surface-container-high">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button onClick={handleSaveProfile} className="w-full py-2 rounded-full border border-outline text-primary text-sm font-medium hover:bg-primary-container/10 flex justify-center gap-2 items-center">
|
||||
<Save size={16} /> {t('save_profile', lang)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* EXERCISE MANAGER */}
|
||||
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
|
||||
<button
|
||||
onClick={() => setShowExercises(!showExercises)}
|
||||
className="w-full flex justify-between items-center text-sm font-bold text-primary"
|
||||
>
|
||||
<span className="flex items-center gap-2"><Dumbbell size={14} /> {t('manage_exercises', lang)}</span>
|
||||
{showExercises ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
|
||||
{showExercises && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<button
|
||||
onClick={() => setIsCreatingEx(true)}
|
||||
className="w-full py-2 border border-outline border-dashed rounded-lg text-sm text-on-surface-variant hover:bg-surface-container-high flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={16}/> {t('create_exercise', lang)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<label className="text-xs text-on-surface-variant">{t('show_archived', lang)}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={(e) => setShowArchived(e.target.checked)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{exercises
|
||||
.filter(e => showArchived || !e.isArchived)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(ex => (
|
||||
<div key={ex.id} className={`p-3 rounded-lg flex justify-between items-center border border-outline-variant/20 ${ex.isArchived ? 'bg-surface-container-low opacity-60' : 'bg-surface-container-high'}`}>
|
||||
<div className="overflow-hidden mr-2">
|
||||
<div className="font-medium text-sm text-on-surface truncate">{ex.name}</div>
|
||||
<div className="text-xs text-on-surface-variant">{ex.type}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => setEditingExercise(ex)} className="p-2 text-on-surface-variant hover:text-primary hover:bg-white/5 rounded-full">
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleArchiveExercise(ex, !ex.isArchived)}
|
||||
className={`p-2 rounded-full hover:bg-white/5 ${ex.isArchived ? 'text-primary' : 'text-on-surface-variant'}`}
|
||||
title={ex.isArchived ? t('unarchive', lang) : t('archive', lang)}
|
||||
>
|
||||
{ex.isArchived ? <ArchiveRestore size={16} /> : <Archive size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
|
||||
<h3 className="text-sm font-bold text-primary mb-4 flex items-center gap-2"><Lock size={14} /> {t('change_pass_btn', lang)}</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t('change_pass_new', lang)}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="flex-1 bg-surface-container-high border-b border-outline-variant px-3 py-2 text-on-surface focus:outline-none rounded-t-lg"
|
||||
/>
|
||||
<button onClick={handleChangePassword} className="bg-secondary-container text-on-secondary-container px-4 rounded-lg font-medium text-sm">OK</button>
|
||||
</div>
|
||||
{passMsg && <p className="text-xs text-primary mt-2">{passMsg}</p>}
|
||||
</div>
|
||||
|
||||
{/* User Self Deletion (Not for Admin) */}
|
||||
{user.role !== 'ADMIN' && (
|
||||
<div className="bg-surface-container rounded-xl p-4 border border-error/30">
|
||||
<h3 className="text-sm font-bold text-error mb-2 flex items-center gap-2"><Trash2 size={14} /> {t('delete_account', lang)}</h3>
|
||||
{!showDeleteConfirm ? (
|
||||
<button onClick={() => setShowDeleteConfirm(true)} className="text-error text-sm hover:underline">
|
||||
{t('delete', lang)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-error">{t('delete_account_confirm', lang)}</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setShowDeleteConfirm(false)} className="text-xs px-3 py-1 bg-surface-container-high rounded-full">{t('cancel', lang)}</button>
|
||||
<button onClick={handleDeleteMyAccount} className="text-xs px-3 py-1 bg-error text-on-error rounded-full">{t('delete', lang)}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ADMIN AREA */}
|
||||
{user.role === 'ADMIN' && (
|
||||
<div className="bg-surface-container rounded-xl p-4 border border-primary/30 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-2 bg-primary/10 rounded-bl-xl">
|
||||
<Shield size={16} className="text-primary" />
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-primary mb-4 flex items-center gap-2"><UserPlus size={14} /> {t('admin_area', lang)}</h3>
|
||||
|
||||
{/* Create User */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<h4 className="text-xs font-medium text-on-surface-variant">{t('create_user', lang)}</h4>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={newUserEmail}
|
||||
onChange={(e) => setNewUserEmail(e.target.value)}
|
||||
className="w-full bg-surface-container-high border-b border-outline-variant px-3 py-2 text-on-surface focus:outline-none rounded-t-lg"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login_password', lang)}
|
||||
value={newUserPass}
|
||||
onChange={(e) => setNewUserPass(e.target.value)}
|
||||
className="w-full bg-surface-container-high border-b border-outline-variant px-3 py-2 text-on-surface focus:outline-none rounded-t-lg"
|
||||
/>
|
||||
<button onClick={handleCreateUser} className="w-full py-2 bg-primary text-on-primary rounded-full text-sm font-medium">
|
||||
{t('create_btn', lang)}
|
||||
</button>
|
||||
{createMsg && <p className="text-xs text-on-surface-variant text-center">{createMsg}</p>}
|
||||
</div>
|
||||
|
||||
{/* User List */}
|
||||
<div className="border-t border-outline-variant pt-4">
|
||||
<button
|
||||
onClick={() => setShowUserList(!showUserList)}
|
||||
className="w-full flex justify-between items-center text-sm font-medium text-on-surface"
|
||||
>
|
||||
<span>{t('admin_users_list', lang)} ({allUsers.length})</span>
|
||||
{showUserList ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
|
||||
{showUserList && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{allUsers.map(u => (
|
||||
<div key={u.id} className="bg-surface-container-high p-3 rounded-lg space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="overflow-hidden">
|
||||
<div className="font-medium text-sm text-on-surface truncate">{u.email}</div>
|
||||
<div className="text-xs text-on-surface-variant flex gap-2">
|
||||
<span>{u.role}</span>
|
||||
{u.isBlocked && <span className="text-error font-bold flex items-center gap-1"><Ban size={10}/> {t('block', lang)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{u.role !== 'ADMIN' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleAdminBlockUser(u.id, !u.isBlocked)}
|
||||
className={`p-2 rounded-full ${u.isBlocked ? 'bg-primary/20 text-primary' : 'text-on-surface-variant hover:bg-white/10'}`}
|
||||
title={u.isBlocked ? t('unblock', lang) : t('block', lang)}
|
||||
>
|
||||
<Ban size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAdminDeleteUser(u.id)}
|
||||
className="p-2 text-on-surface-variant hover:text-error hover:bg-error/10 rounded-full"
|
||||
title={t('delete', lang)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{u.role !== 'ADMIN' && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-1 flex items-center bg-surface-container rounded px-2 border border-outline-variant/20">
|
||||
<KeyRound size={12} className="text-on-surface-variant mr-2"/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('change_pass_new', lang)}
|
||||
className="bg-transparent text-xs py-2 w-full focus:outline-none text-on-surface"
|
||||
value={adminPassResetInput[u.id] || ''}
|
||||
onChange={(e) => setAdminPassResetInput({...adminPassResetInput, [u.id]: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAdminResetPass(u.id)}
|
||||
className="text-xs bg-secondary-container text-on-secondary-container px-3 py-2 rounded font-medium"
|
||||
>
|
||||
{t('reset_pass', lang)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Exercise Modal */}
|
||||
{editingExercise && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-surface-container w-full max-w-sm rounded-[28px] p-6 shadow-elevation-3">
|
||||
<h3 className="text-xl font-normal text-on-surface mb-4">{t('edit', lang)}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant font-medium">{t('ex_name', lang)}</label>
|
||||
<input
|
||||
value={editingExercise.name}
|
||||
onChange={(e) => setEditingExercise({...editingExercise, name: e.target.value})}
|
||||
className="w-full bg-transparent text-on-surface focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{editingExercise.type === ExerciseType.BODYWEIGHT && (
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant font-medium">{t('body_weight_percent', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingExercise.bodyWeightPercentage || 100}
|
||||
onChange={(e) => setEditingExercise({...editingExercise, bodyWeightPercentage: parseFloat(e.target.value)})}
|
||||
className="w-full bg-transparent text-on-surface focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={() => setEditingExercise(null)} className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
|
||||
<button onClick={handleSaveExerciseEdit} className="px-4 py-2 rounded-full bg-primary text-on-primary font-medium">{t('save', lang)}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Exercise Modal */}
|
||||
{isCreatingEx && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-surface-container w-full max-w-sm rounded-[28px] p-6 shadow-elevation-3">
|
||||
<h3 className="text-xl font-normal text-on-surface mb-4">{t('create_exercise', lang)}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant font-medium">{t('ex_name', lang)}</label>
|
||||
<input
|
||||
value={newExName}
|
||||
onChange={(e) => setNewExName(e.target.value)}
|
||||
className="w-full bg-transparent text-on-surface focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] text-on-surface-variant font-medium mb-2 block">{t('ex_type', lang)}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setNewExType(ExerciseType.STRENGTH)}
|
||||
className={`px-3 py-1 rounded-full text-xs border ${newExType === ExerciseType.STRENGTH ? 'bg-primary text-on-primary border-transparent' : 'border-outline text-on-surface-variant'}`}
|
||||
>
|
||||
{t('type_strength', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setNewExType(ExerciseType.BODYWEIGHT)}
|
||||
className={`px-3 py-1 rounded-full text-xs border ${newExType === ExerciseType.BODYWEIGHT ? 'bg-primary text-on-primary border-transparent' : 'border-outline text-on-surface-variant'}`}
|
||||
>
|
||||
{t('type_bodyweight', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setNewExType(ExerciseType.CARDIO)}
|
||||
className={`px-3 py-1 rounded-full text-xs border ${newExType === ExerciseType.CARDIO ? 'bg-primary text-on-primary border-transparent' : 'border-outline text-on-surface-variant'}`}
|
||||
>
|
||||
{t('type_cardio', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newExType === ExerciseType.BODYWEIGHT && (
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant font-medium">{t('body_weight_percent', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newExBw}
|
||||
onChange={(e) => setNewExBw(e.target.value)}
|
||||
className="w-full bg-transparent text-on-surface focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={() => setIsCreatingEx(false)} className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
|
||||
<button onClick={handleCreateExercise} className="px-4 py-2 rounded-full bg-primary text-on-primary font-medium">{t('create_btn', lang)}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
136
components/Stats.tsx
Normal file
136
components/Stats.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { WorkoutSession, ExerciseType, Language } from '../types';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
interface StatsProps {
|
||||
sessions: WorkoutSession[];
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const Stats: React.FC<StatsProps> = ({ sessions, lang }) => {
|
||||
|
||||
const volumeData = useMemo(() => {
|
||||
const data = [...sessions].reverse().map(session => {
|
||||
const sessionWeight = session.userBodyWeight || 70;
|
||||
const work = session.sets.reduce((acc, set) => {
|
||||
let setWork = 0;
|
||||
const reps = set.reps || 0;
|
||||
const weight = set.weight || 0;
|
||||
if (set.type === ExerciseType.STRENGTH) {
|
||||
setWork = weight * reps;
|
||||
} else if (set.type === ExerciseType.BODYWEIGHT) {
|
||||
const percentage = set.bodyWeightPercentage || 100;
|
||||
const effectiveBw = sessionWeight * (percentage / 100);
|
||||
setWork = (effectiveBw + weight) * reps;
|
||||
} else if (set.type === ExerciseType.STATIC) {
|
||||
setWork = 0;
|
||||
}
|
||||
return acc + Math.max(0, setWork);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
|
||||
work: Math.round(work)
|
||||
};
|
||||
}).filter(d => d.work > 0);
|
||||
return data;
|
||||
}, [sessions, lang]);
|
||||
|
||||
const setsData = useMemo(() => {
|
||||
return [...sessions].reverse().map(session => ({
|
||||
date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
|
||||
sets: session.sets.length
|
||||
}));
|
||||
}, [sessions, lang]);
|
||||
|
||||
const weightData = useMemo(() => {
|
||||
return [...sessions].reverse()
|
||||
.filter(s => s.userBodyWeight)
|
||||
.map(session => ({
|
||||
date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
|
||||
weight: session.userBodyWeight
|
||||
}));
|
||||
}, [sessions, lang]);
|
||||
|
||||
if (sessions.length < 2) {
|
||||
return (
|
||||
<div className="p-8 text-center text-on-surface-variant flex flex-col items-center justify-center h-full">
|
||||
<p>{t('not_enough_data', lang)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 space-y-6 pb-24 bg-surface">
|
||||
<h2 className="text-3xl font-normal text-on-surface mb-2 pl-2">{t('progress', lang)}</h2>
|
||||
|
||||
{/* Volume Chart */}
|
||||
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
|
||||
<div className="flex justify-between items-end mb-6">
|
||||
<div>
|
||||
<h3 className="text-title-medium font-medium text-on-surface">{t('volume_title', lang)}</h3>
|
||||
<p className="text-xs text-on-surface-variant mt-1">{t('volume_subtitle', lang)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={volumeData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
|
||||
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
|
||||
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(val) => `${(val/1000).toFixed(1)}k`} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
|
||||
itemStyle={{ color: '#D0BCFF' }}
|
||||
formatter={(val: number) => [`${val.toLocaleString()} kg`, t('volume_title', lang)]}
|
||||
/>
|
||||
<Line type="monotone" dataKey="work" stroke="#D0BCFF" strokeWidth={3} dot={{r: 4, fill: '#D0BCFF'}} activeDot={{r: 6}} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sets Chart */}
|
||||
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
|
||||
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sets_title', lang)}</h3>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={setsData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
|
||||
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
|
||||
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
|
||||
cursor={{fill: 'rgba(255,255,255,0.05)'}}
|
||||
/>
|
||||
<Bar dataKey="sets" fill="#CCC2DC" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body Weight Chart */}
|
||||
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
|
||||
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('weight_title', lang)}</h3>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={weightData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
|
||||
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
|
||||
<YAxis domain={['auto', 'auto']} stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
|
||||
itemStyle={{ color: '#6EE7B7' }}
|
||||
formatter={(val: number) => [`${val} kg`, t('weight_kg', lang)]}
|
||||
/>
|
||||
<Line type="monotone" dataKey="weight" stroke="#6EE7B7" strokeWidth={3} dot={{r: 4, fill: '#6EE7B7'}} activeDot={{r: 6}} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Stats;
|
||||
581
components/Tracker.tsx
Normal file
581
components/Tracker.tsx
Normal file
@@ -0,0 +1,581 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Activity, ChevronDown, ChevronUp, Dumbbell, PlayCircle, CheckCircle, User, Scale, X, Flame, Timer as TimerIcon, ArrowUp, ArrowRight, Footprints, Ruler, CheckSquare, Trash2, Percent } from 'lucide-react';
|
||||
import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan, Language } from '../types';
|
||||
import { getExercises, getLastSetForExercise, saveExercise, getPlans } from '../services/storage';
|
||||
import { getCurrentUserProfile } from '../services/auth';
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
interface TrackerProps {
|
||||
userId: string;
|
||||
activeSession: WorkoutSession | null;
|
||||
activePlan: WorkoutPlan | null;
|
||||
onSessionStart: (plan?: WorkoutPlan) => void;
|
||||
onSessionEnd: () => void;
|
||||
onSetAdded: (set: WorkoutSet) => void;
|
||||
onRemoveSet: (setId: string) => void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const Tracker: React.FC<TrackerProps> = ({ userId, activeSession, activePlan, onSessionStart, onSessionEnd, onSetAdded, onRemoveSet, lang }) => {
|
||||
const [exercises, setExercises] = useState<ExerciseDef[]>([]);
|
||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
||||
const [selectedExercise, setSelectedExercise] = useState<ExerciseDef | null>(null);
|
||||
|
||||
// Timer State
|
||||
const [elapsedTime, setElapsedTime] = useState<string>('00:00:00');
|
||||
|
||||
// Form State
|
||||
const [weight, setWeight] = useState<string>('');
|
||||
const [reps, setReps] = useState<string>('');
|
||||
const [duration, setDuration] = useState<string>('');
|
||||
const [distance, setDistance] = useState<string>('');
|
||||
const [height, setHeight] = useState<string>('');
|
||||
const [bwPercentage, setBwPercentage] = useState<string>('100');
|
||||
|
||||
// User Weight State
|
||||
const [userBodyWeight, setUserBodyWeight] = useState<string>('70');
|
||||
|
||||
// Create Exercise State
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newType, setNewType] = useState<ExerciseType>(ExerciseType.STRENGTH);
|
||||
const [newBwPercentage, setNewBwPercentage] = useState<string>('100');
|
||||
|
||||
// Plan Execution State
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [showPlanPrep, setShowPlanPrep] = useState<WorkoutPlan | null>(null);
|
||||
const [showPlanList, setShowPlanList] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Filter out archived exercises for the selector
|
||||
setExercises(getExercises(userId).filter(e => !e.isArchived));
|
||||
setPlans(getPlans(userId));
|
||||
if (activeSession?.userBodyWeight) {
|
||||
setUserBodyWeight(activeSession.userBodyWeight.toString());
|
||||
} else {
|
||||
const profile = getCurrentUserProfile(userId);
|
||||
setUserBodyWeight(profile?.weight ? profile.weight.toString() : '70');
|
||||
}
|
||||
}, [activeSession, userId]);
|
||||
|
||||
// Timer Logic
|
||||
useEffect(() => {
|
||||
let interval: number;
|
||||
if (activeSession) {
|
||||
const updateTimer = () => {
|
||||
const diff = Math.floor((Date.now() - activeSession.startTime) / 1000);
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
const s = diff % 60;
|
||||
setElapsedTime(`${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`);
|
||||
};
|
||||
|
||||
updateTimer();
|
||||
interval = window.setInterval(updateTimer, 1000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSession && activePlan && exercises.length > 0 && activePlan.steps.length > 0) {
|
||||
if (currentStepIndex < activePlan.steps.length) {
|
||||
const step = activePlan.steps[currentStepIndex];
|
||||
if (step) {
|
||||
const exDef = exercises.find(e => e.id === step.exerciseId);
|
||||
if (exDef) {
|
||||
if (!selectedExercise || selectedExercise.id !== exDef.id) {
|
||||
setSelectedExercise(exDef);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [activeSession, activePlan, currentStepIndex, exercises]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedExercise) {
|
||||
setBwPercentage(selectedExercise.bodyWeightPercentage ? selectedExercise.bodyWeightPercentage.toString() : '100');
|
||||
const lastSet = getLastSetForExercise(userId, selectedExercise.id);
|
||||
if (lastSet) {
|
||||
if (lastSet.weight !== undefined) setWeight(lastSet.weight.toString());
|
||||
if (lastSet.reps !== undefined) setReps(lastSet.reps.toString());
|
||||
if (lastSet.durationSeconds !== undefined) setDuration(lastSet.durationSeconds.toString());
|
||||
if (lastSet.distanceMeters !== undefined) setDistance(lastSet.distanceMeters.toString());
|
||||
if (lastSet.height !== undefined) setHeight(lastSet.height.toString());
|
||||
} else {
|
||||
setWeight(''); setReps(''); setDuration(''); setDistance(''); setHeight('');
|
||||
}
|
||||
}
|
||||
}, [selectedExercise, userId]);
|
||||
|
||||
const handleStart = (plan?: WorkoutPlan) => {
|
||||
if (plan && plan.description) {
|
||||
setShowPlanPrep(plan);
|
||||
} else {
|
||||
onSessionStart(plan);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPlanStart = () => {
|
||||
if (showPlanPrep) {
|
||||
onSessionStart(showPlanPrep);
|
||||
setShowPlanPrep(null);
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddSet = () => {
|
||||
if (!activeSession || !selectedExercise) return;
|
||||
|
||||
const newSet: WorkoutSet = {
|
||||
id: crypto.randomUUID(),
|
||||
exerciseId: selectedExercise.id,
|
||||
exerciseName: selectedExercise.name,
|
||||
type: selectedExercise.type,
|
||||
timestamp: Date.now(),
|
||||
...(weight && { weight: parseFloat(weight) }),
|
||||
...(reps && { reps: parseInt(reps) }),
|
||||
...(duration && { durationSeconds: parseInt(duration) }),
|
||||
...(distance && { distanceMeters: parseFloat(distance) }),
|
||||
...(height && { height: parseFloat(height) }),
|
||||
...((selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && { bodyWeightPercentage: parseFloat(bwPercentage) || 100 })
|
||||
};
|
||||
|
||||
onSetAdded(newSet);
|
||||
|
||||
if (activePlan) {
|
||||
const currentStep = activePlan.steps[currentStepIndex];
|
||||
if (currentStep && currentStep.exerciseId === selectedExercise.id) {
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
setCurrentStepIndex(nextIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateExercise = () => {
|
||||
if (!newName.trim()) return;
|
||||
const newEx: ExerciseDef = {
|
||||
id: crypto.randomUUID(),
|
||||
name: newName.trim(),
|
||||
type: newType,
|
||||
...(newType === ExerciseType.BODYWEIGHT && { bodyWeightPercentage: parseFloat(newBwPercentage) || 100 })
|
||||
};
|
||||
saveExercise(userId, newEx);
|
||||
const updatedList = getExercises(userId).filter(e => !e.isArchived);
|
||||
setExercises(updatedList);
|
||||
setSelectedExercise(newEx);
|
||||
setNewName('');
|
||||
setNewType(ExerciseType.STRENGTH);
|
||||
setNewBwPercentage('100');
|
||||
setIsCreating(false);
|
||||
};
|
||||
|
||||
const jumpToStep = (index: number) => {
|
||||
if (!activePlan) return;
|
||||
setCurrentStepIndex(index);
|
||||
setShowPlanList(false);
|
||||
};
|
||||
|
||||
const isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length;
|
||||
|
||||
const FilledInput = ({ label, value, onChange, type = "number", icon, autoFocus, step }: any) => (
|
||||
<div className="relative group bg-surface-container-high rounded-t-lg border-b border-outline-variant hover:bg-white/5 focus-within:border-primary transition-colors">
|
||||
<label className="absolute top-2 left-4 text-[10px] font-medium text-on-surface-variant flex items-center gap-1">
|
||||
{icon} {label}
|
||||
</label>
|
||||
<input
|
||||
type={type}
|
||||
step={step}
|
||||
inputMode="decimal"
|
||||
autoFocus={autoFocus}
|
||||
className="w-full pt-6 pb-2 px-4 bg-transparent text-2xl text-on-surface focus:outline-none placeholder-transparent"
|
||||
placeholder="0"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const exerciseTypeLabels: Record<ExerciseType, string> = {
|
||||
[ExerciseType.STRENGTH]: t('type_strength', lang),
|
||||
[ExerciseType.BODYWEIGHT]: t('type_bodyweight', lang),
|
||||
[ExerciseType.CARDIO]: t('type_cardio', lang),
|
||||
[ExerciseType.STATIC]: t('type_static', lang),
|
||||
[ExerciseType.HIGH_JUMP]: t('type_height', lang),
|
||||
[ExerciseType.LONG_JUMP]: t('type_dist', lang),
|
||||
[ExerciseType.PLYOMETRIC]: t('type_jump', lang),
|
||||
};
|
||||
|
||||
if (!activeSession) {
|
||||
return (
|
||||
<div className="flex flex-col h-full p-4 md:p-8 overflow-y-auto relative">
|
||||
<div className="flex-1 flex flex-col items-center justify-center space-y-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-24 h-24 rounded-full bg-surface-container-high flex items-center justify-center text-primary shadow-elevation-1">
|
||||
<Dumbbell size={40} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-normal text-on-surface">{t('ready_title', lang)}</h1>
|
||||
<p className="text-on-surface-variant text-sm">{t('ready_subtitle', lang)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm bg-surface-container rounded-2xl p-6 flex flex-col items-center gap-4 shadow-elevation-1">
|
||||
<label className="text-xs text-on-surface-variant font-bold tracking-wide flex items-center gap-2">
|
||||
<User size={14} />
|
||||
{t('my_weight', lang)}
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
className="text-5xl font-normal text-on-surface tabular-nums bg-transparent text-center w-full focus:outline-none"
|
||||
value={userBodyWeight}
|
||||
onChange={(e) => setUserBodyWeight(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-on-surface-variant">{t('change_in_profile', lang)}</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xs">
|
||||
<button
|
||||
onClick={() => handleStart()}
|
||||
className="w-full h-16 rounded-full bg-primary text-on-primary font-medium text-lg shadow-elevation-2 hover:shadow-elevation-3 active:shadow-elevation-1 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<PlayCircle size={24} />
|
||||
{t('free_workout', lang)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{plans.length > 0 && (
|
||||
<div className="w-full max-w-md mt-8">
|
||||
<h3 className="text-sm text-on-surface-variant font-medium px-4 mb-3">{t('or_choose_plan', lang)}</h3>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{plans.map(plan => (
|
||||
<button
|
||||
key={plan.id}
|
||||
onClick={() => handleStart(plan)}
|
||||
className="flex items-center justify-between p-4 bg-surface-container rounded-xl hover:bg-surface-container-high transition-colors border border-outline-variant/20"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-base font-medium text-on-surface">{plan.name}</div>
|
||||
<div className="text-xs text-on-surface-variant">{plan.steps.length} {t('exercises_count', lang)}</div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center">
|
||||
<ArrowRight size={20} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPlanPrep && (
|
||||
<div className="absolute inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
|
||||
<h3 className="text-2xl font-normal text-on-surface mb-4">{showPlanPrep.name}</h3>
|
||||
<div className="bg-surface-container-high p-4 rounded-xl text-on-surface-variant text-sm mb-8">
|
||||
<div className="text-xs font-bold text-primary mb-2">{t('prep_title', lang)}</div>
|
||||
{showPlanPrep.description || t('prep_no_instructions', lang)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => setShowPlanPrep(null)} className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
|
||||
<button onClick={confirmPlanStart} className="px-6 py-2.5 rounded-full bg-primary text-on-primary font-medium">{t('start', lang)}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-full overflow-hidden relative bg-surface">
|
||||
<div className="px-4 py-3 bg-surface-container shadow-elevation-1 z-20 flex justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-title-medium text-on-surface flex items-center gap-2 font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-error animate-pulse"/>
|
||||
{activePlan ? activePlan.name : t('free_workout', lang)}
|
||||
</h2>
|
||||
<span className="text-xs text-on-surface-variant font-mono mt-0.5 flex items-center gap-2">
|
||||
<span className="bg-surface-container-high px-1.5 py-0.5 rounded text-on-surface font-bold">{elapsedTime}</span>
|
||||
{activeSession.userBodyWeight ? ` • ${activeSession.userBodyWeight}kg` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSessionEnd}
|
||||
className="px-5 py-2 rounded-full bg-error-container text-on-error-container text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('finish', lang)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activePlan && (
|
||||
<div className="bg-surface-container-low border-b border-outline-variant">
|
||||
<button
|
||||
onClick={() => setShowPlanList(!showPlanList)}
|
||||
className="w-full px-4 py-3 flex justify-between items-center"
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
{isPlanFinished ? (
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<CheckSquare size={18} />
|
||||
<span className="font-bold">{t('plan_completed', lang)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[10px] text-primary font-medium tracking-wider">{t('step', lang)} {currentStepIndex + 1} {t('of', lang)} {activePlan.steps.length}</span>
|
||||
<div className="font-medium text-on-surface flex items-center gap-2">
|
||||
{activePlan.steps[currentStepIndex].exerciseName}
|
||||
{activePlan.steps[currentStepIndex].isWeighted && <Scale size={12} className="text-primary" />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showPlanList ? <ChevronUp size={20} className="text-on-surface-variant"/> : <ChevronDown size={20} className="text-on-surface-variant"/>}
|
||||
</button>
|
||||
|
||||
{showPlanList && (
|
||||
<div className="max-h-48 overflow-y-auto bg-surface-container-high p-2 space-y-1 animate-in slide-in-from-top-2">
|
||||
{activePlan.steps.map((step, idx) => (
|
||||
<button
|
||||
key={step.id}
|
||||
onClick={() => jumpToStep(idx)}
|
||||
className={`w-full text-left px-4 py-3 rounded-full text-sm flex items-center justify-between transition-colors ${
|
||||
idx === currentStepIndex
|
||||
? 'bg-primary-container text-on-primary-container font-medium'
|
||||
: idx < currentStepIndex
|
||||
? 'text-on-surface-variant opacity-50'
|
||||
: 'text-on-surface hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<span>{idx+1}. {step.exerciseName}</span>
|
||||
{step.isWeighted && <Scale size={14} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-6">
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
className="w-full p-4 pr-12 bg-transparent border border-outline rounded-lg text-on-surface appearance-none focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary text-lg font-normal"
|
||||
value={selectedExercise?.id || ''}
|
||||
onChange={(e) => setSelectedExercise(exercises.find(ex => ex.id === e.target.value) || null)}
|
||||
>
|
||||
<option value="" disabled>{t('select_exercise', lang)}</option>
|
||||
{exercises.map(ex => (
|
||||
<option key={ex.id} value={ex.id} className="bg-surface-container text-on-surface">{ex.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 text-on-surface-variant pointer-events-none" size={24} />
|
||||
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="absolute right-12 top-1/2 -translate-y-1/2 p-2 text-primary hover:bg-primary-container/20 rounded-full"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedExercise && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-300 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
|
||||
<FilledInput
|
||||
label={selectedExercise.type === ExerciseType.BODYWEIGHT ? t('add_weight', lang) : t('weight_kg', lang)}
|
||||
value={weight}
|
||||
step="0.1"
|
||||
onChange={(e: any) => setWeight(e.target.value)}
|
||||
icon={<Scale size={10} />}
|
||||
autoFocus={activePlan && !isPlanFinished && activePlan.steps[currentStepIndex]?.isWeighted && (selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STRENGTH)}
|
||||
/>
|
||||
)}
|
||||
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && (
|
||||
<FilledInput
|
||||
label={t('reps', lang)}
|
||||
value={reps}
|
||||
onChange={(e: any) => setReps(e.target.value)}
|
||||
icon={<Activity size={10} />}
|
||||
type="number"
|
||||
/>
|
||||
)}
|
||||
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && (
|
||||
<FilledInput
|
||||
label={t('time_sec', lang)}
|
||||
value={duration}
|
||||
onChange={(e: any) => setDuration(e.target.value)}
|
||||
icon={<TimerIcon size={10} />}
|
||||
/>
|
||||
)}
|
||||
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && (
|
||||
<FilledInput
|
||||
label={t('dist_m', lang)}
|
||||
value={distance}
|
||||
onChange={(e: any) => setDistance(e.target.value)}
|
||||
icon={<ArrowRight size={10} />}
|
||||
/>
|
||||
)}
|
||||
{(selectedExercise.type === ExerciseType.HIGH_JUMP) && (
|
||||
<FilledInput
|
||||
label={t('height_cm', lang)}
|
||||
value={height}
|
||||
onChange={(e: any) => setHeight(e.target.value)}
|
||||
icon={<ArrowUp size={10} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
|
||||
<div className="flex items-center gap-4 px-2">
|
||||
<div className="flex items-center gap-2 text-on-surface-variant">
|
||||
<Percent size={16} />
|
||||
<span className="text-xs font-medium">{t('body_weight_percent', lang)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
className="w-20 border-b border-outline-variant bg-transparent text-center text-on-surface focus:border-primary focus:outline-none"
|
||||
value={bwPercentage}
|
||||
onChange={(e) => setBwPercentage(e.target.value)}
|
||||
/>
|
||||
<span className="text-on-surface-variant text-sm">%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleAddSet}
|
||||
className="w-full h-14 bg-primary-container text-on-primary-container font-medium text-lg rounded-full shadow-elevation-2 hover:shadow-elevation-3 active:scale-[0.98] transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<CheckCircle size={24} />
|
||||
<span>{t('log_set', lang)}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-surface-container px-4 py-2 rounded-full border border-outline-variant/20 text-xs text-on-surface-variant">
|
||||
{t('prev', lang)}: <span className="text-on-surface font-medium ml-1">
|
||||
{getLastSetForExercise(userId, selectedExercise.id) ? (
|
||||
<>
|
||||
{getLastSetForExercise(userId, selectedExercise.id)?.weight ? `${getLastSetForExercise(userId, selectedExercise.id)?.weight}kg × ` : ''}
|
||||
{getLastSetForExercise(userId, selectedExercise.id)?.reps ? `${getLastSetForExercise(userId, selectedExercise.id)?.reps}` : ''}
|
||||
{getLastSetForExercise(userId, selectedExercise.id)?.distanceMeters ? `${getLastSetForExercise(userId, selectedExercise.id)?.distanceMeters}m` : ''}
|
||||
{getLastSetForExercise(userId, selectedExercise.id)?.height ? `${getLastSetForExercise(userId, selectedExercise.id)?.height}cm` : ''}
|
||||
{getLastSetForExercise(userId, selectedExercise.id)?.durationSeconds ? `${getLastSetForExercise(userId, selectedExercise.id)?.durationSeconds}s` : ''}
|
||||
</>
|
||||
) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSession.sets.length > 0 && (
|
||||
<div className="pt-4">
|
||||
<h3 className="text-sm text-primary font-medium px-2 mb-3 tracking-wide">{t('history_section', lang)}</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[...activeSession.sets].reverse().map((set, idx) => {
|
||||
const setNumber = activeSession.sets.length - idx;
|
||||
return (
|
||||
<div key={set.id} className="flex justify-between items-center p-4 bg-surface-container rounded-xl shadow-elevation-1 animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-8 h-8 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center text-xs font-bold">
|
||||
{setNumber}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-medium text-on-surface">{set.exerciseName}</div>
|
||||
<div className="text-sm text-on-surface-variant">
|
||||
{set.weight !== undefined && `${set.weight}kg `}
|
||||
{set.reps !== undefined && `x ${set.reps}`}
|
||||
{set.distanceMeters !== undefined && `${set.distanceMeters}m`}
|
||||
{set.durationSeconds !== undefined && `${set.durationSeconds}s`}
|
||||
{set.height !== undefined && `${set.height}cm`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRemoveSet(set.id)}
|
||||
className="p-2 text-on-surface-variant hover:text-error hover:bg-error-container/10 rounded-full transition-colors"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCreating && (
|
||||
<div className="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-surface-container w-full max-w-sm rounded-[28px] p-6 shadow-elevation-3 animate-in slide-in-from-bottom-10 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-2xl font-normal text-on-surface">{t('create_exercise', lang)}</h3>
|
||||
<button onClick={() => setIsCreating(false)} className="p-2 bg-surface-container-high rounded-full hover:bg-outline-variant/20"><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<FilledInput
|
||||
label={t('ex_name', lang)}
|
||||
value={newName}
|
||||
onChange={(e: any) => setNewName(e.target.value)}
|
||||
type="text"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell},
|
||||
{id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User},
|
||||
{id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame},
|
||||
{id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon},
|
||||
{id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp},
|
||||
{id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler},
|
||||
{id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints},
|
||||
].map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setNewType(type.id)}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${
|
||||
newType === type.id
|
||||
? 'bg-secondary-container text-on-secondary-container border-transparent'
|
||||
: 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant'
|
||||
}`}
|
||||
>
|
||||
<type.icon size={14} /> {type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newType === ExerciseType.BODYWEIGHT && (
|
||||
<FilledInput
|
||||
label={t('body_weight_percent', lang)}
|
||||
value={newBwPercentage}
|
||||
onChange={(e: any) => setNewBwPercentage(e.target.value)}
|
||||
icon={<Percent size={12}/>}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
onClick={handleCreateExercise}
|
||||
className="px-8 py-3 bg-primary text-on-primary rounded-full font-medium shadow-elevation-1"
|
||||
>
|
||||
{t('create_btn', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tracker;
|
||||
101
index.html
Normal file
101
index.html
Normal 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
15
index.tsx
Normal 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
5
metadata.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "GymFlow AI",
|
||||
"description": "Трекер тренировок с AI-аналитикой.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
2978
package-lock.json
generated
Normal file
2978
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
127
services/auth.ts
Normal 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
38
services/geminiService.ts
Normal 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
279
services/i18n.ts
Normal 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
137
services/storage.ts
Normal 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
29
tsconfig.json
Normal 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
82
types.ts
Normal 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
23
vite.config.ts
Normal 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, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user