Files
gymflow/components/Profile.tsx
2025-11-28 17:57:01 +02:00

532 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { User, Language, ExerciseDef, ExerciseType } from '../types';
import { createUser, changePassword, updateUserProfile, getCurrentUserProfile, getUsers, deleteUser, toggleBlockUser, adminResetPassword, getMe } from '../services/auth';
import { getExercises, saveExercise } from '../services/storage';
import { User as UserIcon, LogOut, Save, Shield, UserPlus, Lock, Calendar, Ruler, Scale, PersonStanding, Globe, ChevronDown, ChevronUp, Trash2, Ban, KeyRound, Dumbbell, Archive, ArchiveRestore, Pencil, Plus } from 'lucide-react';
import ExerciseModal from './ExerciseModal';
import { t } from '../services/i18n';
import Snackbar from './Snackbar';
interface ProfileProps {
user: User;
onLogout: () => void;
lang: Language;
onLanguageChange: (lang: Language) => void;
onUserUpdate?: (user: User) => void;
}
const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChange, onUserUpdate }) => {
// Profile Data
const [weight, setWeight] = useState<string>('');
const [height, setHeight] = useState<string>('');
const [birthDate, setBirthDate] = useState<string>('');
const [gender, setGender] = useState<string>('MALE');
// Admin: Create User
const [newUserEmail, setNewUserEmail] = useState('');
const [newUserPass, setNewUserPass] = useState('');
const [createMsg, setCreateMsg] = useState('');
// Admin: User List
const [showUserList, setShowUserList] = useState(false);
const [allUsers, setAllUsers] = useState<any[]>([]);
const [adminPassResetInput, setAdminPassResetInput] = useState<{ [key: string]: string }>({});
// Change Password
const [newPassword, setNewPassword] = useState('');
const [passMsg, setPassMsg] = useState('');
// Account Deletion
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Exercise Management
const [showExercises, setShowExercises] = useState(false);
const [exercises, setExercises] = useState<ExerciseDef[]>([]);
const [showArchived, setShowArchived] = useState(false);
const [editingExercise, setEditingExercise] = useState<ExerciseDef | null>(null);
const [isCreatingEx, setIsCreatingEx] = useState(false);
const [exerciseNameFilter, setExerciseNameFilter] = useState('');
const exerciseTypeLabels: Record<ExerciseType, string> = {
[ExerciseType.STRENGTH]: t('type_strength', lang),
[ExerciseType.BODYWEIGHT]: t('type_bodyweight', lang),
[ExerciseType.CARDIO]: t('type_cardio', lang),
[ExerciseType.STATIC]: t('type_static', lang),
[ExerciseType.HIGH_JUMP]: t('type_height', lang),
[ExerciseType.LONG_JUMP]: t('type_dist', lang),
[ExerciseType.PLYOMETRIC]: t('type_jump', lang),
};
useEffect(() => {
// Load profile data from user object (comes from /auth/me endpoint)
if (user.profile) {
if (user.profile.weight) setWeight(user.profile.weight.toString());
if (user.profile.height) setHeight(user.profile.height.toString());
if (user.profile.gender) setGender(user.profile.gender);
if (user.profile.birthDate) setBirthDate(new Date(user.profile.birthDate).toISOString().split('T')[0]);
}
if (user.role === 'ADMIN') {
refreshUserList();
}
refreshExercises();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user.id, user.role, JSON.stringify(user.profile)]);
const refreshUserList = () => {
setAllUsers(getUsers());
};
const refreshExercises = async () => {
const exercises = await getExercises(user.id);
setExercises(exercises);
};
// Snackbar State
const [snackbar, setSnackbar] = useState<{ isOpen: boolean; message: string; type: 'success' | 'error' | 'info' }>({
isOpen: false,
message: '',
type: 'info'
});
const showSnackbar = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
setSnackbar({ isOpen: true, message, type });
};
const handleSaveProfile = async () => {
const res = await updateUserProfile(user.id, {
weight: parseFloat(weight) || undefined,
height: parseFloat(height) || undefined,
gender: gender as any,
birthDate: birthDate ? new Date(birthDate).toISOString() : undefined,
language: lang
});
if (res.success) {
showSnackbar(t('profile_saved', lang) || 'Profile saved successfully', 'success');
// Refetch user data to update the profile in the app state
const userRes = await getMe();
if (userRes.success && userRes.user && onUserUpdate) {
onUserUpdate(userRes.user);
}
} else {
showSnackbar(res.error || 'Failed to save profile', 'error');
}
};
const handleChangePassword = async () => {
if (newPassword.length < 4) {
setPassMsg('Password too short');
return;
}
const res = await changePassword(user.id, newPassword);
if (res.success) {
setPassMsg('Password changed');
setNewPassword('');
} else {
setPassMsg(res.error || 'Error changing password');
}
};
const handleCreateUser = async () => {
const res = await createUser(newUserEmail, newUserPass);
if (res.success) {
setCreateMsg(`${t('user_created', lang)}: ${newUserEmail}`);
setNewUserEmail('');
setNewUserPass('');
refreshUserList();
} else {
setCreateMsg(res.error || 'Error');
}
};
const handleAdminDeleteUser = (uid: string) => {
if (confirm(t('delete_confirm', lang))) {
deleteUser(uid);
refreshUserList();
}
};
const handleAdminBlockUser = (uid: string, isBlocked: boolean) => {
toggleBlockUser(uid, isBlocked);
refreshUserList();
};
const handleAdminResetPass = (uid: string) => {
const pass = adminPassResetInput[uid];
if (pass && pass.length >= 4) {
adminResetPassword(uid, pass);
alert(t('pass_reset', lang));
setAdminPassResetInput({ ...adminPassResetInput, [uid]: '' });
}
};
const handleDeleteMyAccount = () => {
deleteUser(user.id);
onLogout();
};
// Exercise Management Handlers
const handleArchiveExercise = async (ex: ExerciseDef, archive: boolean) => {
const updated = { ...ex, isArchived: archive };
await saveExercise(user.id, updated);
await refreshExercises();
};
const handleSaveExerciseEdit = async () => {
if (editingExercise && editingExercise.name) {
await saveExercise(user.id, editingExercise);
setEditingExercise(null);
await refreshExercises();
}
};
const handleCreateExercise = async (newEx: ExerciseDef) => {
await saveExercise(user.id, newEx);
setIsCreatingEx(false);
await refreshExercises();
};
return (
<div className="h-full flex flex-col bg-surface">
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center justify-between z-10">
<h2 className="text-xl font-normal text-on-surface flex items-center gap-2">
<UserIcon size={20} />
{t('profile_title', lang)}
</h2>
<button onClick={onLogout} className="text-error flex items-center gap-1 text-sm font-medium hover:bg-error-container/10 px-3 py-1 rounded-full">
<LogOut size={16} /> {t('logout', lang)}
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6 pb-24">
{/* User Info Card */}
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
<div className="flex items-center gap-4 mb-6">
<div className="w-14 h-14 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xl font-bold">
{user.email[0].toUpperCase()}
</div>
<div>
<div className="text-lg font-medium text-on-surface">{user.email}</div>
<div className="text-xs text-on-surface-variant bg-surface-container-high px-2 py-1 rounded w-fit mt-1 flex items-center gap-1">
{user.role === 'ADMIN' && <Shield size={10} />}
{user.role}
</div>
</div>
</div>
<h3 className="text-sm font-bold text-primary mb-4">{t('personal_data', lang)}</h3>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Scale size={10} /> {t('weight_kg', lang)}</label>
<input
type="number"
step="0.1"
value={weight}
onChange={(e) => setWeight(e.target.value)}
className="w-full bg-transparent text-on-surface focus:outline-none"
/>
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Ruler size={10} /> {t('height', lang)}</label>
<input type="number" value={height} onChange={(e) => setHeight(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none" />
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Calendar size={10} /> {t('birth_date', lang)}</label>
<input type="date" value={birthDate} onChange={(e) => setBirthDate(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1" />
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><PersonStanding size={10} /> {t('gender', lang)}</label>
<select value={gender} onChange={(e) => setGender(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1 bg-surface-container-high">
<option value="MALE">{t('male', lang)}</option>
<option value="FEMALE">{t('female', lang)}</option>
<option value="OTHER">{t('other', lang)}</option>
</select>
</div>
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2 mb-4">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Globe size={10} /> {t('language', lang)}</label>
<select value={lang} onChange={(e) => onLanguageChange(e.target.value as Language)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1 bg-surface-container-high">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
<button onClick={handleSaveProfile} className="w-full py-2 rounded-full border border-outline text-primary text-sm font-medium hover:bg-primary-container/10 flex justify-center gap-2 items-center">
<Save size={16} /> {t('save_profile', lang)}
</button>
</div>
{/* EXERCISE MANAGER */}
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
<button
onClick={() => setShowExercises(!showExercises)}
className="w-full flex justify-between items-center text-sm font-bold text-primary"
>
<span className="flex items-center gap-2"><Dumbbell size={14} /> {t('manage_exercises', lang)}</span>
{showExercises ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{showExercises && (
<div className="mt-4 space-y-4">
<button
onClick={() => setIsCreatingEx(true)}
className="w-full py-2 border border-outline border-dashed rounded-lg text-sm text-on-surface-variant hover:bg-surface-container-high flex items-center justify-center gap-2"
>
<Plus size={16} /> {t('create_exercise', lang)}
</button>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant font-medium">{t('filter_by_name', lang) || 'Filter by name'}</label>
<input
type="text"
value={exerciseNameFilter}
onChange={(e) => setExerciseNameFilter(e.target.value)}
placeholder={t('type_to_filter', lang) || 'Type to filter...'}
className="w-full bg-transparent text-on-surface focus:outline-none"
/>
</div>
<div className="flex items-center justify-end gap-2">
<label className="text-xs text-on-surface-variant">{t('show_archived', lang)}</label>
<input
type="checkbox"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
className="accent-primary"
/>
</div>
<div className="space-y-2">
{exercises
.filter(e => showArchived || !e.isArchived)
.filter(e => e.name.toLowerCase().includes(exerciseNameFilter.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name))
.map(ex => (
<div key={ex.id} className={`p-3 rounded-lg flex justify-between items-center border border-outline-variant/20 ${ex.isArchived ? 'bg-surface-container-low opacity-60' : 'bg-surface-container-high'}`}>
<div className="overflow-hidden mr-2">
<div className="font-medium text-sm text-on-surface truncate">{ex.name}</div>
<div className="text-xs text-on-surface-variant">{exerciseTypeLabels[ex.type]}</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => setEditingExercise(ex)} className="p-2 text-on-surface-variant hover:text-primary hover:bg-white/5 rounded-full">
<Pencil size={16} />
</button>
<button
onClick={() => handleArchiveExercise(ex, !ex.isArchived)}
className={`p-2 rounded-full hover:bg-white/5 ${ex.isArchived ? 'text-primary' : 'text-on-surface-variant'}`}
title={ex.isArchived ? t('unarchive', lang) : t('archive', lang)}
>
{ex.isArchived ? <ArchiveRestore size={16} /> : <Archive size={16} />}
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Change Password */}
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
<h3 className="text-sm font-bold text-primary mb-4 flex items-center gap-2"><Lock size={14} /> {t('change_pass_btn', lang)}</h3>
<div className="flex gap-2">
<input
type="password"
placeholder={t('change_pass_new', lang)}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="flex-1 bg-surface-container-high border-b border-outline-variant px-3 py-2 text-on-surface focus:outline-none rounded-t-lg"
/>
<button onClick={handleChangePassword} className="bg-secondary-container text-on-secondary-container px-4 rounded-lg font-medium text-sm">OK</button>
</div>
{passMsg && <p className="text-xs text-primary mt-2">{passMsg}</p>}
</div>
{/* User Self Deletion (Not for Admin) */}
{user.role !== 'ADMIN' && (
<div className="bg-surface-container rounded-xl p-4 border border-error/30">
<h3 className="text-sm font-bold text-error mb-2 flex items-center gap-2"><Trash2 size={14} /> {t('delete_account', lang)}</h3>
{!showDeleteConfirm ? (
<button onClick={() => setShowDeleteConfirm(true)} className="text-error text-sm hover:underline">
{t('delete', lang)}
</button>
) : (
<div className="space-y-2">
<p className="text-xs text-error">{t('delete_account_confirm', lang)}</p>
<div className="flex gap-2">
<button onClick={() => setShowDeleteConfirm(false)} className="text-xs px-3 py-1 bg-surface-container-high rounded-full">{t('cancel', lang)}</button>
<button onClick={handleDeleteMyAccount} className="text-xs px-3 py-1 bg-error text-on-error rounded-full">{t('delete', lang)}</button>
</div>
</div>
)}
</div>
)}
{/* ADMIN AREA */}
{user.role === 'ADMIN' && (
<div className="bg-surface-container rounded-xl p-4 border border-primary/30 relative overflow-hidden">
<div className="absolute top-0 right-0 p-2 bg-primary/10 rounded-bl-xl">
<Shield size={16} className="text-primary" />
</div>
<h3 className="text-sm font-bold text-primary mb-4 flex items-center gap-2"><UserPlus size={14} /> {t('admin_area', lang)}</h3>
{/* Create User */}
<div className="space-y-3 mb-6">
<h4 className="text-xs font-medium text-on-surface-variant">{t('create_user', lang)}</h4>
<input
type="email"
placeholder="Email"
value={newUserEmail}
onChange={(e) => setNewUserEmail(e.target.value)}
className="w-full bg-surface-container-high border-b border-outline-variant px-3 py-2 text-on-surface focus:outline-none rounded-t-lg"
/>
<input
type="text"
placeholder={t('login_password', lang)}
value={newUserPass}
onChange={(e) => setNewUserPass(e.target.value)}
className="w-full bg-surface-container-high border-b border-outline-variant px-3 py-2 text-on-surface focus:outline-none rounded-t-lg"
/>
<button onClick={handleCreateUser} className="w-full py-2 bg-primary text-on-primary rounded-full text-sm font-medium">
{t('create_btn', lang)}
</button>
{createMsg && <p className="text-xs text-on-surface-variant text-center">{createMsg}</p>}
</div>
{/* User List */}
<div className="border-t border-outline-variant pt-4">
<button
onClick={() => setShowUserList(!showUserList)}
className="w-full flex justify-between items-center text-sm font-medium text-on-surface"
>
<span>{t('admin_users_list', lang)} ({allUsers.length})</span>
{showUserList ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{showUserList && (
<div className="mt-4 space-y-4">
{allUsers.map(u => (
<div key={u.id} className="bg-surface-container-high p-3 rounded-lg space-y-3">
<div className="flex justify-between items-center">
<div className="overflow-hidden">
<div className="font-medium text-sm text-on-surface truncate">{u.email}</div>
<div className="text-xs text-on-surface-variant flex gap-2">
<span>{u.role}</span>
{u.isBlocked && <span className="text-error font-bold flex items-center gap-1"><Ban size={10} /> {t('block', lang)}</span>}
</div>
</div>
<div className="flex gap-1">
{u.role !== 'ADMIN' && (
<>
<button
onClick={() => handleAdminBlockUser(u.id, !u.isBlocked)}
className={`p-2 rounded-full ${u.isBlocked ? 'bg-primary/20 text-primary' : 'text-on-surface-variant hover:bg-white/10'}`}
title={u.isBlocked ? t('unblock', lang) : t('block', lang)}
>
<Ban size={16} />
</button>
<button
onClick={() => handleAdminDeleteUser(u.id)}
className="p-2 text-on-surface-variant hover:text-error hover:bg-error/10 rounded-full"
title={t('delete', lang)}
>
<Trash2 size={16} />
</button>
</>
)}
</div>
</div>
{u.role !== 'ADMIN' && (
<div className="flex gap-2 items-center">
<div className="flex-1 flex items-center bg-surface-container rounded px-2 border border-outline-variant/20">
<KeyRound size={12} className="text-on-surface-variant mr-2" />
<input
type="text"
placeholder={t('change_pass_new', lang)}
className="bg-transparent text-xs py-2 w-full focus:outline-none text-on-surface"
value={adminPassResetInput[u.id] || ''}
onChange={(e) => setAdminPassResetInput({ ...adminPassResetInput, [u.id]: e.target.value })}
/>
</div>
<button
onClick={() => handleAdminResetPass(u.id)}
className="text-xs bg-secondary-container text-on-secondary-container px-3 py-2 rounded font-medium"
>
{t('reset_pass', lang)}
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Edit Exercise Modal */}
{editingExercise && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-surface-container w-full max-w-sm rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-xl font-normal text-on-surface mb-4">{t('edit', lang)}</h3>
<div className="space-y-4">
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant font-medium">{t('ex_name', lang)}</label>
<input
value={editingExercise.name}
onChange={(e) => setEditingExercise({ ...editingExercise, name: e.target.value })}
className="w-full bg-transparent text-on-surface focus:outline-none"
/>
</div>
{editingExercise.type === ExerciseType.BODYWEIGHT && (
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant font-medium">{t('body_weight_percent', lang)}</label>
<input
type="number"
value={editingExercise.bodyWeightPercentage || 100}
onChange={(e) => setEditingExercise({ ...editingExercise, bodyWeightPercentage: parseFloat(e.target.value) })}
className="w-full bg-transparent text-on-surface focus:outline-none"
/>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setEditingExercise(null)} className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
<button onClick={handleSaveExerciseEdit} className="px-4 py-2 rounded-full bg-primary text-on-primary font-medium">{t('save', lang)}</button>
</div>
</div>
</div>
</div>
)}
{/* Create Exercise Modal */}
{isCreatingEx && (
<ExerciseModal
isOpen={isCreatingEx}
onClose={() => setIsCreatingEx(false)}
onSave={handleCreateExercise}
lang={lang}
existingExercises={exercises}
/>
)}
</div>
<Snackbar
isOpen={snackbar.isOpen}
message={snackbar.message}
type={snackbar.type}
onClose={() => setSnackbar(prev => ({ ...prev, isOpen: false }))}
/>
</div>
);
};
export default Profile;