1. Change Password fixed. 2. Personal Data implemented. 3. New alerts style. 4. Better dropdowns.

This commit is contained in:
AG
2025-11-19 22:52:32 +02:00
parent bb705c8a63
commit 8cc9ab29b7
14 changed files with 1266 additions and 632 deletions

16
App.tsx
View File

@@ -10,7 +10,7 @@ import Login from './components/Login';
import Profile from './components/Profile'; import Profile from './components/Profile';
import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types'; import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types';
import { getSessions, saveSession, deleteSession } from './services/storage'; import { getSessions, saveSession, deleteSession } from './services/storage';
import { getCurrentUserProfile } from './services/auth'; import { getCurrentUserProfile, getMe } from './services/auth';
import { getSystemLanguage } from './services/i18n'; import { getSystemLanguage } from './services/i18n';
function App() { function App() {
@@ -25,6 +25,20 @@ function App() {
useEffect(() => { useEffect(() => {
// Set initial language // Set initial language
setLanguage(getSystemLanguage()); setLanguage(getSystemLanguage());
// Restore session
const restoreSession = async () => {
const token = localStorage.getItem('token');
if (token) {
const res = await getMe();
if (res.success && res.user) {
setCurrentUser(res.user);
} else {
localStorage.removeItem('token');
}
}
};
restoreSession();
}, []); }, []);
useEffect(() => { useEffect(() => {

View File

@@ -36,11 +36,15 @@ const Login: React.FC<LoginProps> = ({ onLogin, language, onLanguageChange }) =>
} }
}; };
const handleChangePassword = () => { const handleChangePassword = async () => {
if (tempUser && newPassword.length >= 4) { if (tempUser && newPassword.length >= 4) {
changePassword(tempUser.id, newPassword); const res = await changePassword(tempUser.id, newPassword);
const updatedUser = { ...tempUser, isFirstLogin: false }; if (res.success) {
onLogin(updatedUser); const updatedUser = { ...tempUser, isFirstLogin: false };
onLogin(updatedUser);
} else {
setError(res.error || t('change_pass_error', language));
}
} else { } else {
setError(t('login_password_short', language)); setError(t('login_password_short', language));
} }

View File

@@ -24,9 +24,19 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
const [showExerciseSelector, setShowExerciseSelector] = useState(false); const [showExerciseSelector, setShowExerciseSelector] = useState(false);
useEffect(() => { useEffect(() => {
setPlans(getPlans(userId)); const loadData = async () => {
// Filter out archived exercises const fetchedPlans = await getPlans(userId);
setAvailableExercises(getExercises(userId).filter(e => !e.isArchived)); setPlans(fetchedPlans);
const fetchedExercises = await getExercises(userId);
// Filter out archived exercises
if (Array.isArray(fetchedExercises)) {
setAvailableExercises(fetchedExercises.filter(e => !e.isArchived));
} else {
setAvailableExercises([]);
}
};
loadData();
}, [userId]); }, [userId]);
const handleCreateNew = () => { const handleCreateNew = () => {
@@ -66,20 +76,20 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
}; };
const toggleWeighted = (stepId: string) => { const toggleWeighted = (stepId: string) => {
setSteps(steps.map(s => s.id === stepId ? { ...s, isWeighted: !s.isWeighted } : s)); setSteps(steps.map(s => s.id === stepId ? { ...s, isWeighted: !s.isWeighted } : s));
}; };
const removeStep = (stepId: string) => { const removeStep = (stepId: string) => {
setSteps(steps.filter(s => s.id !== stepId)); setSteps(steps.filter(s => s.id !== stepId));
}; };
const moveStep = (index: number, direction: 'up' | 'down') => { const moveStep = (index: number, direction: 'up' | 'down') => {
if (direction === 'up' && index === 0) return; if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === steps.length - 1) return; if (direction === 'down' && index === steps.length - 1) return;
const newSteps = [...steps]; const newSteps = [...steps];
const targetIndex = direction === 'up' ? index - 1 : index + 1; const targetIndex = direction === 'up' ? index - 1 : index + 1;
[newSteps[index], newSteps[targetIndex]] = [newSteps[targetIndex], newSteps[index]]; [newSteps[index], newSteps[targetIndex]] = [newSteps[targetIndex], newSteps[index]];
setSteps(newSteps); setSteps(newSteps);
}; };
if (isEditing) { if (isEditing) {
@@ -115,81 +125,81 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center px-2"> <div className="flex justify-between items-center px-2">
<label className="text-sm text-primary font-medium">{t('exercises_list', lang)}</label> <label className="text-sm text-primary font-medium">{t('exercises_list', lang)}</label>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{steps.map((step, idx) => ( {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 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"> <div className="flex flex-col gap-1">
{idx > 0 && ( {idx > 0 && (
<button onClick={() => moveStep(idx, 'up')} className="p-1 text-on-surface-variant hover:bg-white/5 rounded"> <button onClick={() => moveStep(idx, 'up')} className="p-1 text-on-surface-variant hover:bg-white/5 rounded">
<ArrowUp size={16} /> <ArrowUp size={16} />
</button> </button>
)} )}
{idx < steps.length - 1 && ( {idx < steps.length - 1 && (
<button onClick={() => moveStep(idx, 'down')} className="p-1 text-on-surface-variant hover:bg-white/5 rounded"> <button onClick={() => moveStep(idx, 'down')} className="p-1 text-on-surface-variant hover:bg-white/5 rounded">
<ArrowDown size={16} /> <ArrowDown size={16} />
</button> </button>
)} )}
</div> </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"> <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} {idx + 1}
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="text-base font-medium text-on-surface">{step.exerciseName}</div> <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"> <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'}`}> <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" />} {step.isWeighted && <Scale size={10} className="text-on-primary" />}
</div> </div>
<input <input
type="checkbox" type="checkbox"
checked={step.isWeighted} checked={step.isWeighted}
onChange={() => toggleWeighted(step.id)} onChange={() => toggleWeighted(step.id)}
className="hidden" className="hidden"
/> />
<span className="text-xs text-on-surface-variant">{t('weighted', lang)}</span> <span className="text-xs text-on-surface-variant">{t('weighted', lang)}</span>
</label> </label>
</div> </div>
<button onClick={() => removeStep(step.id)} className="text-on-surface-variant hover:text-error p-2"> <button onClick={() => removeStep(step.id)} className="text-on-surface-variant hover:text-error p-2">
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
))} ))}
</div> </div>
<button <button
onClick={() => setShowExerciseSelector(true)} 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" 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} /> <Plus size={20} />
{t('add_exercise', lang)} {t('add_exercise', lang)}
</button> </button>
</div> </div>
</div> </div>
{showExerciseSelector && ( {showExerciseSelector && (
<div className="absolute inset-0 bg-surface z-50 flex flex-col animate-in slide-in-from-bottom-full duration-200"> <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"> <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> <span className="font-medium text-on-surface">{t('select_exercise', lang)}</span>
<button onClick={() => setShowExerciseSelector(false)}><X /></button> <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>
<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> </div>
); );
@@ -203,50 +213,50 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
<div className="flex-1 p-4 overflow-y-auto space-y-4 pb-24"> <div className="flex-1 p-4 overflow-y-auto space-y-4 pb-24">
{plans.length === 0 ? ( {plans.length === 0 ? (
<div className="text-center text-on-surface-variant mt-20 flex flex-col items-center"> <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"> <div className="w-16 h-16 bg-surface-container-high rounded-full flex items-center justify-center mb-4">
<List size={32} /> <List size={32} />
</div>
<p className="text-lg">{t('plans_empty', lang)}</p>
</div> </div>
<p className="text-lg">{t('plans_empty', lang)}</p>
</div>
) : ( ) : (
plans.map(plan => ( 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 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"> <div className="flex justify-between items-start mb-2">
<h3 className="text-xl font-normal text-on-surface">{plan.name}</h3> <h3 className="text-xl font-normal text-on-surface">{plan.name}</h3>
<button <button
onClick={(e) => handleDelete(plan.id, e)} onClick={(e) => handleDelete(plan.id, e)}
className="text-on-surface-variant hover:text-error p-2 rounded-full hover:bg-white/5" className="text-on-surface-variant hover:text-error p-2 rounded-full hover:bg-white/5"
> >
<Trash2 size={20} /> <Trash2 size={20} />
</button> </button>
</div> </div>
<p className="text-on-surface-variant text-sm line-clamp-2 mb-4 min-h-[1.25rem]"> <p className="text-on-surface-variant text-sm line-clamp-2 mb-4 min-h-[1.25rem]">
{plan.description || t('prep_no_instructions', lang)} {plan.description || t('prep_no_instructions', lang)}
</p> </p>
<div className="flex items-center justify-between"> <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"> <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)} {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>
)) <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> </div>
{/* FAB */} {/* FAB */}
<button <button
onClick={handleCreateNew} 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" 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} /> <Plus size={28} />
</button> </button>
</div> </div>
); );

File diff suppressed because it is too large Load Diff

49
components/Snackbar.tsx Normal file
View File

@@ -0,0 +1,49 @@
import React, { useEffect } from 'react';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
export type SnackbarType = 'success' | 'error' | 'info';
interface SnackbarProps {
message: string;
type?: SnackbarType;
isOpen: boolean;
onClose: () => void;
duration?: number;
}
const Snackbar: React.FC<SnackbarProps> = ({ message, type = 'info', isOpen, onClose, duration = 3000 }) => {
useEffect(() => {
if (isOpen && duration > 0) {
const timer = setTimeout(() => {
onClose();
}, duration);
return () => clearTimeout(timer);
}
}, [isOpen, duration, onClose]);
if (!isOpen) return null;
const bgColors = {
success: 'bg-primary-container text-on-primary-container',
error: 'bg-error-container text-on-error-container',
info: 'bg-secondary-container text-on-secondary-container'
};
const icons = {
success: <CheckCircle size={20} />,
error: <AlertCircle size={20} />,
info: <Info size={20} />
};
return (
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 flex items-center gap-3 px-4 py-3 rounded-lg shadow-elevation-3 ${bgColors[type]} min-w-[300px] animate-in fade-in slide-in-from-bottom-4 duration-300`}>
<div className="shrink-0">{icons[type]}</div>
<p className="flex-1 text-sm font-medium">{message}</p>
<button onClick={onClose} className="p-1 hover:bg-black/10 rounded-full transition-colors">
<X size={16} />
</button>
</div>
);
};
export default Snackbar;

36
index.css Normal file
View File

@@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 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;
}
/* Dropdown Styles for Dark Mode */
select option {
background-color: #2B2930; /* surface-container-high */
color: #E6E0E9; /* on-surface */
padding: 8px;
}
/* Ensure select itself has correct background when closed */
select {
background-color: transparent;
color: inherit;
}

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import './index.css';
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
if (!rootElement) { if (!rootElement) {

379
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"concurrently": "^8.2.2",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0"
} }
@@ -255,6 +256,16 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.27.2", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -1496,6 +1507,146 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1523,6 +1674,34 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": "^14.13.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1674,6 +1853,23 @@
"node": ">= 12" "node": ">= 12"
} }
}, },
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1922,6 +2118,16 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob": { "node_modules/glob": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -1982,6 +2188,16 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -2107,6 +2323,13 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2412,6 +2635,16 @@
"redux": "^5.0.0" "redux": "^5.0.0"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/reselect": { "node_modules/reselect": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
@@ -2475,6 +2708,16 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2532,6 +2775,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -2554,6 +2810,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -2650,6 +2912,22 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tiny-invariant": { "node_modules/tiny-invariant": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -2673,6 +2951,23 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@@ -2967,12 +3262,96 @@
} }
} }
}, },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
} }
} }
} }

View File

@@ -6,18 +6,20 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"dev:full": "concurrently \"npm run dev\" \"npm run dev --prefix server\""
}, },
"dependencies": { "dependencies": {
"recharts": "^3.4.1",
"react-dom": "^19.2.0",
"@google/genai": "^1.30.0", "@google/genai": "^1.30.0",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"react": "^19.2.0" "react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.4.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"concurrently": "^8.2.2",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0"
} }

Binary file not shown.

View File

@@ -30,7 +30,11 @@ model UserProfile {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @unique userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
weight Float? weight Float?
height Float?
gender String?
birthDate DateTime?
language String? @default("en")
} }
model Exercise { model Exercise {

View File

@@ -7,19 +7,34 @@ const router = express.Router();
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'secret'; const JWT_SECRET = process.env.JWT_SECRET || 'secret';
// Get Current User
router.get('/me', async (req, res) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
const decoded = jwt.verify(token, JWT_SECRET) as any;
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
include: { profile: true }
});
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
const { password: _, ...userSafe } = user;
res.json({ success: true, user: userSafe });
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});
// Login // Login
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
try { try {
const { email, password } = req.body; 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({ const user = await prisma.user.findUnique({
where: { email }, where: { email },
include: { profile: true } include: { profile: true }
@@ -47,4 +62,73 @@ router.post('/login', async (req, res) => {
} }
}); });
// Change Password
router.post('/change-password', async (req, res) => {
console.log('DEBUG: change-password route hit');
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
const { userId, newPassword } = req.body;
// Verify token
const decoded = jwt.verify(token, JWT_SECRET) as any;
if (decoded.userId !== userId) {
return res.status(403).json({ error: 'Forbidden' });
}
if (!newPassword || newPassword.length < 4) {
return res.status(400).json({ error: 'Password too short' });
}
const hashed = await bcrypt.hash(newPassword, 10);
await prisma.user.update({
where: { id: userId },
data: {
password: hashed,
isFirstLogin: false
}
});
res.json({ success: true });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Update Profile
router.patch('/profile', async (req, res) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
const { userId, profile } = req.body;
// Verify token
const decoded = jwt.verify(token, JWT_SECRET) as any;
if (decoded.userId !== userId) {
return res.status(403).json({ error: 'Forbidden' });
}
// Update or create profile
await prisma.userProfile.upsert({
where: { userId: userId },
update: {
...profile
},
create: {
userId: userId,
...profile
}
});
res.json({ success: true });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
export default router; export default router;

View File

@@ -54,19 +54,39 @@ export const adminResetPassword = (userId: string, newPass: string) => {
// Admin only // Admin only
}; };
export const updateUserProfile = async (userId: string, profile: Partial<UserProfile>) => { export const updateUserProfile = async (userId: string, profile: Partial<UserProfile>): Promise<{ success: boolean; error?: string }> => {
// Not implemented in backend yet as a separate endpoint, try {
// but typically this would be a PATCH /users/me/profile const res = await api.patch('/auth/profile', { userId, profile });
// For now, let's skip or implement if needed. return res;
// The session save updates weight. } catch (e) {
return { success: false, error: 'Failed to update profile' };
}
}; };
export const changePassword = (userId: string, newPassword: string) => { export const changePassword = async (userId: string, newPassword: string) => {
// Not implemented try {
const res = await api.post('/auth/change-password', { userId, newPassword });
if (!res.success) {
console.error('Failed to change password:', res.error);
}
return res;
} catch (e) {
console.error(e);
return { success: false, error: 'Network error' };
}
}; };
export const getCurrentUserProfile = (userId: string): UserProfile | undefined => { export const getCurrentUserProfile = (userId: string): UserProfile | undefined => {
// This was synchronous. Now it needs to be async or fetched on load. // This was synchronous. Now it needs to be async or fetched on load.
// For now, we return undefined and let the app fetch it. // For now, we return undefined and let the app fetch it.
return undefined; return undefined;
} };
export const getMe = async (): Promise<{ success: boolean; user?: User; error?: string }> => {
try {
const res = await api.get('/auth/me');
return res;
} catch (e) {
return { success: false, error: 'Failed to fetch user' };
}
};

View File

@@ -29,6 +29,7 @@ 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',
change_pass_error: 'Error changing password',
passwords_mismatch: 'Passwords do not match', passwords_mismatch: 'Passwords do not match',
register_title: 'Create Account', register_title: 'Create Account',
confirm_password: 'Confirm Password', confirm_password: 'Confirm Password',
@@ -145,6 +146,7 @@ const translations = {
archive: 'Archive', archive: 'Archive',
unarchive: 'Unarchive', unarchive: 'Unarchive',
show_archived: 'Show Archived', show_archived: 'Show Archived',
profile_saved: 'Profile saved successfully',
}, },
ru: { ru: {
// Tabs // Tabs
@@ -167,6 +169,7 @@ const translations = {
change_pass_desc: 'Это ваш первый вход. Пожалуйста, установите новый пароль.', change_pass_desc: 'Это ваш первый вход. Пожалуйста, установите новый пароль.',
change_pass_new: 'Новый пароль', change_pass_new: 'Новый пароль',
change_pass_save: 'Сохранить и войти', change_pass_save: 'Сохранить и войти',
change_pass_error: 'Ошибка смены пароля',
passwords_mismatch: 'Пароли не совпадают', passwords_mismatch: 'Пароли не совпадают',
register_title: 'Регистрация', register_title: 'Регистрация',
confirm_password: 'Подтвердите пароль', confirm_password: 'Подтвердите пароль',
@@ -283,6 +286,7 @@ const translations = {
archive: 'Архив', archive: 'Архив',
unarchive: 'Вернуть', unarchive: 'Вернуть',
show_archived: 'Показать архивные', show_archived: 'Показать архивные',
profile_saved: 'Профиль успешно сохранен',
} }
}; };