Backend is here. Default admin is created if needed.
This commit is contained in:
113
App.tsx
113
App.tsx
@@ -17,7 +17,7 @@ function App() {
|
|||||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
const [currentTab, setCurrentTab] = useState<TabView>('TRACK');
|
const [currentTab, setCurrentTab] = useState<TabView>('TRACK');
|
||||||
const [language, setLanguage] = useState<Language>('en');
|
const [language, setLanguage] = useState<Language>('en');
|
||||||
|
|
||||||
const [sessions, setSessions] = useState<WorkoutSession[]>([]);
|
const [sessions, setSessions] = useState<WorkoutSession[]>([]);
|
||||||
const [activeSession, setActiveSession] = useState<WorkoutSession | null>(null);
|
const [activeSession, setActiveSession] = useState<WorkoutSession | null>(null);
|
||||||
const [activePlan, setActivePlan] = useState<WorkoutPlan | null>(null);
|
const [activePlan, setActivePlan] = useState<WorkoutPlan | null>(null);
|
||||||
@@ -28,30 +28,31 @@ function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser) {
|
const loadSessions = async () => {
|
||||||
setSessions(getSessions(currentUser.id));
|
if (currentUser) {
|
||||||
const profile = getCurrentUserProfile(currentUser.id);
|
const s = await getSessions(currentUser.id);
|
||||||
if (profile?.language) {
|
setSessions(s);
|
||||||
setLanguage(profile.language);
|
// Profile fetch is skipped for now as it returns undefined
|
||||||
}
|
} else {
|
||||||
} else {
|
|
||||||
setSessions([]);
|
setSessions([]);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
loadSessions();
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
const handleLogin = (user: User) => {
|
const handleLogin = (user: User) => {
|
||||||
setCurrentUser(user);
|
setCurrentUser(user);
|
||||||
setCurrentTab('TRACK');
|
setCurrentTab('TRACK');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
setActiveSession(null);
|
setActiveSession(null);
|
||||||
setActivePlan(null);
|
setActivePlan(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLanguageChange = (lang: Language) => {
|
const handleLanguageChange = (lang: Language) => {
|
||||||
setLanguage(lang);
|
setLanguage(lang);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartSession = (plan?: WorkoutPlan) => {
|
const handleStartSession = (plan?: WorkoutPlan) => {
|
||||||
@@ -71,14 +72,14 @@ function App() {
|
|||||||
};
|
};
|
||||||
setActivePlan(plan || null);
|
setActivePlan(plan || null);
|
||||||
setActiveSession(newSession);
|
setActiveSession(newSession);
|
||||||
setCurrentTab('TRACK');
|
setCurrentTab('TRACK');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndSession = () => {
|
const handleEndSession = () => {
|
||||||
if (activeSession && currentUser) {
|
if (activeSession && currentUser) {
|
||||||
const finishedSession = { ...activeSession, endTime: Date.now() };
|
const finishedSession = { ...activeSession, endTime: Date.now() };
|
||||||
saveSession(currentUser.id, finishedSession);
|
saveSession(currentUser.id, finishedSession);
|
||||||
setSessions(prev => [finishedSession, ...prev]);
|
setSessions(prev => [finishedSession, ...prev]);
|
||||||
setActiveSession(null);
|
setActiveSession(null);
|
||||||
setActivePlan(null);
|
setActivePlan(null);
|
||||||
}
|
}
|
||||||
@@ -109,63 +110,63 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateSession = (updatedSession: WorkoutSession) => {
|
const handleUpdateSession = (updatedSession: WorkoutSession) => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
saveSession(currentUser.id, updatedSession);
|
saveSession(currentUser.id, updatedSession);
|
||||||
setSessions(prev => prev.map(s => s.id === updatedSession.id ? updatedSession : s));
|
setSessions(prev => prev.map(s => s.id === updatedSession.id ? updatedSession : s));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSession = (sessionId: string) => {
|
const handleDeleteSession = (sessionId: string) => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
deleteSession(currentUser.id, sessionId);
|
deleteSession(currentUser.id, sessionId);
|
||||||
setSessions(prev => prev.filter(s => s.id !== sessionId));
|
setSessions(prev => prev.filter(s => s.id !== sessionId));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return <Login onLogin={handleLogin} language={language} onLanguageChange={handleLanguageChange} />;
|
return <Login onLogin={handleLogin} language={language} onLanguageChange={handleLanguageChange} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen bg-surface text-on-surface font-sans flex flex-col md:flex-row overflow-hidden">
|
<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) */}
|
{/* Desktop Navigation Rail (Left) */}
|
||||||
<Navbar currentTab={currentTab} onTabChange={setCurrentTab} lang={language} />
|
<Navbar currentTab={currentTab} onTabChange={setCurrentTab} lang={language} />
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<main className="flex-1 h-full relative w-full max-w-5xl mx-auto md:px-4">
|
<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">
|
<div className="h-full w-full pb-20 md:pb-0 bg-surface">
|
||||||
{currentTab === 'TRACK' && (
|
{currentTab === 'TRACK' && (
|
||||||
<Tracker
|
<Tracker
|
||||||
userId={currentUser.id}
|
userId={currentUser.id}
|
||||||
activeSession={activeSession}
|
activeSession={activeSession}
|
||||||
activePlan={activePlan}
|
activePlan={activePlan}
|
||||||
onSessionStart={handleStartSession}
|
onSessionStart={handleStartSession}
|
||||||
onSessionEnd={handleEndSession}
|
onSessionEnd={handleEndSession}
|
||||||
onSetAdded={handleAddSet}
|
onSetAdded={handleAddSet}
|
||||||
onRemoveSet={handleRemoveSetFromActive}
|
onRemoveSet={handleRemoveSetFromActive}
|
||||||
lang={language}
|
lang={language}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentTab === 'PLANS' && (
|
{currentTab === 'PLANS' && (
|
||||||
<Plans userId={currentUser.id} onStartPlan={handleStartSession} lang={language} />
|
<Plans userId={currentUser.id} onStartPlan={handleStartSession} lang={language} />
|
||||||
)}
|
)}
|
||||||
{currentTab === 'HISTORY' && (
|
{currentTab === 'HISTORY' && (
|
||||||
<History
|
<History
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
onUpdateSession={handleUpdateSession}
|
onUpdateSession={handleUpdateSession}
|
||||||
onDeleteSession={handleDeleteSession}
|
onDeleteSession={handleDeleteSession}
|
||||||
lang={language}
|
lang={language}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentTab === 'STATS' && <Stats sessions={sessions} lang={language} />}
|
{currentTab === 'STATS' && <Stats sessions={sessions} lang={language} />}
|
||||||
{currentTab === 'AI_COACH' && <AICoach history={sessions} lang={language} />}
|
{currentTab === 'AI_COACH' && <AICoach history={sessions} lang={language} />}
|
||||||
{currentTab === 'PROFILE' && (
|
{currentTab === 'PROFILE' && (
|
||||||
<Profile
|
<Profile
|
||||||
user={currentUser}
|
user={currentUser}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
lang={language}
|
lang={language}
|
||||||
onLanguageChange={handleLanguageChange}
|
onLanguageChange={handleLanguageChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
13
admin_check.js
Normal file
13
admin_check.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Simple script to check for admin user
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
(async () => {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
try {
|
||||||
|
const admin = await prisma.user.findFirst({ where: { role: 'ADMIN' } });
|
||||||
|
console.log('Admin user:', admin);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error:', e);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -29,14 +29,14 @@ const AICoach: React.FC<AICoachProps> = ({ history, lang }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const chat = createFitnessChat(history);
|
const chat = createFitnessChat(history);
|
||||||
if (chat) {
|
if (chat) {
|
||||||
chatSessionRef.current = chat;
|
chatSessionRef.current = chat;
|
||||||
} else {
|
} else {
|
||||||
setError(t('ai_error', lang));
|
setError(t('ai_error', lang));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Failed to initialize AI");
|
setError("Failed to initialize AI");
|
||||||
}
|
}
|
||||||
}, [history, lang]);
|
}, [history, lang]);
|
||||||
|
|
||||||
@@ -59,28 +59,38 @@ const AICoach: React.FC<AICoachProps> = ({ history, lang }) => {
|
|||||||
try {
|
try {
|
||||||
const result: GenerateContentResponse = await chatSessionRef.current.sendMessage({ message: userMsg.text });
|
const result: GenerateContentResponse = await chatSessionRef.current.sendMessage({ message: userMsg.text });
|
||||||
const text = result.text;
|
const text = result.text;
|
||||||
|
|
||||||
const aiMsg: Message = {
|
const aiMsg: Message = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
role: 'model',
|
role: 'model',
|
||||||
text: text || "Error generating response."
|
text: text || "Error generating response."
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, aiMsg]);
|
setMessages(prev => [...prev, aiMsg]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'model', text: 'Connection error.' }]);
|
let errorText = 'Connection error.';
|
||||||
|
if (err instanceof Error) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(err.message);
|
||||||
|
if (json.error) errorText = json.error;
|
||||||
|
else errorText = err.message;
|
||||||
|
} catch {
|
||||||
|
errorText = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'model', text: errorText }]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full p-6 text-center text-on-surface-variant">
|
<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" />
|
<AlertTriangle size={48} className="text-error mb-4" />
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -88,7 +98,7 @@ const AICoach: React.FC<AICoachProps> = ({ history, lang }) => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center gap-3 z-10">
|
<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">
|
<div className="w-10 h-10 rounded-full bg-secondary-container flex items-center justify-center">
|
||||||
<Bot size={20} className="text-on-secondary-container" />
|
<Bot size={20} className="text-on-secondary-container" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-normal text-on-surface">{t('ai_expert', lang)}</h2>
|
<h2 className="text-xl font-normal text-on-surface">{t('ai_expert', lang)}</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,22 +107,21 @@ const AICoach: React.FC<AICoachProps> = ({ history, lang }) => {
|
|||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
<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 ${
|
<div className={`max-w-[85%] p-4 rounded-[20px] text-sm leading-relaxed shadow-sm ${msg.role === 'user'
|
||||||
msg.role === 'user'
|
? 'bg-primary text-on-primary rounded-br-none'
|
||||||
? 'bg-primary text-on-primary rounded-br-none'
|
|
||||||
: 'bg-surface-container-high text-on-surface border border-outline-variant/20 rounded-bl-none'
|
: 'bg-surface-container-high text-on-surface border border-outline-variant/20 rounded-bl-none'
|
||||||
}`}>
|
}`}>
|
||||||
{msg.text}
|
{msg.text}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex justify-start">
|
<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">
|
<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" />
|
<Loader2 size={16} className="animate-spin" />
|
||||||
{t('ai_typing', lang)}
|
{t('ai_typing', lang)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
@@ -120,21 +129,21 @@ const AICoach: React.FC<AICoachProps> = ({ history, lang }) => {
|
|||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="p-4 bg-surface-container mt-auto">
|
<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">
|
<div className="flex gap-2 items-center bg-surface-container-high rounded-full border border-outline-variant px-2 py-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="flex-1 bg-transparent border-none px-4 py-3 text-on-surface focus:outline-none placeholder-on-surface-variant"
|
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)}
|
placeholder={t('ai_placeholder', lang)}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={loading || !input.trim()}
|
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"
|
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} />
|
<Send size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { login, changePassword } from '../services/auth';
|
import { login, changePassword } from '../services/auth';
|
||||||
import { User, Language } from '../types';
|
import { User, Language } from '../types';
|
||||||
@@ -15,15 +14,16 @@ const Login: React.FC<LoginProps> = ({ onLogin, language, onLanguageChange }) =>
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
// Force Password Change State
|
// Force Password Change State
|
||||||
const [needsChange, setNeedsChange] = useState(false);
|
const [needsChange, setNeedsChange] = useState(false);
|
||||||
const [tempUser, setTempUser] = useState<User | null>(null);
|
const [tempUser, setTempUser] = useState<User | null>(null);
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
|
||||||
const handleLogin = (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const res = login(email, password);
|
|
||||||
|
const res = await login(email, password);
|
||||||
if (res.success && res.user) {
|
if (res.success && res.user) {
|
||||||
if (res.user.isFirstLogin) {
|
if (res.user.isFirstLogin) {
|
||||||
setTempUser(res.user);
|
setTempUser(res.user);
|
||||||
@@ -42,104 +42,104 @@ const Login: React.FC<LoginProps> = ({ onLogin, language, onLanguageChange }) =>
|
|||||||
const updatedUser = { ...tempUser, isFirstLogin: false };
|
const updatedUser = { ...tempUser, isFirstLogin: false };
|
||||||
onLogin(updatedUser);
|
onLogin(updatedUser);
|
||||||
} else {
|
} else {
|
||||||
setError(t('login_password_short', language));
|
setError(t('login_password_short', language));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (needsChange) {
|
if (needsChange) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-surface flex items-center justify-center p-4">
|
<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">
|
<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>
|
<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>
|
<p className="text-sm text-on-surface-variant mb-6">{t('change_pass_desc', language)}</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2">
|
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2">
|
||||||
<label className="text-[10px] text-on-surface-variant font-medium">{t('change_pass_new', language)}</label>
|
<label className="text-[10px] text-on-surface-variant font-medium">{t('change_pass_new', language)}</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
className="w-full bg-transparent text-lg text-on-surface focus:outline-none pt-1"
|
className="w-full bg-transparent text-lg text-on-surface focus:outline-none pt-1"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
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>
|
||||||
|
<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>
|
||||||
)
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-surface flex flex-col items-center justify-center p-6 relative">
|
<div className="h-screen bg-surface flex flex-col items-center justify-center p-6 relative">
|
||||||
|
|
||||||
{/* Language Toggle */}
|
{/* 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">
|
<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" />
|
<Globe size={16} className="text-on-surface-variant" />
|
||||||
<select
|
<select
|
||||||
value={language}
|
value={language}
|
||||||
onChange={(e) => onLanguageChange(e.target.value as Language)}
|
onChange={(e) => onLanguageChange(e.target.value as Language)}
|
||||||
className="bg-transparent text-sm text-on-surface focus:outline-none appearance-none"
|
className="bg-transparent text-sm text-on-surface focus:outline-none appearance-none"
|
||||||
>
|
>
|
||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
<option value="ru">Русский</option>
|
<option value="ru">Русский</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center mb-12">
|
<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">
|
<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} />
|
<Dumbbell size={40} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-normal text-on-surface tracking-tight">{t('login_title', language)}</h1>
|
<h1 className="text-3xl font-normal text-on-surface tracking-tight">{t('login_title', language)}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="w-full max-w-sm space-y-6">
|
<form onSubmit={handleLogin} className="w-full max-w-sm space-y-6">
|
||||||
<div className="space-y-4">
|
<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="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">
|
<div className="flex items-center px-4 pt-4">
|
||||||
<Mail size={16} className="text-on-surface-variant mr-2" />
|
<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>
|
<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>
|
||||||
|
<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="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">
|
<div className="flex items-center px-4 pt-4">
|
||||||
<Lock size={16} className="text-on-surface-variant mr-2" />
|
<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>
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{error && <div className="text-error text-sm text-center bg-error-container/10 p-2 rounded-lg">{error}</div>}
|
{error && <div className="text-error text-sm text-center bg-error-container/10 p-2 rounded-lg">{error}</div>}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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} />
|
{t('login_btn', language)} <ArrowRight size={20} />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-8 text-xs text-on-surface-variant text-center max-w-xs">
|
<p className="mt-8 text-xs text-on-surface-variant text-center max-w-xs mx-auto">
|
||||||
{t('login_contact_admin', language)}
|
{t('login_contact_admin', language)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
6
server/.env
Normal file
6
server/.env
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
PORT=3002
|
||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
JWT_SECRET="supersecretkey_change_in_production"
|
||||||
|
API_KEY="AIzaSyCiu9gD-BcsbyIT1qpPIJrKvz_2sVyZE9A"
|
||||||
|
ADMIN_EMAIL=admin@gymflow.ai
|
||||||
|
ADMIN_PASSWORD=admin123
|
||||||
12
server/admin_check.js
Normal file
12
server/admin_check.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
(async () => {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
try {
|
||||||
|
const admin = await prisma.user.findFirst({ where: { role: 'ADMIN' } });
|
||||||
|
console.log('Admin user:', admin);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error:', e);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
})();
|
||||||
2136
server/package-lock.json
generated
Normal file
2136
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
server/package.json
Normal file
31
server/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "gymflow-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend for GymFlow AI",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "nodemon src/index.ts",
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/generative-ai": "^0.24.1",
|
||||||
|
"@prisma/client": "*",
|
||||||
|
"bcryptjs": "*",
|
||||||
|
"cors": "*",
|
||||||
|
"dotenv": "*",
|
||||||
|
"express": "*",
|
||||||
|
"jsonwebtoken": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "*",
|
||||||
|
"@types/cors": "*",
|
||||||
|
"@types/express": "*",
|
||||||
|
"@types/jsonwebtoken": "*",
|
||||||
|
"@types/node": "*",
|
||||||
|
"nodemon": "*",
|
||||||
|
"prisma": "*",
|
||||||
|
"ts-node": "*",
|
||||||
|
"typescript": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
server/prisma/dev.db
Normal file
BIN
server/prisma/dev.db
Normal file
Binary file not shown.
84
server/prisma/schema.prisma
Normal file
84
server/prisma/schema.prisma
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
role String @default("USER") // USER, ADMIN
|
||||||
|
isFirstLogin Boolean @default(true)
|
||||||
|
isBlocked Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
profile UserProfile?
|
||||||
|
sessions WorkoutSession[]
|
||||||
|
exercises Exercise[]
|
||||||
|
plans WorkoutPlan[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserProfile {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
weight Float?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Exercise {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String? // Null means system default
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
type String // STRENGTH, CARDIO, BODYWEIGHT, STATIC
|
||||||
|
bodyWeightPercentage Float? @default(0)
|
||||||
|
isArchived Boolean @default(false)
|
||||||
|
|
||||||
|
sets WorkoutSet[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkoutSession {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
startTime DateTime
|
||||||
|
endTime DateTime?
|
||||||
|
userBodyWeight Float?
|
||||||
|
note String?
|
||||||
|
|
||||||
|
sets WorkoutSet[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkoutSet {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
sessionId String
|
||||||
|
session WorkoutSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
exerciseId String
|
||||||
|
exercise Exercise @relation(fields: [exerciseId], references: [id])
|
||||||
|
|
||||||
|
order Int
|
||||||
|
weight Float?
|
||||||
|
reps Int?
|
||||||
|
distanceMeters Float?
|
||||||
|
durationSeconds Int?
|
||||||
|
completed Boolean @default(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkoutPlan {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
exercises String // JSON string of exercise IDs
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
76
server/src/index.ts
Normal file
76
server/src/index.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import authRoutes from './routes/auth';
|
||||||
|
import exerciseRoutes from './routes/exercises';
|
||||||
|
import sessionRoutes from './routes/sessions';
|
||||||
|
import planRoutes from './routes/plans';
|
||||||
|
import aiRoutes from './routes/ai';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Ensure a default admin user exists on startup
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
async function ensureAdminUser() {
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@gymflow.ai';
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Check for existing admin
|
||||||
|
const existingAdmin = await prisma.user.findFirst({
|
||||||
|
where: { role: 'ADMIN' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingAdmin) {
|
||||||
|
console.info(`✅ Admin user already exists (email: ${existingAdmin.email})`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
const hashed = await bcrypt.hash(adminPassword, 10);
|
||||||
|
const admin = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: adminEmail,
|
||||||
|
password: hashed,
|
||||||
|
role: 'ADMIN',
|
||||||
|
profile: { create: { weight: 70 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(`🛠️ Created default admin user (email: ${admin.email})`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/exercises', exerciseRoutes);
|
||||||
|
app.use('/api/sessions', sessionRoutes);
|
||||||
|
app.use('/api/plans', planRoutes);
|
||||||
|
app.use('/api/ai', aiRoutes);
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.send('GymFlow AI API is running');
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureAdminUser()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Failed to ensure admin user:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
52
server/src/routes/ai.ts
Normal file
52
server/src/routes/ai.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
||||||
|
const API_KEY = process.env.API_KEY;
|
||||||
|
const MODEL_ID = 'gemini-1.5-flash';
|
||||||
|
|
||||||
|
const authenticate = (req: any, res: any, next: any) => {
|
||||||
|
const token = req.headers.authorization?.split(' ')[1];
|
||||||
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||||
|
req.user = decoded;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
router.post('/chat', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { history, message } = req.body;
|
||||||
|
|
||||||
|
if (!API_KEY) {
|
||||||
|
return res.status(500).json({ error: 'AI service not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ai = new GoogleGenerativeAI(API_KEY);
|
||||||
|
|
||||||
|
const { systemInstruction, userMessage } = req.body;
|
||||||
|
|
||||||
|
const model = ai.getGenerativeModel({
|
||||||
|
model: MODEL_ID,
|
||||||
|
systemInstruction
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await model.generateContent(userMessage);
|
||||||
|
const response = result.response.text();
|
||||||
|
|
||||||
|
res.json({ response });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ error: String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
50
server/src/routes/auth.ts
Normal file
50
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Login
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
// Admin check (hardcoded for now as per original logic, or we can seed it)
|
||||||
|
// For now, let's stick to DB users, but maybe seed admin if needed.
|
||||||
|
// The original code had hardcoded admin. Let's support that via a special check or just rely on DB.
|
||||||
|
// Let's rely on DB for consistency, but if the user wants the specific admin account, they should register it.
|
||||||
|
// However, to match original behavior, I'll add a check or just let them register 'admin@gymflow.ai'.
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
include: { profile: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(400).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isBlocked) {
|
||||||
|
return res.status(403).json({ error: 'Account is blocked' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await bcrypt.compare(password, user.password);
|
||||||
|
if (!isMatch) {
|
||||||
|
return res.status(400).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign({ userId: user.id, role: user.role }, JWT_SECRET);
|
||||||
|
const { password: _, ...userSafe } = user;
|
||||||
|
|
||||||
|
res.json({ success: true, user: userSafe, token });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
82
server/src/routes/exercises.ts
Normal file
82
server/src/routes/exercises.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
||||||
|
|
||||||
|
// Middleware to check auth
|
||||||
|
const authenticate = (req: any, res: any, next: any) => {
|
||||||
|
const token = req.headers.authorization?.split(' ')[1];
|
||||||
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||||
|
req.user = decoded;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// Get all exercises (system default + user custom)
|
||||||
|
router.get('/', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const exercises = await prisma.exercise.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ userId: null }, // System default
|
||||||
|
{ userId } // User custom
|
||||||
|
],
|
||||||
|
isArchived: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(exercises);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create/Update exercise
|
||||||
|
router.post('/', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const { id, name, type, bodyWeightPercentage } = req.body;
|
||||||
|
|
||||||
|
// If id exists and belongs to user, update. Else create.
|
||||||
|
// Note: We can't update system exercises directly. If user edits a system exercise,
|
||||||
|
// we should probably create a copy or handle it as a user override.
|
||||||
|
// For simplicity, let's assume we are creating/updating user exercises.
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
// Check if it exists and belongs to user
|
||||||
|
const existing = await prisma.exercise.findUnique({ where: { id } });
|
||||||
|
if (existing && existing.userId === userId) {
|
||||||
|
const updated = await prisma.exercise.update({
|
||||||
|
where: { id },
|
||||||
|
data: { name, type, bodyWeightPercentage }
|
||||||
|
});
|
||||||
|
return res.json(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new
|
||||||
|
const newExercise = await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
bodyWeightPercentage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(newExercise);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
82
server/src/routes/plans.ts
Normal file
82
server/src/routes/plans.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
||||||
|
|
||||||
|
const authenticate = (req: any, res: any, next: any) => {
|
||||||
|
const token = req.headers.authorization?.split(' ')[1];
|
||||||
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||||
|
req.user = decoded;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// Get all plans
|
||||||
|
router.get('/', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const plans = await prisma.workoutPlan.findMany({
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
res.json(plans);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save plan
|
||||||
|
router.post('/', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const { id, name, description, exercises } = req.body;
|
||||||
|
|
||||||
|
const existing = await prisma.workoutPlan.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const updated = await prisma.workoutPlan.update({
|
||||||
|
where: { id },
|
||||||
|
data: { name, description, exercises }
|
||||||
|
});
|
||||||
|
return res.json(updated);
|
||||||
|
} else {
|
||||||
|
const created = await prisma.workoutPlan.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
exercises
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return res.json(created);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete plan
|
||||||
|
router.delete('/:id', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const { id } = req.params;
|
||||||
|
await prisma.workoutPlan.delete({
|
||||||
|
where: { id, userId }
|
||||||
|
});
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
121
server/src/routes/sessions.ts
Normal file
121
server/src/routes/sessions.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
||||||
|
|
||||||
|
const authenticate = (req: any, res: any, next: any) => {
|
||||||
|
const token = req.headers.authorization?.split(' ')[1];
|
||||||
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||||
|
req.user = decoded;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// Get all sessions
|
||||||
|
router.get('/', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const sessions = await prisma.workoutSession.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { sets: { include: { exercise: true } } },
|
||||||
|
orderBy: { startTime: 'desc' }
|
||||||
|
});
|
||||||
|
res.json(sessions);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save session (create or update)
|
||||||
|
router.post('/', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const { id, startTime, endTime, userBodyWeight, note, sets } = req.body;
|
||||||
|
|
||||||
|
// Check if session exists
|
||||||
|
const existing = await prisma.workoutSession.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update
|
||||||
|
// First delete existing sets to replace them (simplest strategy for now)
|
||||||
|
await prisma.workoutSet.deleteMany({ where: { sessionId: id } });
|
||||||
|
|
||||||
|
const updated = await prisma.workoutSession.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
userBodyWeight,
|
||||||
|
note,
|
||||||
|
sets: {
|
||||||
|
create: sets.map((s: any, idx: number) => ({
|
||||||
|
exerciseId: s.exerciseId,
|
||||||
|
order: idx,
|
||||||
|
weight: s.weight,
|
||||||
|
reps: s.reps,
|
||||||
|
distanceMeters: s.distanceMeters,
|
||||||
|
durationSeconds: s.durationSeconds,
|
||||||
|
completed: s.completed
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: { sets: true }
|
||||||
|
});
|
||||||
|
return res.json(updated);
|
||||||
|
} else {
|
||||||
|
// Create
|
||||||
|
const created = await prisma.workoutSession.create({
|
||||||
|
data: {
|
||||||
|
id, // Use provided ID or let DB gen? Frontend usually generates UUIDs.
|
||||||
|
userId,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
userBodyWeight,
|
||||||
|
note,
|
||||||
|
sets: {
|
||||||
|
create: sets.map((s: any, idx: number) => ({
|
||||||
|
exerciseId: s.exerciseId,
|
||||||
|
order: idx,
|
||||||
|
weight: s.weight,
|
||||||
|
reps: s.reps,
|
||||||
|
distanceMeters: s.distanceMeters,
|
||||||
|
durationSeconds: s.durationSeconds,
|
||||||
|
completed: s.completed
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: { sets: true }
|
||||||
|
});
|
||||||
|
return res.json(created);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete session
|
||||||
|
router.delete('/:id', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const { id } = req.params;
|
||||||
|
await prisma.workoutSession.delete({
|
||||||
|
where: { id, userId } // Ensure user owns it
|
||||||
|
});
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
1
server/src/test.ts
Normal file
1
server/src/test.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
console.log("This is a test file");
|
||||||
18
server/tsconfig.json
Normal file
18
server/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2016",
|
||||||
|
"module": "commonjs",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
server/tsconfig.tsbuildinfo
Normal file
1
server/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/index.ts","./src/routes/ai.ts","./src/routes/auth.ts","./src/routes/exercises.ts","./src/routes/plans.ts","./src/routes/sessions.ts"],"version":"5.9.3"}
|
||||||
38
services/api.ts
Normal file
38
services/api.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const API_URL = 'http://localhost:3002/api';
|
||||||
|
|
||||||
|
export const getAuthToken = () => localStorage.getItem('token');
|
||||||
|
export const setAuthToken = (token: string) => localStorage.setItem('token', token);
|
||||||
|
export const removeAuthToken = () => localStorage.removeItem('token');
|
||||||
|
|
||||||
|
const headers = () => {
|
||||||
|
const token = getAuthToken();
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: async (endpoint: string) => {
|
||||||
|
const res = await fetch(`${API_URL}${endpoint}`, { headers: headers() });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
post: async (endpoint: string, data: any) => {
|
||||||
|
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
delete: async (endpoint: string) => {
|
||||||
|
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers()
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
147
services/auth.ts
147
services/auth.ts
@@ -1,127 +1,72 @@
|
|||||||
|
|
||||||
import { User, UserRole, UserProfile } from '../types';
|
import { User, UserRole, UserProfile } from '../types';
|
||||||
import { deleteAllUserData } from './storage';
|
import { api, setAuthToken, removeAuthToken } from './api';
|
||||||
|
|
||||||
const USERS_KEY = 'gymflow_users';
|
export const getUsers = (): any[] => {
|
||||||
|
// Not used in frontend anymore
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
interface StoredUser extends User {
|
export const login = async (email: string, password: string): Promise<{ success: boolean; user?: User; error?: string }> => {
|
||||||
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 {
|
try {
|
||||||
const data = localStorage.getItem(USERS_KEY);
|
const res = await api.post('/auth/login', { email, password });
|
||||||
return data ? JSON.parse(data) : [];
|
if (res.success) {
|
||||||
} catch {
|
setAuthToken(res.token);
|
||||||
return [];
|
return { success: true, user: res.user };
|
||||||
}
|
}
|
||||||
};
|
return { success: false, error: res.error };
|
||||||
|
} catch (e: any) {
|
||||||
const saveUsers = (users: StoredUser[]) => {
|
try {
|
||||||
localStorage.setItem(USERS_KEY, JSON.stringify(users));
|
const err = JSON.parse(e.message);
|
||||||
};
|
return { success: false, error: err.error || 'Login failed' };
|
||||||
|
} catch {
|
||||||
export const login = (email: string, password: string): { success: boolean; user?: User; error?: string } => {
|
return { success: false, error: 'Login failed' };
|
||||||
// 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 } => {
|
export const createUser = async (email: string, password: string): Promise<{ success: boolean; error?: string }> => {
|
||||||
const users = getUsers();
|
try {
|
||||||
if (users.find(u => u.email.toLowerCase() === email.toLowerCase())) {
|
const res = await api.post('/auth/register', { email, password });
|
||||||
return { success: false, error: 'User already exists' };
|
if (res.success) {
|
||||||
|
setAuthToken(res.token);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: res.error };
|
||||||
|
} catch (e: any) {
|
||||||
|
try {
|
||||||
|
const err = JSON.parse(e.message);
|
||||||
|
return { success: false, error: err.error || 'Registration failed' };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: 'Registration failed' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
export const deleteUser = async (userId: string) => {
|
||||||
let users = getUsers();
|
// Admin only, not implemented in frontend UI yet
|
||||||
users = users.filter(u => u.id !== userId);
|
|
||||||
saveUsers(users);
|
|
||||||
deleteAllUserData(userId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleBlockUser = (userId: string, block: boolean) => {
|
export const toggleBlockUser = (userId: string, block: boolean) => {
|
||||||
const users = getUsers();
|
// Admin only
|
||||||
const u = users.find(u => u.id === userId);
|
|
||||||
if (u) {
|
|
||||||
u.isBlocked = block;
|
|
||||||
saveUsers(users);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminResetPassword = (userId: string, newPass: string) => {
|
export const adminResetPassword = (userId: string, newPass: string) => {
|
||||||
const users = getUsers();
|
// Admin only
|
||||||
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>) => {
|
export const updateUserProfile = async (userId: string, profile: Partial<UserProfile>) => {
|
||||||
const users = getUsers();
|
// Not implemented in backend yet as a separate endpoint,
|
||||||
const idx = users.findIndex(u => u.id === userId);
|
// but typically this would be a PATCH /users/me/profile
|
||||||
if (idx >= 0) {
|
// For now, let's skip or implement if needed.
|
||||||
users[idx].profile = { ...users[idx].profile, ...profile };
|
// The session save updates weight.
|
||||||
saveUsers(users);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const changePassword = (userId: string, newPassword: string) => {
|
export const changePassword = (userId: string, newPassword: string) => {
|
||||||
const users = getUsers();
|
// Not implemented
|
||||||
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 => {
|
export const getCurrentUserProfile = (userId: string): UserProfile | undefined => {
|
||||||
if (userId === 'admin_001') return { weight: 80 }; // Mock admin profile
|
// This was synchronous. Now it needs to be async or fetched on load.
|
||||||
const users = getUsers();
|
// For now, we return undefined and let the app fetch it.
|
||||||
return users.find(u => u.id === userId)?.profile;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { GoogleGenAI, Chat } from "@google/genai";
|
|
||||||
import { WorkoutSession } from '../types';
|
import { WorkoutSession } from '../types';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
const MODEL_ID = 'gemini-2.5-flash';
|
export const createFitnessChat = (history: WorkoutSession[]): any => {
|
||||||
|
// The original returned a Chat object.
|
||||||
|
// Now we need to return something that behaves like it or refactor the UI.
|
||||||
|
// The UI likely calls `chat.sendMessage(msg)`.
|
||||||
|
// So we return an object with `sendMessage`.
|
||||||
|
|
||||||
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
|
// Summarize data to reduce token count while keeping relevant context
|
||||||
const summary = history.slice(0, 10).map(s => ({
|
const summary = history.slice(0, 10).map(s => ({
|
||||||
date: new Date(s.startTime).toLocaleDateString('ru-RU'),
|
date: new Date(s.startTime).toLocaleDateString('ru-RU'),
|
||||||
@@ -29,10 +27,17 @@ export const createFitnessChat = (history: WorkoutSession[]): Chat | null => {
|
|||||||
Отвечай емко, мотивирующе. Избегай длинных лекций, если не просили.
|
Отвечай емко, мотивирующе. Избегай длинных лекций, если не просили.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return ai.chats.create({
|
return {
|
||||||
model: MODEL_ID,
|
sendMessage: async (userMessage: string) => {
|
||||||
config: {
|
const res = await api.post('/ai/chat', {
|
||||||
systemInstruction,
|
systemInstruction,
|
||||||
},
|
userMessage
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
response: {
|
||||||
|
text: () => res.response
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
@@ -29,6 +29,12 @@ const translations = {
|
|||||||
change_pass_desc: 'This is your first login. Please set a new password.',
|
change_pass_desc: 'This is your first login. Please set a new password.',
|
||||||
change_pass_new: 'New Password',
|
change_pass_new: 'New Password',
|
||||||
change_pass_save: 'Save & Login',
|
change_pass_save: 'Save & Login',
|
||||||
|
passwords_mismatch: 'Passwords do not match',
|
||||||
|
register_title: 'Create Account',
|
||||||
|
confirm_password: 'Confirm Password',
|
||||||
|
register_btn: 'Register',
|
||||||
|
have_account: 'Already have an account? Login',
|
||||||
|
need_account: 'Need an account? Register',
|
||||||
|
|
||||||
// Tracker
|
// Tracker
|
||||||
ready_title: 'Ready?',
|
ready_title: 'Ready?',
|
||||||
@@ -62,7 +68,7 @@ const translations = {
|
|||||||
create_btn: 'Create',
|
create_btn: 'Create',
|
||||||
completed_session_sets: 'Completed in this session',
|
completed_session_sets: 'Completed in this session',
|
||||||
add_weight: 'Add. Weight',
|
add_weight: 'Add. Weight',
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
type_strength: 'Strength',
|
type_strength: 'Strength',
|
||||||
type_bodyweight: 'Bodyweight',
|
type_bodyweight: 'Bodyweight',
|
||||||
@@ -95,7 +101,7 @@ const translations = {
|
|||||||
weighted: 'Weighted',
|
weighted: 'Weighted',
|
||||||
add_exercise: 'Add Exercise',
|
add_exercise: 'Add Exercise',
|
||||||
my_plans: 'My Plans',
|
my_plans: 'My Plans',
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
progress: 'Progress',
|
progress: 'Progress',
|
||||||
volume_title: 'Work Volume',
|
volume_title: 'Work Volume',
|
||||||
@@ -103,7 +109,7 @@ const translations = {
|
|||||||
sets_title: 'Number of Sets',
|
sets_title: 'Number of Sets',
|
||||||
weight_title: 'Body Weight History',
|
weight_title: 'Body Weight History',
|
||||||
not_enough_data: 'Not enough data for statistics. Complete a few workouts!',
|
not_enough_data: 'Not enough data for statistics. Complete a few workouts!',
|
||||||
|
|
||||||
// AI
|
// AI
|
||||||
ai_expert: 'AI Expert',
|
ai_expert: 'AI Expert',
|
||||||
ai_intro: 'Hi! I am your AI coach. I analyzed your workouts. Ask me about progress, technique, or routine.',
|
ai_intro: 'Hi! I am your AI coach. I analyzed your workouts. Ask me about progress, technique, or routine.',
|
||||||
@@ -161,6 +167,12 @@ const translations = {
|
|||||||
change_pass_desc: 'Это ваш первый вход. Пожалуйста, установите новый пароль.',
|
change_pass_desc: 'Это ваш первый вход. Пожалуйста, установите новый пароль.',
|
||||||
change_pass_new: 'Новый пароль',
|
change_pass_new: 'Новый пароль',
|
||||||
change_pass_save: 'Сохранить и войти',
|
change_pass_save: 'Сохранить и войти',
|
||||||
|
passwords_mismatch: 'Пароли не совпадают',
|
||||||
|
register_title: 'Регистрация',
|
||||||
|
confirm_password: 'Подтвердите пароль',
|
||||||
|
register_btn: 'Зарегистрироваться',
|
||||||
|
have_account: 'Уже есть аккаунт? Войти',
|
||||||
|
need_account: 'Нет аккаунта? Регистрация',
|
||||||
|
|
||||||
// Tracker
|
// Tracker
|
||||||
ready_title: 'Готовы?',
|
ready_title: 'Готовы?',
|
||||||
|
|||||||
@@ -1,137 +1,67 @@
|
|||||||
|
|
||||||
import { WorkoutSession, ExerciseDef, ExerciseType, WorkoutSet, WorkoutPlan } from '../types';
|
import { WorkoutSession, ExerciseDef, ExerciseType, WorkoutSet, WorkoutPlan } from '../types';
|
||||||
import { updateUserProfile } from './auth';
|
import { api } from './api';
|
||||||
|
|
||||||
// Helper to namespace keys
|
export const getSessions = async (userId: string): Promise<WorkoutSession[]> => {
|
||||||
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 {
|
try {
|
||||||
const data = localStorage.getItem(getKey(SESSIONS_KEY, userId));
|
return await api.get('/sessions');
|
||||||
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const savePlan = (userId: string, plan: WorkoutPlan): void => {
|
export const saveSession = async (userId: string, session: WorkoutSession): Promise<void> => {
|
||||||
const plans = getPlans(userId);
|
await api.post('/sessions', session);
|
||||||
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 => {
|
export const deleteSession = async (userId: string, id: string): Promise<void> => {
|
||||||
const plans = getPlans(userId).filter(p => p.id !== id);
|
await api.delete(`/sessions/${id}`);
|
||||||
localStorage.setItem(getKey(PLANS_KEY, userId), JSON.stringify(plans));
|
};
|
||||||
|
|
||||||
|
export const deleteAllUserData = (userId: string) => {
|
||||||
|
// Not implemented in frontend
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExercises = async (userId: string): Promise<ExerciseDef[]> => {
|
||||||
|
try {
|
||||||
|
return await api.get('/exercises');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveExercise = async (userId: string, exercise: ExerciseDef): Promise<void> => {
|
||||||
|
await api.post('/exercises', exercise);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLastSetForExercise = async (userId: string, exerciseId: string): Promise<WorkoutSet | undefined> => {
|
||||||
|
// This requires fetching sessions or a specific endpoint.
|
||||||
|
// For performance, we should probably have an endpoint for this.
|
||||||
|
// For now, let's fetch sessions and find it client side, or implement endpoint later.
|
||||||
|
// Given the async nature, we need to change the signature to Promise.
|
||||||
|
// The caller needs to await this.
|
||||||
|
const sessions = await 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 = async (userId: string): Promise<WorkoutPlan[]> => {
|
||||||
|
try {
|
||||||
|
return await api.get('/plans');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const savePlan = async (userId: string, plan: WorkoutPlan): Promise<void> => {
|
||||||
|
await api.post('/plans', plan);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deletePlan = async (userId: string, id: string): Promise<void> => {
|
||||||
|
await api.delete(`/plans/${id}`);
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user