Critical Stability & Performance fixes. Excessive Log Set button gone on QIuck Log screen

This commit is contained in:
AG
2025-12-06 08:58:44 +02:00
parent 27afacee3f
commit 1c3e15516c
35 changed files with 48 additions and 26 deletions

624
src/components/Profile.tsx Normal file
View File

@@ -0,0 +1,624 @@
import React, { useState, useEffect } from 'react';
import { User, Language, ExerciseDef, ExerciseType, BodyWeightRecord } from '../types';
import { createUser, changePassword, updateUserProfile, getCurrentUserProfile, getUsers, deleteUser, toggleBlockUser, adminResetPassword, getMe } from '../services/auth';
import { getExercises, saveExercise } from '../services/storage';
import { getWeightHistory, logWeight } from '../services/weight';
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 FilledInput from './FilledInput';
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');
// Weight Tracker
const [weightHistory, setWeightHistory] = useState<BodyWeightRecord[]>([]);
const [todayWeight, setTodayWeight] = useState<string>('');
const [showWeightTracker, setShowWeightTracker] = useState(false);
// 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();
refreshWeightHistory();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user.id, user.role, JSON.stringify(user.profile)]);
const refreshWeightHistory = async () => {
const history = await getWeightHistory();
setWeightHistory(history);
// Check if we have a weight for today
const today = new Date().toISOString().split('T')[0];
const todayRecord = history.find(r => r.dateStr === today);
if (todayRecord) {
setTodayWeight(todayRecord.weight.toString());
}
};
const refreshUserList = async () => {
const res = await getUsers();
if (res.success && res.users) {
setAllUsers(res.users);
}
};
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 handleLogWeight = async () => {
if (!todayWeight) return;
const weightVal = parseFloat(todayWeight);
if (isNaN(weightVal)) return;
const res = await logWeight(weightVal);
if (res) {
showSnackbar('Weight logged successfully', 'success');
refreshWeightHistory();
// Also update the profile weight display if it's today
setWeight(todayWeight);
// And trigger user update to sync across app
const userRes = await getMe();
if (userRes.success && userRes.user && onUserUpdate) {
onUserUpdate(userRes.user);
}
} else {
showSnackbar('Failed to log weight', 'error');
}
};
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 = async (uid: string) => {
if (confirm(t('delete_confirm', lang))) {
await deleteUser(uid);
await refreshUserList();
}
};
const handleAdminBlockUser = async (uid: string, isBlocked: boolean) => {
await toggleBlockUser(uid, isBlocked);
await 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>
{/* WEIGHT TRACKER */}
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
<button
onClick={() => setShowWeightTracker(!showWeightTracker)}
className="w-full flex justify-between items-center text-sm font-bold text-primary"
>
<span className="flex items-center gap-2"><Scale size={14} /> Weight Tracker</span>
{showWeightTracker ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{showWeightTracker && (
<div className="mt-4 space-y-4">
<div className="flex gap-2 items-end">
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2 flex-1">
<label className="text-[10px] text-on-surface-variant font-medium">Today's Weight (kg)</label>
<input
type="number"
step="0.1"
value={todayWeight}
onChange={(e) => setTodayWeight(e.target.value)}
className="w-full bg-transparent text-on-surface focus:outline-none"
placeholder="Enter weight..."
/>
</div>
<button
onClick={handleLogWeight}
className="bg-primary text-on-primary px-4 py-3 rounded-lg font-medium text-sm mb-[1px]"
>
Log
</button>
</div>
<div className="space-y-2 max-h-60 overflow-y-auto">
<h4 className="text-xs font-medium text-on-surface-variant">History</h4>
{weightHistory.length === 0 ? (
<p className="text-xs text-on-surface-variant italic">No weight records yet.</p>
) : (
weightHistory.map(record => (
<div key={record.id} className="flex justify-between items-center p-3 bg-surface-container-high rounded-lg">
<span className="text-sm text-on-surface">{new Date(record.date).toLocaleDateString()}</span>
<span className="text-sm font-bold text-primary">{record.weight} kg</span>
</div>
))
)}
</div>
</div>
)}
</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>
<FilledInput
label={t('filter_by_name', lang) || 'Filter by name'}
value={exerciseNameFilter}
onChange={(e: any) => setExerciseNameFilter(e.target.value)}
icon={<i className="hidden" />} // No icon needed or maybe use Search icon? Profile doesn't import Search. I'll omit icon if optional.
type="text"
autoFocus={false}
/>
<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]}
{ex.isUnilateral && `, ${t('unilateral', lang)}`}
</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>
<FilledInput
label="Email"
value={newUserEmail}
onChange={(e) => setNewUserEmail(e.target.value)}
type="email"
/>
<FilledInput
label={t('login_password', lang)}
value={newUserPass}
onChange={(e) => setNewUserPass(e.target.value)}
type="text"
/>
<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-error text-center font-medium">{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;