Critical Stability & Performance fixes. Excessive Log Set button gone on QIuck Log screen
This commit is contained in:
156
src/components/AICoach.tsx
Normal file
156
src/components/AICoach.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Bot, User, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { createFitnessChat } from '../services/geminiService';
|
||||
import { WorkoutSession, Language, UserProfile, WorkoutPlan } from '../types';
|
||||
import { Chat, GenerateContentResponse } from '@google/genai';
|
||||
import { t } from '../services/i18n';
|
||||
import { generateId } from '../utils/uuid';
|
||||
|
||||
interface AICoachProps {
|
||||
history: WorkoutSession[];
|
||||
userProfile?: UserProfile;
|
||||
plans?: WorkoutPlan[];
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'model';
|
||||
text: string;
|
||||
}
|
||||
|
||||
const AICoach: React.FC<AICoachProps> = ({ history, userProfile, plans, lang }) => {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ id: 'intro', role: 'model', text: t('ai_intro', lang) }
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const chatSessionRef = useRef<Chat | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const chat = createFitnessChat(history, lang, userProfile, plans);
|
||||
if (chat) {
|
||||
chatSessionRef.current = chat;
|
||||
} else {
|
||||
setError(t('ai_error', lang));
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to initialize AI");
|
||||
}
|
||||
}, [history, lang, userProfile, plans]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !chatSessionRef.current || loading) return;
|
||||
|
||||
const userMsg: Message = { id: generateId(), role: 'user', text: input };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result: GenerateContentResponse = await chatSessionRef.current.sendMessage(userMsg.text);
|
||||
const text = result.text;
|
||||
|
||||
const aiMsg: Message = {
|
||||
id: generateId(),
|
||||
role: 'model',
|
||||
text: text || "Error generating response."
|
||||
};
|
||||
setMessages(prev => [...prev, aiMsg]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
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: generateId(), role: 'model', text: errorText }]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-6 text-center text-on-surface-variant">
|
||||
<AlertTriangle size={48} className="text-error mb-4" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface">
|
||||
{/* Header */}
|
||||
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center gap-3 z-10">
|
||||
<div className="w-10 h-10 rounded-full bg-secondary-container flex items-center justify-center">
|
||||
<Bot size={20} className="text-on-secondary-container" />
|
||||
</div>
|
||||
<h2 className="text-xl font-normal text-on-surface">{t('ai_expert', lang)}</h2>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] p-4 rounded-[20px] text-sm leading-relaxed shadow-sm ${msg.role === 'user'
|
||||
? 'bg-primary text-on-primary rounded-br-none'
|
||||
: 'bg-surface-container-high text-on-surface border border-outline-variant/20 rounded-bl-none'
|
||||
}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-surface-container-high px-4 py-3 rounded-[20px] rounded-bl-none flex gap-2 items-center text-on-surface-variant text-sm">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t('ai_typing', lang)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 bg-surface-container mt-auto">
|
||||
<div className="flex gap-2 items-center bg-surface-container-high rounded-full border border-outline-variant px-2 py-1">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-transparent border-none px-4 py-3 text-on-surface focus:outline-none placeholder-on-surface-variant"
|
||||
placeholder={t('ai_placeholder', lang)}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-on-primary hover:opacity-90 disabled:opacity-50 transition-opacity shrink-0"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AICoach;
|
||||
155
src/components/ExerciseModal.tsx
Normal file
155
src/components/ExerciseModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useState } from 'react';
|
||||
import { toTitleCase } from '../utils/text';
|
||||
import { X, Dumbbell, User, Flame, Timer as TimerIcon, ArrowUp, ArrowRight, Footprints, Ruler, Percent } from 'lucide-react';
|
||||
import { ExerciseDef, ExerciseType, Language } from '../types';
|
||||
import { t } from '../services/i18n';
|
||||
import { generateId } from '../utils/uuid';
|
||||
import FilledInput from './FilledInput';
|
||||
|
||||
interface ExerciseModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (exercise: ExerciseDef) => Promise<void> | void;
|
||||
lang: Language;
|
||||
existingExercises?: ExerciseDef[];
|
||||
}
|
||||
|
||||
const ExerciseModal: React.FC<ExerciseModalProps> = ({ isOpen, onClose, onSave, lang, existingExercises = [] }) => {
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newType, setNewType] = useState<ExerciseType>(ExerciseType.STRENGTH);
|
||||
const [newBwPercentage, setNewBwPercentage] = useState<string>('100');
|
||||
const [isUnilateral, setIsUnilateral] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
const handleCreateExercise = async () => {
|
||||
if (!newName.trim()) return;
|
||||
|
||||
// Check for duplicate name (case-insensitive)
|
||||
const trimmedName = newName.trim();
|
||||
const isDuplicate = existingExercises.some(
|
||||
ex => ex.name.toLowerCase() === trimmedName.toLowerCase()
|
||||
);
|
||||
|
||||
if (isDuplicate) {
|
||||
setError(t('exercise_name_exists', lang) || 'An exercise with this name already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
const newEx: ExerciseDef = {
|
||||
id: generateId(),
|
||||
name: trimmedName,
|
||||
type: newType,
|
||||
isUnilateral,
|
||||
...(newType === ExerciseType.BODYWEIGHT && { bodyWeightPercentage: parseFloat(newBwPercentage) || 100 })
|
||||
};
|
||||
await onSave(newEx);
|
||||
setNewName('');
|
||||
setNewType(ExerciseType.STRENGTH);
|
||||
setNewBwPercentage('100');
|
||||
setIsUnilateral(false);
|
||||
setError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-surface-container w-full max-w-sm rounded-[28px] p-6 shadow-elevation-3 animate-in slide-in-from-bottom-10 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-2xl font-normal text-on-surface">{t('create_exercise', lang)}</h3>
|
||||
<button onClick={onClose} className="p-2 bg-surface-container-high rounded-full hover:bg-outline-variant/20"><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<FilledInput
|
||||
label={t('ex_name', lang)}
|
||||
value={newName}
|
||||
onChange={(e: any) => {
|
||||
setNewName(e.target.value);
|
||||
setError(''); // Clear error when user types
|
||||
}}
|
||||
type="text"
|
||||
autoFocus
|
||||
autocapitalize="words"
|
||||
onBlur={() => setNewName(toTitleCase(newName))}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-error mt-2 ml-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell },
|
||||
{ id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User },
|
||||
{ id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame },
|
||||
{ id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon },
|
||||
{ id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp },
|
||||
{ id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler },
|
||||
{ id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints },
|
||||
].map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setNewType(type.id)}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${newType === type.id
|
||||
? 'bg-secondary-container text-on-secondary-container border-transparent'
|
||||
: 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant'
|
||||
}`}
|
||||
>
|
||||
<type.icon size={14} /> {type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newType === ExerciseType.BODYWEIGHT && (
|
||||
<FilledInput
|
||||
label={t('body_weight_percent', lang)}
|
||||
value={newBwPercentage}
|
||||
onChange={(e: any) => setNewBwPercentage(e.target.value)}
|
||||
icon={<Percent size={12} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isUnilateral"
|
||||
checked={isUnilateral}
|
||||
onChange={(e) => setIsUnilateral(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-2 border-outline bg-surface-container-high checked:bg-primary checked:border-primary cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="isUnilateral" className="text-sm text-on-surface cursor-pointer">
|
||||
{t('unilateral_exercise', lang) || 'Unilateral exercise (separate left/right tracking)'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
onClick={handleCreateExercise}
|
||||
className="px-8 py-3 bg-primary text-on-primary rounded-full font-medium shadow-elevation-1"
|
||||
>
|
||||
{t('create_btn', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExerciseModal;
|
||||
67
src/components/FilledInput.tsx
Normal file
67
src/components/FilledInput.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface FilledInputProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onClear?: () => void;
|
||||
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
type?: string;
|
||||
icon?: React.ReactNode;
|
||||
autoFocus?: boolean;
|
||||
step?: string;
|
||||
inputMode?: "search" | "text" | "email" | "tel" | "url" | "none" | "numeric" | "decimal";
|
||||
autocapitalize?: "off" | "none" | "on" | "sentences" | "words" | "characters";
|
||||
autoComplete?: string;
|
||||
rightElement?: React.ReactNode;
|
||||
}
|
||||
|
||||
const FilledInput: React.FC<FilledInputProps> = ({ label, value, onChange, onClear, onFocus, onBlur, type = "number", icon, autoFocus, step, inputMode, autocapitalize, autoComplete, rightElement }) => {
|
||||
const handleClear = () => {
|
||||
const syntheticEvent = {
|
||||
target: { value: '' }
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
onChange(syntheticEvent);
|
||||
if (onClear) onClear();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group bg-surface-container-high rounded-t-lg border-b border-outline-variant hover:bg-white/5 focus-within:border-primary transition-colors">
|
||||
<label className="absolute top-2 left-4 text-[10px] font-medium text-on-surface-variant flex items-center gap-1">
|
||||
{icon} {label}
|
||||
</label>
|
||||
<input
|
||||
type={type}
|
||||
step={step}
|
||||
inputMode={inputMode || (type === 'number' ? 'decimal' : 'text')}
|
||||
autoFocus={autoFocus}
|
||||
className={`w-full pt-6 pb-2 pl-4 bg-transparent text-2xl text-on-surface focus:outline-none placeholder-transparent ${rightElement ? 'pr-20' : 'pr-10'}`}
|
||||
placeholder="0"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
autoCapitalize={autocapitalize}
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
{value !== '' && value !== 0 && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className={`absolute top-1/2 -translate-y-1/2 p-2 text-on-surface-variant hover:text-on-surface rounded-full opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity ${rightElement ? 'right-12' : 'right-2'}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
{rightElement && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-10">
|
||||
{rightElement}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilledInput;
|
||||
448
src/components/History.tsx
Normal file
448
src/components/History.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, Clock, TrendingUp, Gauge, Pencil, Trash2, X, Save, ArrowRight, ArrowUp, Timer, Activity, Dumbbell, Percent } from 'lucide-react';
|
||||
import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types';
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
interface HistoryProps {
|
||||
sessions: WorkoutSession[];
|
||||
onUpdateSession?: (session: WorkoutSession) => void;
|
||||
onDeleteSession?: (sessionId: string) => void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const History: React.FC<HistoryProps> = ({ sessions, onUpdateSession, onDeleteSession, lang }) => {
|
||||
const [editingSession, setEditingSession] = useState<WorkoutSession | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [deletingSetInfo, setDeletingSetInfo] = useState<{ sessionId: string, setId: string } | null>(null);
|
||||
|
||||
|
||||
const calculateSessionWork = (session: WorkoutSession) => {
|
||||
const bw = session.userBodyWeight || 70;
|
||||
return session.sets.reduce((acc, set) => {
|
||||
let w = 0;
|
||||
if (set.type === ExerciseType.STRENGTH) {
|
||||
w = (set.weight || 0) * (set.reps || 0);
|
||||
}
|
||||
if (set.type === ExerciseType.BODYWEIGHT) {
|
||||
const percent = set.bodyWeightPercentage || 100;
|
||||
const effectiveBw = bw * (percent / 100);
|
||||
w = (effectiveBw + (set.weight || 0)) * (set.reps || 0);
|
||||
}
|
||||
return acc + Math.max(0, w);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const formatDateForInput = (timestamp: number) => {
|
||||
const d = new Date(timestamp);
|
||||
const pad = (n: number) => n < 10 ? '0' + n : n;
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
const parseDateFromInput = (value: string) => {
|
||||
return new Date(value).getTime();
|
||||
};
|
||||
|
||||
const formatDuration = (startTime: number, endTime?: number) => {
|
||||
if (!endTime || isNaN(endTime) || isNaN(startTime)) return '';
|
||||
const durationMs = endTime - startTime;
|
||||
if (durationMs < 0 || isNaN(durationMs)) return '';
|
||||
|
||||
const hours = Math.floor(durationMs / 3600000);
|
||||
const minutes = Math.floor((durationMs % 3600000) / 60000);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (minutes < 1) {
|
||||
return '<1m';
|
||||
}
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editingSession && onUpdateSession) {
|
||||
onUpdateSession(editingSession);
|
||||
setEditingSession(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSet = (setId: string, field: keyof WorkoutSet, value: number) => {
|
||||
if (!editingSession) return;
|
||||
const updatedSets = editingSession.sets.map(s =>
|
||||
s.id === setId ? { ...s, [field]: value } : s
|
||||
);
|
||||
setEditingSession({ ...editingSession, sets: updatedSets });
|
||||
};
|
||||
|
||||
const handleDeleteSet = (setId: string) => {
|
||||
if (!editingSession) return;
|
||||
setEditingSession({
|
||||
...editingSession,
|
||||
sets: editingSession.sets.filter(s => s.id !== setId)
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (deletingId && onDeleteSession) {
|
||||
onDeleteSession(deletingId);
|
||||
setDeletingId(null);
|
||||
} else if (deletingSetInfo && onUpdateSession) {
|
||||
// Find the session
|
||||
const session = sessions.find(s => s.id === deletingSetInfo.sessionId);
|
||||
if (session) {
|
||||
// Create updated session with the set removed
|
||||
const updatedSession = {
|
||||
...session,
|
||||
sets: session.sets.filter(s => s.id !== deletingSetInfo.setId)
|
||||
};
|
||||
onUpdateSession(updatedSession);
|
||||
}
|
||||
setDeletingSetInfo(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant p-8 text-center">
|
||||
<Clock size={48} className="mb-4 opacity-50" />
|
||||
<p>{t('history_empty', lang)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-surface">
|
||||
<div className="p-4 bg-surface-container shadow-elevation-1 z-10">
|
||||
<h2 className="text-2xl font-normal text-on-surface">{t('tab_history', lang)}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-20">
|
||||
{/* Regular Workout Sessions */}
|
||||
{sessions.filter(s => s.type === 'STANDARD').map((session) => {
|
||||
const totalWork = calculateSessionWork(session);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="bg-surface-container rounded-xl p-5 shadow-elevation-1 border border-outline-variant/20 cursor-pointer hover:bg-surface-container-high transition-colors"
|
||||
onClick={() => setEditingSession(JSON.parse(JSON.stringify(session)))}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="font-medium text-on-surface text-lg">
|
||||
{new Date(session.startTime).toISOString().split('T')[0]}
|
||||
</span>
|
||||
<span className="text-sm text-on-surface-variant">
|
||||
{new Date(session.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
{session.endTime && (
|
||||
<span className="text-sm text-on-surface-variant">
|
||||
{formatDuration(session.startTime, session.endTime)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-on-surface-variant">
|
||||
{session.planName || t('no_plan', lang)}
|
||||
</span>
|
||||
{session.userBodyWeight && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-surface-container-high text-on-surface text-xs">
|
||||
{session.userBodyWeight}kg
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-on-surface-variant flex items-center">
|
||||
<span className="inline-flex items-center">
|
||||
{t('sets_count', lang)}: <span className="text-on-surface font-medium ml-1">{session.sets.length}</span>
|
||||
</span>
|
||||
{totalWork > 0 && (
|
||||
<span className="ml-4 inline-flex items-center gap-1">
|
||||
<Gauge size={12} />
|
||||
{(totalWork / 1000).toFixed(1)}t
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSession(JSON.parse(JSON.stringify(session)));
|
||||
}}
|
||||
className="p-2 text-on-surface-variant hover:text-primary hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<Pencil size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeletingId(session.id);
|
||||
}}
|
||||
className="p-2 text-on-surface-variant hover:text-error hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Quick Log Sessions */}
|
||||
{sessions.filter(s => s.type === 'QUICK_LOG').length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-xl font-medium text-on-surface mb-4 px-2">{t('quick_log', lang)}</h3>
|
||||
{Object.entries(
|
||||
sessions
|
||||
.filter(s => s.type === 'QUICK_LOG')
|
||||
.reduce((groups: Record<string, WorkoutSession[]>, session) => {
|
||||
const date = new Date(session.startTime).toISOString().split('T')[0];
|
||||
if (!groups[date]) groups[date] = [];
|
||||
groups[date].push(session);
|
||||
return groups;
|
||||
}, {})
|
||||
)
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.map(([date, daySessions]) => (
|
||||
<div key={date} className="mb-4">
|
||||
<div className="text-sm text-on-surface-variant px-2 mb-2 font-medium">{date}</div>
|
||||
<div className="space-y-2">
|
||||
{daySessions.flatMap(session => session.sets).map((set, idx) => (
|
||||
<div
|
||||
key={set.id}
|
||||
className="bg-surface-container-low rounded-xl p-4 border border-outline-variant/10 flex justify-between items-center"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-on-surface">
|
||||
{set.exerciseName}
|
||||
{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}
|
||||
</div>
|
||||
<div className="text-sm text-on-surface-variant mt-1">
|
||||
{set.type === ExerciseType.STRENGTH && `${set.weight || 0}kg x ${set.reps || 0}`}
|
||||
{set.type === ExerciseType.BODYWEIGHT && `${set.weight ? `+${set.weight}kg` : 'BW'} x ${set.reps || 0}`}
|
||||
{set.type === ExerciseType.CARDIO && `${set.durationSeconds || 0}s ${set.distanceMeters ? `/ ${set.distanceMeters}m` : ''}`}
|
||||
{set.type === ExerciseType.STATIC && `${set.durationSeconds || 0}s`}
|
||||
{set.type === ExerciseType.HIGH_JUMP && `${set.height || 0}cm`}
|
||||
{set.type === ExerciseType.LONG_JUMP && `${set.distanceMeters || 0}m`}
|
||||
{set.type === ExerciseType.PLYOMETRIC && `x ${set.reps || 0}`}
|
||||
</div>
|
||||
<div className="text-xs text-on-surface-variant mt-1">
|
||||
{new Date(set.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Find the session this set belongs to and open edit mode
|
||||
const parentSession = daySessions.find(s => s.sets.some(st => st.id === set.id));
|
||||
if (parentSession) {
|
||||
setEditingSession(JSON.parse(JSON.stringify(parentSession)));
|
||||
}
|
||||
}}
|
||||
className="p-2 text-on-surface-variant hover:text-primary hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Find the session and set up for deletion
|
||||
const parentSession = daySessions.find(s => s.sets.some(st => st.id === set.id));
|
||||
if (parentSession) {
|
||||
setDeletingSetInfo({ sessionId: parentSession.id, setId: set.id });
|
||||
}
|
||||
}}
|
||||
className="p-2 text-on-surface-variant hover:text-error hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* DELETE CONFIRMATION DIALOG (MD3) */}
|
||||
{(deletingId || deletingSetInfo) && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-surface-container w-full max-w-xs rounded-[28px] p-6 shadow-elevation-3">
|
||||
<h3 className="text-xl font-normal text-on-surface mb-2">
|
||||
{deletingId ? t('delete_workout', lang) : t('delete_set', lang) || 'Delete Set'}
|
||||
</h3>
|
||||
<p className="text-sm text-on-surface-variant mb-8">
|
||||
{deletingId ? t('delete_confirm', lang) : t('delete_set_confirm', lang) || 'Are you sure you want to delete this set?'}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeletingId(null);
|
||||
setDeletingSetInfo(null);
|
||||
}}
|
||||
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
|
||||
>
|
||||
{t('cancel', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmDelete}
|
||||
className="px-4 py-2 rounded-full bg-error-container text-on-error-container font-medium"
|
||||
>
|
||||
{t('delete', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EDIT SESSION FULLSCREEN DIALOG */}
|
||||
{editingSession && (
|
||||
<div className="fixed inset-0 z-[60] bg-surface flex flex-col animate-in slide-in-from-bottom-10 duration-200">
|
||||
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container shadow-elevation-1">
|
||||
<button onClick={() => setEditingSession(null)} className="text-on-surface-variant hover:text-on-surface">
|
||||
<X />
|
||||
</button>
|
||||
<h2 className="text-lg font-medium text-on-surface">{t('edit', lang)}</h2>
|
||||
<button onClick={handleSaveEdit} className="text-primary font-medium flex items-center gap-2">
|
||||
{t('save', lang)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Meta Info */}
|
||||
<div className="bg-surface-container p-4 rounded-xl border border-outline-variant/20 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
|
||||
<label className="text-[10px] text-on-surface-variant font-bold block">{t('start_time', lang)}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formatDateForInput(editingSession.startTime)}
|
||||
onChange={(e) => setEditingSession({ ...editingSession, startTime: parseDateFromInput(e.target.value) })}
|
||||
className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
|
||||
<label className="text-[10px] text-on-surface-variant font-bold block">{t('end_time', lang)}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={editingSession.endTime ? formatDateForInput(editingSession.endTime) : ''}
|
||||
onChange={(e) => setEditingSession({ ...editingSession, endTime: parseDateFromInput(e.target.value) })}
|
||||
className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
|
||||
<label className="text-[10px] text-on-surface-variant font-bold block">{t('weight_kg', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingSession.userBodyWeight || ''}
|
||||
onChange={(e) => setEditingSession({ ...editingSession, userBodyWeight: parseFloat(e.target.value) })}
|
||||
className="w-full bg-transparent text-on-surface focus:outline-none text-lg mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-primary ml-1">{t('sets_count', lang)} ({editingSession.sets.length})</h3>
|
||||
{editingSession.sets.map((set, idx) => (
|
||||
<div key={set.id} className="bg-surface-container p-3 rounded-xl border border-outline-variant/20 flex flex-col gap-3 shadow-sm">
|
||||
<div className="flex justify-between items-center border-b border-outline-variant pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-secondary-container text-on-secondary-container text-xs font-bold flex items-center justify-center">{idx + 1}</span>
|
||||
<span className="font-medium text-on-surface text-sm">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase(), lang)}</span>}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteSet(set.id)}
|
||||
className="text-on-surface-variant hover:text-error p-1 rounded hover:bg-error-container/10 transition-colors"
|
||||
title={t('delete', lang)}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Dumbbell size={10} /> {t('weight_kg', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.weight === 0 ? '' : (set.weight ?? '')}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'weight', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.PLYOMETRIC) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Activity size={10} /> {t('reps', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.reps === 0 ? '' : (set.reps ?? '')}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'reps', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Percent size={10} /> {t('body_weight_percent', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.bodyWeightPercentage === 0 ? '' : (set.bodyWeightPercentage ?? 100)}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'bodyWeightPercentage', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(set.type === ExerciseType.CARDIO || set.type === ExerciseType.STATIC) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Timer size={10} /> {t('time_sec', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.durationSeconds === 0 ? '' : (set.durationSeconds ?? '')}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'durationSeconds', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(set.type === ExerciseType.CARDIO || set.type === ExerciseType.LONG_JUMP) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><ArrowRight size={10} /> {t('dist_m', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.distanceMeters === 0 ? '' : (set.distanceMeters ?? '')}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'distanceMeters', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(set.type === ExerciseType.HIGH_JUMP) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><ArrowUp size={10} /> {t('height_cm', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.height === 0 ? '' : (set.height ?? '')}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'height', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default History;
|
||||
140
src/components/Login.tsx
Normal file
140
src/components/Login.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useState } from 'react';
|
||||
import FilledInput from './FilledInput';
|
||||
import { login, changePassword } from '../services/auth';
|
||||
import { User, Language } from '../types';
|
||||
import { Dumbbell, ArrowRight, Lock, Mail, Globe } from 'lucide-react';
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: (user: User) => void;
|
||||
language: Language;
|
||||
onLanguageChange: (lang: Language) => void;
|
||||
}
|
||||
|
||||
const Login: React.FC<LoginProps> = ({ onLogin, language, onLanguageChange }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Force Password Change State
|
||||
const [needsChange, setNeedsChange] = useState(false);
|
||||
const [tempUser, setTempUser] = useState<User | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const res = await login(email, password);
|
||||
if (res.success && res.user) {
|
||||
if (res.user.isFirstLogin) {
|
||||
setTempUser(res.user);
|
||||
setNeedsChange(true);
|
||||
} else {
|
||||
onLogin(res.user);
|
||||
}
|
||||
} else {
|
||||
setError(res.error || t('login_error', language));
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (tempUser && newPassword.length >= 4) {
|
||||
const res = await changePassword(tempUser.id, newPassword);
|
||||
if (res.success) {
|
||||
const updatedUser = { ...tempUser, isFirstLogin: false };
|
||||
onLogin(updatedUser);
|
||||
} else {
|
||||
setError(res.error || t('change_pass_error', language));
|
||||
}
|
||||
} else {
|
||||
setError(t('login_password_short', language));
|
||||
}
|
||||
};
|
||||
|
||||
if (needsChange) {
|
||||
return (
|
||||
<div className="h-screen bg-surface flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm bg-surface-container p-8 rounded-[28px] shadow-elevation-2">
|
||||
<h2 className="text-2xl text-on-surface mb-2">{t('change_pass_title', language)}</h2>
|
||||
<p className="text-sm text-on-surface-variant mb-6">{t('change_pass_desc', language)}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FilledInput
|
||||
label={t('change_pass_new', language)}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
type="password"
|
||||
/>
|
||||
<button
|
||||
onClick={handleChangePassword}
|
||||
className="w-full py-3 bg-primary text-on-primary rounded-full font-medium"
|
||||
>
|
||||
{t('change_pass_save', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-surface flex flex-col items-center justify-center p-6 relative">
|
||||
|
||||
{/* Language Toggle */}
|
||||
<div className="absolute top-6 right-6 flex items-center bg-surface-container rounded-full px-3 py-1 gap-2 border border-outline-variant/20">
|
||||
<Globe size={16} className="text-on-surface-variant" />
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => onLanguageChange(e.target.value as Language)}
|
||||
className="bg-transparent text-sm text-on-surface focus:outline-none appearance-none"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center mb-12">
|
||||
<div className="w-20 h-20 bg-primary-container rounded-2xl flex items-center justify-center text-on-primary-container mb-4 shadow-elevation-2 transform rotate-3">
|
||||
<Dumbbell size={40} />
|
||||
</div>
|
||||
<h1 className="text-3xl font-normal text-on-surface tracking-tight">{t('login_title', language)}</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="w-full max-w-sm space-y-6">
|
||||
<div className="space-y-4">
|
||||
<FilledInput
|
||||
label={t('login_email', language)}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="email"
|
||||
icon={<Mail size={16} />}
|
||||
inputMode="email"
|
||||
/>
|
||||
|
||||
<FilledInput
|
||||
label={t('login_password', language)}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
icon={<Lock size={16} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-error text-sm text-center bg-error-container/10 p-2 rounded-lg">{error}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-4 bg-primary text-on-primary rounded-full font-medium text-lg shadow-elevation-1 flex items-center justify-center gap-2 hover:shadow-elevation-2 transition-all"
|
||||
>
|
||||
{t('login_btn', language)} <ArrowRight size={20} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-8 text-xs text-on-surface-variant text-center max-w-xs mx-auto">
|
||||
{t('login_contact_admin', language)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
82
src/components/Navbar.tsx
Normal file
82
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Dumbbell, History as HistoryIcon, BarChart2, MessageSquare, ClipboardList, User } from 'lucide-react';
|
||||
import { TabView, Language } from '../types';
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
interface NavbarProps {
|
||||
currentTab: TabView;
|
||||
onTabChange: (tab: TabView) => void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ currentTab, onTabChange, lang }) => {
|
||||
const navItems = [
|
||||
{ id: 'TRACK' as TabView, icon: Dumbbell, label: t('tab_tracker', lang) },
|
||||
{ id: 'PLANS' as TabView, icon: ClipboardList, label: t('tab_plans', lang) },
|
||||
{ id: 'HISTORY' as TabView, icon: HistoryIcon, label: t('tab_history', lang) },
|
||||
{ id: 'STATS' as TabView, icon: BarChart2, label: t('tab_stats', lang) },
|
||||
{ id: 'AI_COACH' as TabView, icon: MessageSquare, label: t('tab_ai', lang) },
|
||||
{ id: 'PROFILE' as TabView, icon: User, label: t('tab_profile', lang) },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* MOBILE: Bottom Navigation Bar (MD3) */}
|
||||
<div className="md:hidden fixed bottom-0 left-0 w-full bg-surface-container shadow-elevation-2 border-t border-white/5 pb-safe z-50 h-20">
|
||||
<div className="flex justify-evenly items-center h-full px-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = currentTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
className="flex flex-col items-center justify-center w-full h-full gap-1 group min-w-0"
|
||||
>
|
||||
<div className={`px-4 py-1 rounded-full transition-all duration-200 ${
|
||||
isActive ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant group-hover:bg-surface-container-high'
|
||||
}`}>
|
||||
<item.icon size={22} strokeWidth={isActive ? 2.5 : 2} />
|
||||
</div>
|
||||
<span className={`text-[10px] font-medium transition-colors truncate w-full text-center ${
|
||||
isActive ? 'text-on-surface' : 'text-on-surface-variant'
|
||||
}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DESKTOP: Navigation Rail (MD3) */}
|
||||
<div className="hidden md:flex flex-col w-20 h-full bg-surface-container border-r border-outline-variant items-center py-8 gap-8 z-50">
|
||||
<div className="flex flex-col gap-6 w-full px-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = currentTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
className="flex flex-col items-center gap-1 group w-full"
|
||||
>
|
||||
<div className={`w-14 h-8 rounded-full flex items-center justify-center transition-colors duration-200 ${
|
||||
isActive ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant group-hover:bg-surface-container-high'
|
||||
}`}>
|
||||
<item.icon size={24} />
|
||||
</div>
|
||||
<span className={`text-[11px] font-medium text-center ${
|
||||
isActive ? 'text-on-surface' : 'text-on-surface-variant'
|
||||
}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
417
src/components/Plans.tsx
Normal file
417
src/components/Plans.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Scale, Edit2, User, Flame, Timer as TimerIcon, Ruler, Footprints, Activity, Percent, CheckCircle, GripVertical } from 'lucide-react';
|
||||
import { WorkoutPlan, ExerciseDef, PlannedSet, Language, ExerciseType } from '../types';
|
||||
import { getPlans, savePlan, deletePlan, getExercises, saveExercise } from '../services/storage';
|
||||
import { t } from '../services/i18n';
|
||||
import { generateId } from '../utils/uuid';
|
||||
|
||||
import FilledInput from './FilledInput';
|
||||
import { toTitleCase } from '../utils/text';
|
||||
|
||||
interface PlansProps {
|
||||
userId: string;
|
||||
onStartPlan: (plan: WorkoutPlan) => void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [steps, setSteps] = useState<PlannedSet[]>([]);
|
||||
|
||||
const [availableExercises, setAvailableExercises] = useState<ExerciseDef[]>([]);
|
||||
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
|
||||
|
||||
// Drag and Drop Refs
|
||||
const dragItem = React.useRef<number | null>(null);
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
|
||||
// Create Exercise State
|
||||
const [isCreatingExercise, setIsCreatingExercise] = useState(false);
|
||||
const [newExName, setNewExName] = useState('');
|
||||
const [newExType, setNewExType] = useState<ExerciseType>(ExerciseType.STRENGTH);
|
||||
const [newExBwPercentage, setNewExBwPercentage] = useState<string>('100');
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const fetchedPlans = await getPlans(userId);
|
||||
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]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setEditId(generateId());
|
||||
setName('');
|
||||
setDescription('');
|
||||
setSteps([]);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleEdit = (plan: WorkoutPlan) => {
|
||||
setEditId(plan.id);
|
||||
setName(plan.name);
|
||||
setDescription(plan.description || '');
|
||||
setSteps(plan.steps);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim() || !editId) return;
|
||||
const newPlan: WorkoutPlan = { id: editId, name, description, steps };
|
||||
await savePlan(userId, newPlan);
|
||||
const updated = await getPlans(userId);
|
||||
setPlans(updated);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (confirm(t('delete_confirm', lang))) {
|
||||
await deletePlan(userId, id);
|
||||
const updated = await getPlans(userId);
|
||||
setPlans(updated);
|
||||
}
|
||||
};
|
||||
|
||||
const addStep = (ex: ExerciseDef) => {
|
||||
const newStep: PlannedSet = {
|
||||
id: generateId(),
|
||||
exerciseId: ex.id,
|
||||
exerciseName: ex.name,
|
||||
exerciseType: ex.type,
|
||||
isWeighted: false
|
||||
};
|
||||
setSteps([...steps, newStep]);
|
||||
setShowExerciseSelector(false);
|
||||
};
|
||||
|
||||
const handleCreateExercise = async () => {
|
||||
if (!newExName.trim()) return;
|
||||
const newEx: ExerciseDef = {
|
||||
id: generateId(),
|
||||
name: newExName.trim(),
|
||||
type: newExType,
|
||||
...(newExType === ExerciseType.BODYWEIGHT && { bodyWeightPercentage: parseFloat(newExBwPercentage) || 100 })
|
||||
};
|
||||
await saveExercise(userId, newEx);
|
||||
const exList = await getExercises(userId);
|
||||
setAvailableExercises(exList.filter(e => !e.isArchived));
|
||||
|
||||
// Automatically add the new exercise to the plan
|
||||
addStep(newEx);
|
||||
|
||||
setNewExName('');
|
||||
setNewExType(ExerciseType.STRENGTH);
|
||||
setNewExBwPercentage('100');
|
||||
setIsCreatingExercise(false);
|
||||
};
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
const toggleWeighted = (stepId: string) => {
|
||||
setSteps(steps.map(s => s.id === stepId ? { ...s, isWeighted: !s.isWeighted } : s));
|
||||
};
|
||||
|
||||
const removeStep = (stepId: string) => {
|
||||
setSteps(steps.filter(s => s.id !== stepId));
|
||||
};
|
||||
|
||||
const onDragStart = (index: number) => {
|
||||
dragItem.current = index;
|
||||
setDraggingIndex(index);
|
||||
};
|
||||
|
||||
const onDragEnter = (index: number) => {
|
||||
if (dragItem.current === null) return;
|
||||
if (dragItem.current === index) return;
|
||||
|
||||
const newSteps = [...steps];
|
||||
const draggedItemContent = newSteps.splice(dragItem.current, 1)[0];
|
||||
newSteps.splice(index, 0, draggedItemContent);
|
||||
|
||||
setSteps(newSteps);
|
||||
dragItem.current = index;
|
||||
setDraggingIndex(index);
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragItem.current = null;
|
||||
setDraggingIndex(null);
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-surface">
|
||||
<div className="px-4 py-3 bg-surface-container border-b border-outline-variant flex justify-between items-center">
|
||||
<button onClick={() => setIsEditing(false)} className="p-2 text-on-surface-variant hover:bg-white/5 rounded-full"><X /></button>
|
||||
<h2 className="text-title-medium font-medium text-on-surface">{t('plan_editor', lang)}</h2>
|
||||
<button onClick={handleSave} className="p-2 text-primary font-medium">
|
||||
{t('save', lang)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant font-medium">{t('ex_name', lang)}</label>
|
||||
<input
|
||||
className="w-full bg-transparent text-xl text-on-surface focus:outline-none pt-1 pb-2"
|
||||
placeholder={t('plan_name_ph', lang)}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoCapitalize="words"
|
||||
onBlur={() => setName(toTitleCase(name))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant font-medium">{t('prep_title', lang)}</label>
|
||||
<textarea
|
||||
className="w-full bg-transparent text-base text-on-surface focus:outline-none pt-1 pb-2 min-h-[80px]"
|
||||
placeholder={t('plan_desc_ph', lang)}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center px-2">
|
||||
<label className="text-sm text-primary font-medium">{t('exercises_list', lang)}</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`bg-surface-container rounded-xl p-3 flex items-center gap-3 shadow-elevation-1 cursor-move transition-all hover:bg-surface-container-high ${draggingIndex === idx ? 'opacity-50 ring-2 ring-primary bg-surface-container-high' : ''}`}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(idx)}
|
||||
onDragEnter={() => onDragEnter(idx)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div className={`text-on-surface-variant p-1 ${draggingIndex === idx ? 'cursor-grabbing' : 'cursor-grab'}`}>
|
||||
<GripVertical size={20} />
|
||||
</div>
|
||||
|
||||
<div className="w-8 h-8 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xs font-bold shrink-0">
|
||||
{idx + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium text-on-surface">{step.exerciseName}</div>
|
||||
<label className="flex items-center gap-2 mt-1 cursor-pointer w-fit">
|
||||
<div className={`w-4 h-4 border rounded flex items-center justify-center ${step.isWeighted ? 'bg-primary border-primary' : 'border-outline'}`}>
|
||||
{step.isWeighted && <Scale size={10} className="text-on-primary" />}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={step.isWeighted}
|
||||
onChange={() => toggleWeighted(step.id)}
|
||||
className="hidden"
|
||||
/>
|
||||
<span className="text-xs text-on-surface-variant">{t('weighted', lang)}</span>
|
||||
</label>
|
||||
</div>
|
||||
<button onClick={() => removeStep(step.id)} className="text-on-surface-variant hover:text-error p-2">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowExerciseSelector(true)}
|
||||
className="w-full py-4 rounded-full border border-outline text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary-container/10 transition-all"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{t('add_exercise', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showExerciseSelector && (
|
||||
<div className="fixed inset-0 bg-surface z-50 flex flex-col animate-in slide-in-from-bottom-full duration-200">
|
||||
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container">
|
||||
<span className="font-medium text-on-surface">{t('select_exercise', lang)}</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setIsCreatingExercise(true)} className="p-2 text-primary hover:bg-primary-container/20 rounded-full">
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
<button onClick={() => setShowExerciseSelector(false)}><X /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{availableExercises
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.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>
|
||||
|
||||
{isCreatingExercise && (
|
||||
<div className="fixed inset-0 bg-surface z-[60] 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">
|
||||
<h3 className="text-title-medium font-medium text-on-surface">{t('create_exercise', lang)}</h3>
|
||||
<button onClick={() => setIsCreatingExercise(false)} className="p-2 text-on-surface-variant hover:bg-white/5 rounded-full"><X /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-6 overflow-y-auto flex-1">
|
||||
<FilledInput
|
||||
label={t('ex_name', lang)}
|
||||
value={newExName}
|
||||
onChange={(e: any) => setNewExName(e.target.value)}
|
||||
type="text"
|
||||
autoFocus
|
||||
autocapitalize="words"
|
||||
onBlur={() => setNewExName(toTitleCase(newExName))}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell },
|
||||
{ id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User },
|
||||
{ id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame },
|
||||
{ id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon },
|
||||
{ id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp },
|
||||
{ id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler },
|
||||
{ id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints },
|
||||
].map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setNewExType(type.id)}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${newExType === type.id
|
||||
? 'bg-secondary-container text-on-secondary-container border-transparent'
|
||||
: 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant'
|
||||
}`}
|
||||
>
|
||||
<type.icon size={14} /> {type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newExType === ExerciseType.BODYWEIGHT && (
|
||||
<FilledInput
|
||||
label={t('body_weight_percent', lang)}
|
||||
value={newExBwPercentage}
|
||||
onChange={(e: any) => setNewExBwPercentage(e.target.value)}
|
||||
icon={<Percent size={12} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
onClick={handleCreateExercise}
|
||||
className="w-full h-14 bg-primary text-on-primary rounded-full font-medium shadow-elevation-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
<CheckCircle size={20} />
|
||||
{t('create_btn', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-surface relative">
|
||||
<div className="p-4 bg-surface-container shadow-elevation-1 z-10">
|
||||
<h2 className="text-2xl font-normal text-on-surface">{t('my_plans', lang)}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4 overflow-y-auto space-y-4 pb-24">
|
||||
{plans.length === 0 ? (
|
||||
<div className="text-center text-on-surface-variant mt-20 flex flex-col items-center">
|
||||
<div className="w-16 h-16 bg-surface-container-high rounded-full flex items-center justify-center mb-4">
|
||||
<List size={32} />
|
||||
</div>
|
||||
<p className="text-lg">{t('plans_empty', lang)}</p>
|
||||
</div>
|
||||
) : (
|
||||
plans.map(plan => (
|
||||
<div key={plan.id} className="bg-surface-container rounded-xl p-4 shadow-elevation-1 border border-outline-variant/20 relative overflow-hidden">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="text-xl font-normal text-on-surface">{plan.name}</h3>
|
||||
<button
|
||||
onClick={(e) => handleDelete(plan.id, e)}
|
||||
className="text-on-surface-variant hover:text-error p-2 rounded-full hover:bg-white/5"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute top-4 right-14">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(plan); }}
|
||||
className="text-on-surface-variant hover:text-primary p-2 rounded-full hover:bg-white/5"
|
||||
>
|
||||
<Edit2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-on-surface-variant text-sm line-clamp-2 mb-4 min-h-[1.25rem]">
|
||||
{plan.description || t('prep_no_instructions', lang)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-primary bg-primary-container/20 px-3 py-1 rounded-full">
|
||||
{plan.steps.length} {t('exercises_count', lang)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onStartPlan(plan)}
|
||||
className="flex items-center gap-2 bg-primary text-on-primary px-5 py-2 rounded-full text-sm font-medium hover:shadow-elevation-2 transition-all"
|
||||
>
|
||||
<PlayCircle size={18} />
|
||||
{t('start', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* FAB */}
|
||||
<button
|
||||
onClick={handleCreateNew}
|
||||
className="absolute bottom-6 right-6 w-14 h-14 bg-primary-container text-on-primary-container rounded-[16px] shadow-elevation-3 flex items-center justify-center hover:bg-primary hover:text-on-primary transition-colors z-20"
|
||||
>
|
||||
<Plus size={28} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Plans;
|
||||
624
src/components/Profile.tsx
Normal file
624
src/components/Profile.tsx
Normal 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;
|
||||
49
src/components/Snackbar.tsx
Normal file
49
src/components/Snackbar.tsx
Normal 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;
|
||||
144
src/components/Stats.tsx
Normal file
144
src/components/Stats.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { WorkoutSession, ExerciseType, Language, BodyWeightRecord } from '../types';
|
||||
import { getWeightHistory } from '../services/weight';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
interface StatsProps {
|
||||
sessions: WorkoutSession[];
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const Stats: React.FC<StatsProps> = ({ sessions, lang }) => {
|
||||
const [weightRecords, setWeightRecords] = useState<BodyWeightRecord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWeights = async () => {
|
||||
const records = await getWeightHistory();
|
||||
setWeightRecords(records);
|
||||
};
|
||||
fetchWeights();
|
||||
}, []);
|
||||
|
||||
const volumeData = useMemo(() => {
|
||||
const data = [...sessions].reverse().map(session => {
|
||||
const sessionWeight = session.userBodyWeight || 70;
|
||||
const work = session.sets.reduce((acc, set) => {
|
||||
let setWork = 0;
|
||||
const reps = set.reps || 0;
|
||||
const weight = set.weight || 0;
|
||||
if (set.type === ExerciseType.STRENGTH) {
|
||||
setWork = weight * reps;
|
||||
} else if (set.type === ExerciseType.BODYWEIGHT) {
|
||||
const percentage = set.bodyWeightPercentage || 100;
|
||||
const effectiveBw = sessionWeight * (percentage / 100);
|
||||
setWork = (effectiveBw + weight) * reps;
|
||||
} else if (set.type === ExerciseType.STATIC) {
|
||||
setWork = 0;
|
||||
}
|
||||
return acc + Math.max(0, setWork);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
|
||||
work: Math.round(work)
|
||||
};
|
||||
}).filter(d => d.work > 0);
|
||||
return data;
|
||||
}, [sessions, lang]);
|
||||
|
||||
const setsData = useMemo(() => {
|
||||
return [...sessions].reverse().map(session => ({
|
||||
date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
|
||||
sets: session.sets.length
|
||||
}));
|
||||
}, [sessions, lang]);
|
||||
|
||||
const weightData = useMemo(() => {
|
||||
return [...weightRecords].reverse().map(record => ({
|
||||
date: new Date(record.date).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
|
||||
weight: record.weight
|
||||
}));
|
||||
}, [weightRecords, lang]);
|
||||
|
||||
if (sessions.length < 2) {
|
||||
return (
|
||||
<div className="p-8 text-center text-on-surface-variant flex flex-col items-center justify-center h-full">
|
||||
<p>{t('not_enough_data', lang)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 space-y-6 pb-24 bg-surface">
|
||||
<h2 className="text-3xl font-normal text-on-surface mb-2 pl-2">{t('progress', lang)}</h2>
|
||||
|
||||
{/* Volume Chart */}
|
||||
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
|
||||
<div className="flex justify-between items-end mb-6">
|
||||
<div>
|
||||
<h3 className="text-title-medium font-medium text-on-surface">{t('volume_title', lang)}</h3>
|
||||
<p className="text-xs text-on-surface-variant mt-1">{t('volume_subtitle', lang)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-64 min-h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height={256}>
|
||||
<LineChart data={volumeData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
|
||||
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
|
||||
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(val) => `${(val / 1000).toFixed(1)}k`} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
|
||||
itemStyle={{ color: '#D0BCFF' }}
|
||||
formatter={(val: number) => [`${val.toLocaleString()} kg`, t('volume_title', lang)]}
|
||||
/>
|
||||
<Line type="monotone" dataKey="work" stroke="#D0BCFF" strokeWidth={3} dot={{ r: 4, fill: '#D0BCFF' }} activeDot={{ r: 6 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sets Chart */}
|
||||
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
|
||||
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sets_title', lang)}</h3>
|
||||
<div className="h-64 min-h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height={256}>
|
||||
<BarChart data={setsData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
|
||||
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
|
||||
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
|
||||
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
|
||||
/>
|
||||
<Bar dataKey="sets" fill="#CCC2DC" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body Weight Chart */}
|
||||
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
|
||||
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('weight_title', lang)}</h3>
|
||||
<div className="h-64 min-h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height={256}>
|
||||
<LineChart data={weightData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
|
||||
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
|
||||
<YAxis domain={['auto', 'auto']} stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
|
||||
itemStyle={{ color: '#6EE7B7' }}
|
||||
formatter={(val: number) => [`${val} kg`, t('weight_kg', lang)]}
|
||||
/>
|
||||
<Line type="monotone" dataKey="weight" stroke="#6EE7B7" strokeWidth={3} dot={{ r: 4, fill: '#6EE7B7' }} activeDot={{ r: 6 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Stats;
|
||||
383
src/components/Tracker/ActiveSessionView.tsx
Normal file
383
src/components/Tracker/ActiveSessionView.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import React from 'react';
|
||||
import { MoreVertical, X, CheckSquare, ChevronUp, ChevronDown, Scale, Dumbbell, Plus, Activity, Timer as TimerIcon, ArrowRight, ArrowUp, CheckCircle, Edit, Trash2 } from 'lucide-react';
|
||||
import { ExerciseType, Language, WorkoutSet } from '../../types';
|
||||
import { t } from '../../services/i18n';
|
||||
import FilledInput from '../FilledInput';
|
||||
import ExerciseModal from '../ExerciseModal';
|
||||
import { useTracker } from './useTracker';
|
||||
import SetLogger from './SetLogger';
|
||||
|
||||
interface ActiveSessionViewProps {
|
||||
tracker: ReturnType<typeof useTracker>;
|
||||
activeSession: any; // Using any to avoid strict type issues with the complex session object for now, but ideally should be WorkoutSession
|
||||
lang: Language;
|
||||
onSessionEnd: () => void;
|
||||
onSessionQuit: () => void;
|
||||
onRemoveSet: (setId: string) => void;
|
||||
}
|
||||
|
||||
const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSession, lang, onSessionEnd, onSessionQuit, onRemoveSet }) => {
|
||||
const {
|
||||
elapsedTime,
|
||||
showFinishConfirm,
|
||||
setShowFinishConfirm,
|
||||
showQuitConfirm,
|
||||
setShowQuitConfirm,
|
||||
showMenu,
|
||||
setShowMenu,
|
||||
activePlan, // This comes from useTracker props but we might need to pass it explicitly if not in hook return
|
||||
currentStepIndex,
|
||||
showPlanList,
|
||||
setShowPlanList,
|
||||
jumpToStep,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
setShowSuggestions,
|
||||
showSuggestions,
|
||||
filteredExercises,
|
||||
setSelectedExercise,
|
||||
selectedExercise,
|
||||
weight,
|
||||
setWeight,
|
||||
reps,
|
||||
setReps,
|
||||
duration,
|
||||
setDuration,
|
||||
distance,
|
||||
setDistance,
|
||||
height,
|
||||
setHeight,
|
||||
handleAddSet,
|
||||
editingSetId,
|
||||
editWeight,
|
||||
setEditWeight,
|
||||
editReps,
|
||||
setEditReps,
|
||||
editDuration,
|
||||
setEditDuration,
|
||||
editDistance,
|
||||
setEditDistance,
|
||||
editHeight,
|
||||
setEditHeight,
|
||||
handleCancelEdit,
|
||||
handleSaveEdit,
|
||||
handleEditSet,
|
||||
isCreating,
|
||||
setIsCreating,
|
||||
handleCreateExercise,
|
||||
exercises
|
||||
} = tracker;
|
||||
|
||||
|
||||
|
||||
const isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-full overflow-hidden relative bg-surface">
|
||||
<div className="px-4 py-3 bg-surface-container shadow-elevation-1 z-20 flex justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-title-medium text-on-surface flex items-center gap-2 font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-error animate-pulse" />
|
||||
{activePlan ? activePlan.name : t('free_workout', lang)}
|
||||
</h2>
|
||||
<span className="text-xs text-on-surface-variant font-mono mt-0.5 flex items-center gap-2">
|
||||
<span className="bg-surface-container-high px-1.5 py-0.5 rounded text-on-surface font-bold">{elapsedTime}</span>
|
||||
{activeSession.userBodyWeight ? ` • ${activeSession.userBodyWeight}kg` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<button
|
||||
onClick={() => setShowFinishConfirm(true)}
|
||||
className="px-5 py-2 rounded-full bg-error-container text-on-error-container text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('finish', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-2 rounded-full bg-surface-container-high text-on-surface hover:bg-surface-container-highest transition-colors"
|
||||
>
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-30"
|
||||
onClick={() => setShowMenu(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-2 bg-surface-container rounded-xl shadow-elevation-3 overflow-hidden z-40 min-w-[200px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
setShowQuitConfirm(true);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-error hover:bg-error-container/20 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<X size={18} />
|
||||
{t('quit_no_save', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activePlan && (
|
||||
<div className="bg-surface-container-low border-b border-outline-variant">
|
||||
<button
|
||||
onClick={() => setShowPlanList(!showPlanList)}
|
||||
className="w-full px-4 py-3 flex justify-between items-center"
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
{isPlanFinished ? (
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<CheckSquare size={18} />
|
||||
<span className="font-bold">{t('plan_completed', lang)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[10px] text-primary font-medium tracking-wider">{t('step', lang)} {currentStepIndex + 1} {t('of', lang)} {activePlan.steps.length}</span>
|
||||
<div className="font-medium text-on-surface flex items-center gap-2">
|
||||
{activePlan.steps[currentStepIndex].exerciseName}
|
||||
{activePlan.steps[currentStepIndex].isWeighted && <Scale size={12} className="text-primary" />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showPlanList ? <ChevronUp size={20} className="text-on-surface-variant" /> : <ChevronDown size={20} className="text-on-surface-variant" />}
|
||||
</button>
|
||||
|
||||
{showPlanList && (
|
||||
<div className="max-h-48 overflow-y-auto bg-surface-container-high p-2 space-y-1 animate-in slide-in-from-top-2">
|
||||
{activePlan.steps.map((step, idx) => (
|
||||
<button
|
||||
key={step.id}
|
||||
onClick={() => jumpToStep(idx)}
|
||||
className={`w-full text-left px-4 py-3 rounded-full text-sm flex items-center justify-between transition-colors ${idx === currentStepIndex
|
||||
? 'bg-primary-container text-on-primary-container font-medium'
|
||||
: idx < currentStepIndex
|
||||
? 'text-on-surface-variant opacity-50'
|
||||
: 'text-on-surface hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<span>{idx + 1}. {step.exerciseName}</span>
|
||||
{step.isWeighted && <Scale size={14} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-6">
|
||||
|
||||
<SetLogger
|
||||
tracker={tracker}
|
||||
lang={lang}
|
||||
onLogSet={handleAddSet}
|
||||
/>
|
||||
|
||||
{activeSession.sets.length > 0 && (
|
||||
<div className="pt-4">
|
||||
<h3 className="text-sm text-primary font-medium px-2 mb-3 tracking-wide">{t('history_section', lang)}</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[...activeSession.sets].reverse().map((set: WorkoutSet, idx: number) => {
|
||||
const setNumber = activeSession.sets.length - idx;
|
||||
const isEditing = editingSetId === set.id;
|
||||
return (
|
||||
<div key={set.id} className="flex justify-between items-center p-4 bg-surface-container rounded-xl shadow-elevation-1 animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="w-8 h-8 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center text-xs font-bold">
|
||||
{setNumber}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium text-on-surface mb-2">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{set.weight !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editWeight}
|
||||
onChange={(e) => setEditWeight(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Weight (kg)"
|
||||
/>
|
||||
)}
|
||||
{set.reps !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
value={editReps}
|
||||
onChange={(e) => setEditReps(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Reps"
|
||||
/>
|
||||
)}
|
||||
{set.durationSeconds !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
value={editDuration}
|
||||
onChange={(e) => setEditDuration(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Duration (s)"
|
||||
/>
|
||||
)}
|
||||
{set.distanceMeters !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editDistance}
|
||||
onChange={(e) => setEditDistance(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Distance (m)"
|
||||
/>
|
||||
)}
|
||||
{set.height !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editHeight}
|
||||
onChange={(e) => setEditHeight(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Height (cm)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-base font-medium text-on-surface">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</div>
|
||||
<div className="text-sm text-on-surface-variant">
|
||||
{set.type === ExerciseType.STRENGTH &&
|
||||
`${set.weight ? `${set.weight}kg` : ''} ${set.reps ? `x ${set.reps}` : ''}`.trim()
|
||||
}
|
||||
{set.type === ExerciseType.BODYWEIGHT &&
|
||||
`${set.weight ? `${set.weight}kg` : ''} ${set.reps ? `x ${set.reps}` : ''}`.trim()
|
||||
}
|
||||
{set.type === ExerciseType.CARDIO &&
|
||||
`${set.durationSeconds ? `${set.durationSeconds}s` : ''} ${set.distanceMeters ? `/ ${set.distanceMeters}m` : ''}`.trim()
|
||||
}
|
||||
{set.type === ExerciseType.STATIC &&
|
||||
`${set.durationSeconds ? `${set.durationSeconds}s` : ''}`.trim()
|
||||
}
|
||||
{set.type === ExerciseType.HIGH_JUMP &&
|
||||
`${set.height ? `${set.height}cm` : ''}`.trim()
|
||||
}
|
||||
{set.type === ExerciseType.LONG_JUMP &&
|
||||
`${set.distanceMeters ? `${set.distanceMeters}m` : ''}`.trim()
|
||||
}
|
||||
{set.type === ExerciseType.PLYOMETRIC &&
|
||||
`${set.reps ? `x ${set.reps}` : ''}`.trim()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="p-2 text-on-surface-variant hover:text-on-surface hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSaveEdit(set)}
|
||||
className="p-2 text-primary hover:bg-primary-container/20 rounded-full transition-colors"
|
||||
>
|
||||
<CheckCircle size={20} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleEditSet(set)}
|
||||
className="p-2 text-on-surface-variant hover:text-primary hover:bg-primary-container/20 rounded-full transition-colors"
|
||||
>
|
||||
<Edit size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemoveSet(set.id)}
|
||||
className="p-2 text-on-surface-variant hover:text-error hover:bg-error-container/10 rounded-full transition-colors"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCreating && (
|
||||
<ExerciseModal
|
||||
isOpen={isCreating}
|
||||
onClose={() => setIsCreating(false)}
|
||||
onSave={handleCreateExercise}
|
||||
lang={lang}
|
||||
existingExercises={exercises}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Finish Confirmation Dialog */}
|
||||
{showFinishConfirm && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
|
||||
<h3 className="text-2xl font-normal text-on-surface mb-2">{t('finish_confirm_title', lang)}</h3>
|
||||
<p className="text-on-surface-variant text-sm mb-8">{t('finish_confirm_msg', lang)}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowFinishConfirm(false)}
|
||||
className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5"
|
||||
>
|
||||
{t('cancel', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFinishConfirm(false);
|
||||
onSessionEnd();
|
||||
}}
|
||||
className="px-6 py-2.5 rounded-full bg-green-600 text-white font-medium hover:bg-green-700"
|
||||
>
|
||||
{t('confirm', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quit Without Saving Confirmation Dialog */}
|
||||
{showQuitConfirm && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
|
||||
<h3 className="text-2xl font-normal text-error mb-2">{t('quit_confirm_title', lang)}</h3>
|
||||
<p className="text-on-surface-variant text-sm mb-8">{t('quit_confirm_msg', lang)}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowQuitConfirm(false)}
|
||||
className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5"
|
||||
>
|
||||
{t('cancel', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowQuitConfirm(false);
|
||||
onSessionQuit();
|
||||
}}
|
||||
className="px-6 py-2.5 rounded-full bg-green-600 text-white font-medium hover:bg-green-700"
|
||||
>
|
||||
{t('confirm', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveSessionView;
|
||||
115
src/components/Tracker/IdleView.tsx
Normal file
115
src/components/Tracker/IdleView.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { Dumbbell, User, PlayCircle, Plus, ArrowRight } from 'lucide-react';
|
||||
import { Language } from '../../types';
|
||||
import { t } from '../../services/i18n';
|
||||
import { useTracker } from './useTracker';
|
||||
|
||||
interface IdleViewProps {
|
||||
tracker: ReturnType<typeof useTracker>;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const IdleView: React.FC<IdleViewProps> = ({ tracker, lang }) => {
|
||||
const {
|
||||
userBodyWeight,
|
||||
setUserBodyWeight,
|
||||
handleStart,
|
||||
setIsSporadicMode,
|
||||
plans,
|
||||
showPlanPrep,
|
||||
setShowPlanPrep,
|
||||
confirmPlanStart
|
||||
} = tracker;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-4 md:p-8 overflow-y-auto relative">
|
||||
<div className="flex-1 flex flex-col items-center justify-center space-y-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-24 h-24 rounded-full bg-surface-container-high flex items-center justify-center text-primary shadow-elevation-1">
|
||||
<Dumbbell size={40} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-normal text-on-surface">{t('ready_title', lang)}</h1>
|
||||
<p className="text-on-surface-variant text-sm">{t('ready_subtitle', lang)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm bg-surface-container rounded-2xl p-6 flex flex-col items-center gap-4 shadow-elevation-1">
|
||||
<label className="text-xs text-on-surface-variant font-bold tracking-wide flex items-center gap-2">
|
||||
<User size={14} />
|
||||
{t('my_weight', lang)}
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
className="text-5xl font-normal text-on-surface tabular-nums bg-transparent text-center w-full focus:outline-none"
|
||||
value={userBodyWeight}
|
||||
onChange={(e) => setUserBodyWeight(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-on-surface-variant">{t('change_in_profile', lang)}</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xs space-y-3">
|
||||
<button
|
||||
onClick={() => handleStart()}
|
||||
className="w-full h-16 rounded-full bg-primary text-on-primary font-medium text-lg shadow-elevation-2 hover:shadow-elevation-3 active:shadow-elevation-1 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<PlayCircle size={24} />
|
||||
{t('free_workout', lang)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsSporadicMode(true)}
|
||||
className="w-full h-12 rounded-full bg-surface-container-high text-on-surface font-medium text-base hover:bg-surface-container-highest transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{t('quick_log', lang)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{plans.length > 0 && (
|
||||
<div className="w-full max-w-md mt-8">
|
||||
<h3 className="text-sm text-on-surface-variant font-medium px-4 mb-3">{t('or_choose_plan', lang)}</h3>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{plans.map(plan => (
|
||||
<button
|
||||
key={plan.id}
|
||||
onClick={() => handleStart(plan)}
|
||||
className="flex items-center justify-between p-4 bg-surface-container rounded-xl hover:bg-surface-container-high transition-colors border border-outline-variant/20"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-base font-medium text-on-surface">{plan.name}</div>
|
||||
<div className="text-xs text-on-surface-variant">{plan.steps.length} {t('exercises_count', lang)}</div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center">
|
||||
<ArrowRight size={20} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPlanPrep && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
|
||||
<h3 className="text-2xl font-normal text-on-surface mb-4">{showPlanPrep.name}</h3>
|
||||
<div className="bg-surface-container-high p-4 rounded-xl text-on-surface-variant text-sm mb-8">
|
||||
<div className="text-xs font-bold text-primary mb-2">{t('prep_title', lang)}</div>
|
||||
{showPlanPrep.description || t('prep_no_instructions', lang)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => setShowPlanPrep(null)} className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
|
||||
<button onClick={confirmPlanStart} className="px-6 py-2.5 rounded-full bg-primary text-on-primary font-medium">{t('start', lang)}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IdleView;
|
||||
181
src/components/Tracker/SetLogger.tsx
Normal file
181
src/components/Tracker/SetLogger.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React from 'react';
|
||||
import { Dumbbell, Scale, Activity, Timer as TimerIcon, ArrowRight, ArrowUp, Plus, CheckCircle } from 'lucide-react';
|
||||
import { ExerciseType, Language } from '../../types';
|
||||
import { t } from '../../services/i18n';
|
||||
import FilledInput from '../FilledInput';
|
||||
import { useTracker } from './useTracker';
|
||||
|
||||
interface SetLoggerProps {
|
||||
tracker: ReturnType<typeof useTracker>;
|
||||
lang: Language;
|
||||
onLogSet: () => void;
|
||||
isSporadic?: boolean;
|
||||
}
|
||||
|
||||
const SetLogger: React.FC<SetLoggerProps> = ({ tracker, lang, onLogSet, isSporadic = false }) => {
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
setShowSuggestions,
|
||||
showSuggestions,
|
||||
filteredExercises,
|
||||
setSelectedExercise,
|
||||
selectedExercise,
|
||||
weight,
|
||||
setWeight,
|
||||
reps,
|
||||
setReps,
|
||||
duration,
|
||||
setDuration,
|
||||
distance,
|
||||
setDistance,
|
||||
height,
|
||||
setHeight,
|
||||
setIsCreating,
|
||||
sporadicSuccess,
|
||||
activePlan,
|
||||
currentStepIndex,
|
||||
unilateralSide,
|
||||
setUnilateralSide
|
||||
} = tracker;
|
||||
|
||||
const isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Exercise Selection */}
|
||||
<div className="relative">
|
||||
<FilledInput
|
||||
label={t('select_exercise', lang)}
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setSearchQuery('');
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 100)}
|
||||
icon={<Dumbbell size={10} />}
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
rightElement={
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="p-2 text-primary hover:bg-primary-container/20 rounded-full"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{showSuggestions && (
|
||||
<div className="absolute top-full left-0 w-full bg-surface-container rounded-xl shadow-elevation-3 overflow-hidden z-20 mt-1 max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-2">
|
||||
{filteredExercises.length > 0 ? (
|
||||
filteredExercises.map(ex => (
|
||||
<button
|
||||
key={ex.id}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedExercise(ex);
|
||||
setSearchQuery(ex.name);
|
||||
setShowSuggestions(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 text-on-surface hover:bg-surface-container-high transition-colors text-lg"
|
||||
>
|
||||
{ex.name}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-3 text-on-surface-variant text-lg">{t('no_exercises_found', lang)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedExercise && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-300 space-y-6">
|
||||
{/* Unilateral Exercise Toggle */}
|
||||
{selectedExercise.isUnilateral && (
|
||||
<div className="flex items-center gap-2 bg-surface-container rounded-full p-1">
|
||||
<button
|
||||
onClick={() => setUnilateralSide('LEFT')}
|
||||
className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'LEFT' ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant hover:bg-surface-container-high'
|
||||
}`}
|
||||
>
|
||||
{t('left', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUnilateralSide('RIGHT')}
|
||||
className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'RIGHT' ? 'bg-secondary-container text-on-secondary-container' : 'text-on-surface-variant hover:bg-surface-container-high'
|
||||
}`}
|
||||
>
|
||||
{t('right', lang)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Forms */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
|
||||
<FilledInput
|
||||
label={selectedExercise.type === ExerciseType.BODYWEIGHT ? t('add_weight', lang) : t('weight_kg', lang)}
|
||||
value={weight}
|
||||
step="0.1"
|
||||
onChange={(e: any) => setWeight(e.target.value)}
|
||||
icon={<Scale size={10} />}
|
||||
autoFocus={!isSporadic && activePlan && !isPlanFinished && activePlan.steps[currentStepIndex]?.isWeighted && (selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STRENGTH)}
|
||||
/>
|
||||
)}
|
||||
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && (
|
||||
<FilledInput
|
||||
label={t('reps', lang)}
|
||||
value={reps}
|
||||
onChange={(e: any) => setReps(e.target.value)}
|
||||
icon={<Activity size={10} />}
|
||||
type="number"
|
||||
/>
|
||||
)}
|
||||
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && (
|
||||
<FilledInput
|
||||
label={t('time_sec', lang)}
|
||||
value={duration}
|
||||
onChange={(e: any) => setDuration(e.target.value)}
|
||||
icon={<TimerIcon size={10} />}
|
||||
/>
|
||||
)}
|
||||
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && (
|
||||
<FilledInput
|
||||
label={t('dist_m', lang)}
|
||||
value={distance}
|
||||
onChange={(e: any) => setDistance(e.target.value)}
|
||||
icon={<ArrowRight size={10} />}
|
||||
/>
|
||||
)}
|
||||
{(selectedExercise.type === ExerciseType.HIGH_JUMP) && (
|
||||
<FilledInput
|
||||
label={t('height_cm', lang)}
|
||||
value={height}
|
||||
onChange={(e: any) => setHeight(e.target.value)}
|
||||
icon={<ArrowUp size={10} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onLogSet}
|
||||
className={`w-full h-14 font-medium text-lg rounded-full shadow-elevation-2 hover:shadow-elevation-3 active:scale-[0.98] transition-all flex items-center justify-center gap-2 ${isSporadic && sporadicSuccess
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-primary-container text-on-primary-container'
|
||||
}`}
|
||||
>
|
||||
{isSporadic && sporadicSuccess ? <CheckCircle size={24} /> : (isSporadic ? <Plus size={24} /> : <CheckCircle size={24} />)}
|
||||
<span>{isSporadic && sporadicSuccess ? t('saved', lang) : t('log_set', lang)}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetLogger;
|
||||
286
src/components/Tracker/SporadicView.tsx
Normal file
286
src/components/Tracker/SporadicView.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CheckCircle, Plus, Pencil, Trash2, X, Save } from 'lucide-react';
|
||||
import { Language, WorkoutSet } from '../../types';
|
||||
import { t } from '../../services/i18n';
|
||||
import ExerciseModal from '../ExerciseModal';
|
||||
import { useTracker } from './useTracker';
|
||||
import SetLogger from './SetLogger';
|
||||
|
||||
interface SporadicViewProps {
|
||||
tracker: ReturnType<typeof useTracker>;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
|
||||
const {
|
||||
handleLogSporadicSet,
|
||||
setIsSporadicMode,
|
||||
isCreating,
|
||||
setIsCreating,
|
||||
handleCreateExercise,
|
||||
exercises,
|
||||
resetForm,
|
||||
quickLogSession,
|
||||
selectedExercise,
|
||||
loadQuickLogSession
|
||||
} = tracker;
|
||||
|
||||
const [todaysSets, setTodaysSets] = useState<WorkoutSet[]>([]);
|
||||
const [editingSetId, setEditingSetId] = useState<string | null>(null);
|
||||
const [editingSet, setEditingSet] = useState<WorkoutSet | null>(null);
|
||||
const [deletingSetId, setDeletingSetId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (quickLogSession && quickLogSession.sets) {
|
||||
// Sets are already ordered by timestamp desc in the backend query, but let's ensure
|
||||
setTodaysSets([...quickLogSession.sets].sort((a, b) => b.timestamp - a.timestamp));
|
||||
} else {
|
||||
setTodaysSets([]);
|
||||
}
|
||||
}, [quickLogSession]);
|
||||
|
||||
const renderSetMetrics = (set: WorkoutSet) => {
|
||||
const metrics: string[] = [];
|
||||
if (set.weight) metrics.push(`${set.weight} ${t('weight_kg', lang)}`);
|
||||
if (set.reps) metrics.push(`${set.reps} ${t('reps', lang)}`);
|
||||
if (set.durationSeconds) metrics.push(`${set.durationSeconds} ${t('time_sec', lang)}`);
|
||||
if (set.distanceMeters) metrics.push(`${set.distanceMeters} ${t('dist_m', lang)}`);
|
||||
if (set.height) metrics.push(`${set.height} ${t('height_cm', lang)}`);
|
||||
return metrics.join(' / ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-full overflow-hidden relative bg-surface">
|
||||
<div className="px-4 py-3 bg-surface-container shadow-elevation-1 z-20 flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setIsSporadicMode(false);
|
||||
}}
|
||||
className="text-error font-medium text-sm hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{t('quit', lang)}
|
||||
</button>
|
||||
<div className="flex flex-col items-center">
|
||||
<h2 className="text-title-medium text-on-surface flex items-center gap-2 font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
||||
{t('quick_log', lang)}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogSporadicSet}
|
||||
className={`px-5 py-2 rounded-full text-sm font-medium transition-all ${selectedExercise
|
||||
? 'bg-primary-container text-on-primary-container hover:opacity-90 shadow-elevation-1'
|
||||
: 'bg-surface-container-high text-on-surface-variant opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!selectedExercise}
|
||||
>
|
||||
{t('log_set', lang)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-6">
|
||||
<SetLogger
|
||||
tracker={tracker}
|
||||
lang={lang}
|
||||
onLogSet={handleLogSporadicSet}
|
||||
isSporadic={true}
|
||||
/>
|
||||
|
||||
{/* History Section */}
|
||||
{todaysSets.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-title-medium font-medium mb-3">{t('history_section', lang)}</h3>
|
||||
<div className="space-y-2">
|
||||
{todaysSets.map((set, idx) => (
|
||||
<div key={set.id} className="bg-surface-container rounded-lg p-3 flex items-center justify-between shadow-elevation-1 animate-in fade-in">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="w-8 h-8 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center text-xs font-bold">
|
||||
{todaysSets.length - idx}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-on-surface">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</p>
|
||||
<p className="text-sm text-on-surface-variant">{renderSetMetrics(set)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingSetId(set.id);
|
||||
setEditingSet(JSON.parse(JSON.stringify(set)));
|
||||
}}
|
||||
className="p-2 text-on-surface-variant hover:text-primary hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingSetId(set.id)}
|
||||
className="p-2 text-on-surface-variant hover:text-error hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCreating && (
|
||||
<ExerciseModal
|
||||
isOpen={isCreating}
|
||||
onClose={() => setIsCreating(false)}
|
||||
onSave={handleCreateExercise}
|
||||
lang={lang}
|
||||
existingExercises={exercises}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Set Modal */}
|
||||
{editingSetId && editingSet && (
|
||||
<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-md rounded-[28px] p-6 shadow-elevation-3 max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-normal text-on-surface">{t('edit', lang)}</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingSetId(null);
|
||||
setEditingSet(null);
|
||||
}}
|
||||
className="p-2 hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{(editingSet.type === 'STRENGTH' || editingSet.type === 'BODYWEIGHT') && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-sm text-on-surface-variant">{t('weight_kg', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editingSet.weight || ''}
|
||||
onChange={(e) => setEditingSet({ ...editingSet, weight: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-on-surface-variant">{t('reps', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingSet.reps || ''}
|
||||
onChange={(e) => setEditingSet({ ...editingSet, reps: parseInt(e.target.value) || 0 })}
|
||||
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(editingSet.type === 'CARDIO' || editingSet.type === 'STATIC') && (
|
||||
<div>
|
||||
<label className="text-sm text-on-surface-variant">{t('time_sec', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingSet.durationSeconds || ''}
|
||||
onChange={(e) => setEditingSet({ ...editingSet, durationSeconds: parseInt(e.target.value) || 0 })}
|
||||
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{editingSet.type === 'CARDIO' && (
|
||||
<div>
|
||||
<label className="text-sm text-on-surface-variant">{t('dist_m', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editingSet.distanceMeters || ''}
|
||||
onChange={(e) => setEditingSet({ ...editingSet, distanceMeters: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingSetId(null);
|
||||
setEditingSet(null);
|
||||
}}
|
||||
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
|
||||
>
|
||||
{t('cancel', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/active/set/${editingSetId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(editingSet)
|
||||
});
|
||||
if (response.ok) {
|
||||
await loadQuickLogSession();
|
||||
setEditingSetId(null);
|
||||
setEditingSet(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update set:', error);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-full bg-primary text-on-primary font-medium flex items-center gap-2"
|
||||
>
|
||||
<Save size={18} />
|
||||
{t('save', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deletingSetId && (
|
||||
<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-xs rounded-[28px] p-6 shadow-elevation-3">
|
||||
<h3 className="text-xl font-normal text-on-surface mb-2">{t('delete', lang)}</h3>
|
||||
<p className="text-sm text-on-surface-variant mb-8">{t('delete_confirm', lang)}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setDeletingSetId(null)}
|
||||
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
|
||||
>
|
||||
{t('cancel', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/active/set/${deletingSetId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
await loadQuickLogSession();
|
||||
setDeletingSetId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete set:', error);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-full bg-error-container text-on-error-container font-medium"
|
||||
>
|
||||
{t('delete', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SporadicView;
|
||||
49
src/components/Tracker/index.tsx
Normal file
49
src/components/Tracker/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
|
||||
import React from 'react';
|
||||
import { WorkoutSession, WorkoutSet, WorkoutPlan, Language } from '../../types';
|
||||
import { useTracker } from './useTracker';
|
||||
import IdleView from './IdleView';
|
||||
import SporadicView from './SporadicView';
|
||||
import ActiveSessionView from './ActiveSessionView';
|
||||
|
||||
interface TrackerProps {
|
||||
userId: string;
|
||||
userWeight?: number;
|
||||
activeSession: WorkoutSession | null;
|
||||
activePlan: WorkoutPlan | null;
|
||||
onSessionStart: (plan?: WorkoutPlan, startWeight?: number) => void;
|
||||
onSessionEnd: () => void;
|
||||
onSessionQuit: () => void;
|
||||
onSetAdded: (set: WorkoutSet) => void;
|
||||
onRemoveSet: (setId: string) => void;
|
||||
onUpdateSet: (set: WorkoutSet) => void;
|
||||
onSporadicSetAdded?: () => void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const Tracker: React.FC<TrackerProps> = (props) => {
|
||||
const tracker = useTracker(props);
|
||||
const { isSporadicMode } = tracker;
|
||||
const { activeSession, lang, onSessionEnd, onSessionQuit, onRemoveSet } = props;
|
||||
|
||||
if (activeSession) {
|
||||
return (
|
||||
<ActiveSessionView
|
||||
tracker={tracker}
|
||||
activeSession={activeSession}
|
||||
lang={lang}
|
||||
onSessionEnd={onSessionEnd}
|
||||
onSessionQuit={onSessionQuit}
|
||||
onRemoveSet={onRemoveSet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSporadicMode) {
|
||||
return <SporadicView tracker={tracker} lang={lang} />;
|
||||
}
|
||||
|
||||
return <IdleView tracker={tracker} lang={lang} />;
|
||||
};
|
||||
|
||||
export default Tracker;
|
||||
491
src/components/Tracker/useTracker.ts
Normal file
491
src/components/Tracker/useTracker.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan, Language } from '../../types';
|
||||
import { getExercises, getLastSetForExercise, saveExercise, getPlans } from '../../services/storage';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
|
||||
interface UseTrackerProps {
|
||||
userId: string;
|
||||
userWeight?: number;
|
||||
activeSession: WorkoutSession | null;
|
||||
activePlan: WorkoutPlan | null;
|
||||
onSessionStart: (plan?: WorkoutPlan, startWeight?: number) => void;
|
||||
onSessionEnd: () => void;
|
||||
onSessionQuit: () => void;
|
||||
onSetAdded: (set: WorkoutSet) => void;
|
||||
onRemoveSet: (setId: string) => void;
|
||||
onUpdateSet: (set: WorkoutSet) => void;
|
||||
onSporadicSetAdded?: () => void;
|
||||
}
|
||||
|
||||
export const useTracker = ({
|
||||
userId,
|
||||
userWeight,
|
||||
activeSession,
|
||||
activePlan,
|
||||
onSessionStart,
|
||||
onSessionEnd,
|
||||
onSessionQuit,
|
||||
onSetAdded,
|
||||
onRemoveSet,
|
||||
onUpdateSet,
|
||||
onSporadicSetAdded
|
||||
}: UseTrackerProps) => {
|
||||
const [exercises, setExercises] = useState<ExerciseDef[]>([]);
|
||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
||||
const [selectedExercise, setSelectedExercise] = useState<ExerciseDef | null>(null);
|
||||
const [lastSet, setLastSet] = useState<WorkoutSet | undefined>(undefined);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
// Timer State
|
||||
const [elapsedTime, setElapsedTime] = useState<string>('00:00:00');
|
||||
|
||||
// Form State
|
||||
const [weight, setWeight] = useState<string>('');
|
||||
const [reps, setReps] = useState<string>('');
|
||||
const [duration, setDuration] = useState<string>('');
|
||||
const [distance, setDistance] = useState<string>('');
|
||||
const [height, setHeight] = useState<string>('');
|
||||
const [bwPercentage, setBwPercentage] = useState<string>('100');
|
||||
|
||||
// User Weight State
|
||||
const [userBodyWeight, setUserBodyWeight] = useState<string>(userWeight ? userWeight.toString() : '70');
|
||||
|
||||
// Create Exercise State
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// Plan Execution State
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [showPlanPrep, setShowPlanPrep] = useState<WorkoutPlan | null>(null);
|
||||
const [showPlanList, setShowPlanList] = useState(false);
|
||||
|
||||
// Confirmation State
|
||||
const [showFinishConfirm, setShowFinishConfirm] = useState(false);
|
||||
const [showQuitConfirm, setShowQuitConfirm] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
// Edit Set State
|
||||
const [editingSetId, setEditingSetId] = useState<string | null>(null);
|
||||
const [editWeight, setEditWeight] = useState<string>('');
|
||||
const [editReps, setEditReps] = useState<string>('');
|
||||
const [editDuration, setEditDuration] = useState<string>('');
|
||||
const [editDistance, setEditDistance] = useState<string>('');
|
||||
const [editHeight, setEditHeight] = useState<string>('');
|
||||
|
||||
// Quick Log State
|
||||
const [quickLogSession, setQuickLogSession] = useState<WorkoutSession | null>(null);
|
||||
const [isSporadicMode, setIsSporadicMode] = useState(false);
|
||||
const [sporadicSuccess, setSporadicSuccess] = useState(false);
|
||||
|
||||
// Unilateral Exercise State
|
||||
const [unilateralSide, setUnilateralSide] = useState<'LEFT' | 'RIGHT'>('LEFT');
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const exList = await getExercises(userId);
|
||||
exList.sort((a, b) => a.name.localeCompare(b.name));
|
||||
setExercises(exList);
|
||||
const planList = await getPlans(userId);
|
||||
setPlans(planList);
|
||||
|
||||
if (activeSession?.userBodyWeight) {
|
||||
setUserBodyWeight(activeSession.userBodyWeight.toString());
|
||||
} else if (userWeight) {
|
||||
setUserBodyWeight(userWeight.toString());
|
||||
}
|
||||
|
||||
// Load Quick Log Session
|
||||
try {
|
||||
const response = await api.get('/sessions/quick-log');
|
||||
if (response.success && response.session) {
|
||||
setQuickLogSession(response.session);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick log session:", error);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, [activeSession, userId, userWeight, activePlan]);
|
||||
|
||||
// Function to reload Quick Log session
|
||||
const loadQuickLogSession = async () => {
|
||||
try {
|
||||
const response = await api.get('/sessions/quick-log');
|
||||
if (response.success && response.session) {
|
||||
setQuickLogSession(response.session);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick log session:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Timer Logic
|
||||
useEffect(() => {
|
||||
let interval: number;
|
||||
if (activeSession) {
|
||||
const updateTimer = () => {
|
||||
const diff = Math.floor((Date.now() - activeSession.startTime) / 1000);
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
const s = diff % 60;
|
||||
setElapsedTime(`${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`);
|
||||
};
|
||||
|
||||
updateTimer();
|
||||
interval = window.setInterval(updateTimer, 1000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSession]);
|
||||
|
||||
// Recalculate current step when sets change
|
||||
useEffect(() => {
|
||||
if (activeSession && activePlan) {
|
||||
const performedCounts = new Map<string, number>();
|
||||
for (const set of activeSession.sets) {
|
||||
performedCounts.set(set.exerciseId, (performedCounts.get(set.exerciseId) || 0) + 1);
|
||||
}
|
||||
|
||||
let nextStepIndex = activePlan.steps.length; // Default to finished
|
||||
const plannedCounts = new Map<string, number>();
|
||||
for (let i = 0; i < activePlan.steps.length; i++) {
|
||||
const step = activePlan.steps[i];
|
||||
const exerciseId = step.exerciseId;
|
||||
plannedCounts.set(exerciseId, (plannedCounts.get(exerciseId) || 0) + 1);
|
||||
const performedCount = performedCounts.get(exerciseId) || 0;
|
||||
|
||||
if (performedCount < plannedCounts.get(exerciseId)!) {
|
||||
nextStepIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setCurrentStepIndex(nextStepIndex);
|
||||
}
|
||||
}, [activeSession, activePlan]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSession && activePlan && exercises.length > 0 && activePlan.steps.length > 0) {
|
||||
if (currentStepIndex < activePlan.steps.length) {
|
||||
const step = activePlan.steps[currentStepIndex];
|
||||
if (step) {
|
||||
const exDef = exercises.find(e => e.id === step.exerciseId);
|
||||
if (exDef) {
|
||||
setSelectedExercise(exDef);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentStepIndex, activePlan, exercises]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateSelection = async () => {
|
||||
if (selectedExercise) {
|
||||
setBwPercentage(selectedExercise.bodyWeightPercentage ? selectedExercise.bodyWeightPercentage.toString() : '100');
|
||||
setSearchQuery(selectedExercise.name);
|
||||
const set = await getLastSetForExercise(userId, selectedExercise.id);
|
||||
setLastSet(set);
|
||||
|
||||
if (set) {
|
||||
setWeight(set.weight?.toString() || '');
|
||||
setReps(set.reps?.toString() || '');
|
||||
setDuration(set.durationSeconds?.toString() || '');
|
||||
setDistance(set.distanceMeters?.toString() || '');
|
||||
setHeight(set.height?.toString() || '');
|
||||
} else {
|
||||
setWeight(''); setReps(''); setDuration(''); setDistance(''); setHeight('');
|
||||
}
|
||||
|
||||
// Clear fields not relevant to the selected exercise type
|
||||
if (selectedExercise.type !== ExerciseType.STRENGTH && selectedExercise.type !== ExerciseType.BODYWEIGHT) {
|
||||
setWeight('');
|
||||
}
|
||||
if (selectedExercise.type !== ExerciseType.STRENGTH && selectedExercise.type !== ExerciseType.BODYWEIGHT && selectedExercise.type !== ExerciseType.PLYOMETRIC) {
|
||||
setReps('');
|
||||
}
|
||||
if (selectedExercise.type !== ExerciseType.CARDIO && selectedExercise.type !== ExerciseType.STATIC) {
|
||||
setDuration('');
|
||||
}
|
||||
if (selectedExercise.type !== ExerciseType.CARDIO && selectedExercise.type !== ExerciseType.LONG_JUMP) {
|
||||
setDistance('');
|
||||
}
|
||||
if (selectedExercise.type !== ExerciseType.HIGH_JUMP) {
|
||||
setHeight('');
|
||||
}
|
||||
} else {
|
||||
setSearchQuery(''); // Clear search query if no exercise is selected
|
||||
}
|
||||
};
|
||||
updateSelection();
|
||||
}, [selectedExercise, userId]);
|
||||
|
||||
|
||||
const filteredExercises = searchQuery === ''
|
||||
? exercises
|
||||
: exercises.filter(ex =>
|
||||
ex.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleStart = (plan?: WorkoutPlan) => {
|
||||
if (plan && plan.description) {
|
||||
setShowPlanPrep(plan);
|
||||
} else {
|
||||
onSessionStart(plan, parseFloat(userBodyWeight));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPlanStart = () => {
|
||||
if (showPlanPrep) {
|
||||
onSessionStart(showPlanPrep, parseFloat(userBodyWeight));
|
||||
setShowPlanPrep(null);
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddSet = async () => {
|
||||
if (!activeSession || !selectedExercise) return;
|
||||
|
||||
const setData: Partial<WorkoutSet> = {
|
||||
exerciseId: selectedExercise.id,
|
||||
};
|
||||
|
||||
if (selectedExercise.isUnilateral) {
|
||||
setData.side = unilateralSide;
|
||||
}
|
||||
|
||||
switch (selectedExercise.type) {
|
||||
case ExerciseType.STRENGTH:
|
||||
if (weight) setData.weight = parseFloat(weight);
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
break;
|
||||
case ExerciseType.BODYWEIGHT:
|
||||
if (weight) setData.weight = parseFloat(weight);
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
|
||||
break;
|
||||
case ExerciseType.CARDIO:
|
||||
if (duration) setData.durationSeconds = parseInt(duration);
|
||||
if (distance) setData.distanceMeters = parseFloat(distance);
|
||||
break;
|
||||
case ExerciseType.STATIC:
|
||||
if (duration) setData.durationSeconds = parseInt(duration);
|
||||
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
|
||||
break;
|
||||
case ExerciseType.HIGH_JUMP:
|
||||
if (height) setData.height = parseFloat(height);
|
||||
break;
|
||||
case ExerciseType.LONG_JUMP:
|
||||
if (distance) setData.distanceMeters = parseFloat(distance);
|
||||
break;
|
||||
case ExerciseType.PLYOMETRIC:
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.post('/sessions/active/log-set', setData);
|
||||
if (response.success) {
|
||||
const { newSet, activeExerciseId } = response;
|
||||
onSetAdded(newSet);
|
||||
|
||||
if (activePlan && activeExerciseId) {
|
||||
const nextStepIndex = activePlan.steps.findIndex(step => step.exerciseId === activeExerciseId);
|
||||
if (nextStepIndex !== -1) {
|
||||
setCurrentStepIndex(nextStepIndex);
|
||||
}
|
||||
} else if (activePlan && !activeExerciseId) {
|
||||
// Plan is finished
|
||||
setCurrentStepIndex(activePlan.steps.length);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to log set:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogSporadicSet = async () => {
|
||||
if (!selectedExercise) return;
|
||||
|
||||
const setData: any = {
|
||||
exerciseId: selectedExercise.id,
|
||||
};
|
||||
|
||||
if (selectedExercise.isUnilateral) {
|
||||
setData.side = unilateralSide;
|
||||
}
|
||||
|
||||
switch (selectedExercise.type) {
|
||||
case ExerciseType.STRENGTH:
|
||||
if (weight) setData.weight = parseFloat(weight);
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
break;
|
||||
case ExerciseType.BODYWEIGHT:
|
||||
if (weight) setData.weight = parseFloat(weight);
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
|
||||
break;
|
||||
case ExerciseType.CARDIO:
|
||||
if (duration) setData.durationSeconds = parseInt(duration);
|
||||
if (distance) setData.distanceMeters = parseFloat(distance);
|
||||
break;
|
||||
case ExerciseType.STATIC:
|
||||
if (duration) setData.durationSeconds = parseInt(duration);
|
||||
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
|
||||
break;
|
||||
case ExerciseType.HIGH_JUMP:
|
||||
if (height) setData.height = parseFloat(height);
|
||||
break;
|
||||
case ExerciseType.LONG_JUMP:
|
||||
if (distance) setData.distanceMeters = parseFloat(distance);
|
||||
break;
|
||||
case ExerciseType.PLYOMETRIC:
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.post('/sessions/quick-log/set', setData);
|
||||
if (response.success) {
|
||||
setSporadicSuccess(true);
|
||||
setTimeout(() => setSporadicSuccess(false), 2000);
|
||||
|
||||
// Refresh quick log session
|
||||
const sessionRes = await api.get('/sessions/quick-log');
|
||||
if (sessionRes.success && sessionRes.session) {
|
||||
setQuickLogSession(sessionRes.session);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setWeight('');
|
||||
setReps('');
|
||||
setDuration('');
|
||||
setDistance('');
|
||||
setHeight('');
|
||||
if (onSporadicSetAdded) onSporadicSetAdded();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to log quick log set:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateExercise = async (newEx: ExerciseDef) => {
|
||||
await saveExercise(userId, newEx);
|
||||
setExercises(prev => [...prev, newEx].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setSelectedExercise(newEx);
|
||||
setSearchQuery(newEx.name);
|
||||
setIsCreating(false);
|
||||
};
|
||||
|
||||
const handleEditSet = (set: WorkoutSet) => {
|
||||
setEditingSetId(set.id);
|
||||
setEditWeight(set.weight?.toString() || '');
|
||||
setEditReps(set.reps?.toString() || '');
|
||||
setEditDuration(set.durationSeconds?.toString() || '');
|
||||
setEditDistance(set.distanceMeters?.toString() || '');
|
||||
setEditHeight(set.height?.toString() || '');
|
||||
};
|
||||
|
||||
const handleSaveEdit = (set: WorkoutSet) => {
|
||||
const updatedSet: WorkoutSet = {
|
||||
...set,
|
||||
...(editWeight && { weight: parseFloat(editWeight) }),
|
||||
...(editReps && { reps: parseInt(editReps) }),
|
||||
...(editDuration && { durationSeconds: parseInt(editDuration) }),
|
||||
...(editDistance && { distanceMeters: parseFloat(editDistance) }),
|
||||
...(editHeight && { height: parseFloat(editHeight) })
|
||||
};
|
||||
onUpdateSet(updatedSet);
|
||||
setEditingSetId(null);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingSetId(null);
|
||||
};
|
||||
|
||||
const jumpToStep = (index: number) => {
|
||||
if (!activePlan) return;
|
||||
setCurrentStepIndex(index);
|
||||
setShowPlanList(false);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setWeight('');
|
||||
setReps('');
|
||||
setDuration('');
|
||||
setDistance('');
|
||||
setHeight('');
|
||||
setSelectedExercise(null);
|
||||
setSearchQuery('');
|
||||
setSporadicSuccess(false);
|
||||
};
|
||||
|
||||
return {
|
||||
exercises,
|
||||
plans,
|
||||
activePlan,
|
||||
selectedExercise,
|
||||
setSelectedExercise,
|
||||
lastSet,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
showSuggestions,
|
||||
setShowSuggestions,
|
||||
elapsedTime,
|
||||
weight,
|
||||
setWeight,
|
||||
reps,
|
||||
setReps,
|
||||
duration,
|
||||
setDuration,
|
||||
distance,
|
||||
setDistance,
|
||||
height,
|
||||
setHeight,
|
||||
bwPercentage,
|
||||
setBwPercentage,
|
||||
userBodyWeight,
|
||||
setUserBodyWeight,
|
||||
isCreating,
|
||||
setIsCreating,
|
||||
currentStepIndex,
|
||||
showPlanPrep,
|
||||
setShowPlanPrep,
|
||||
showPlanList,
|
||||
setShowPlanList,
|
||||
showFinishConfirm,
|
||||
setShowFinishConfirm,
|
||||
showQuitConfirm,
|
||||
setShowQuitConfirm,
|
||||
showMenu,
|
||||
setShowMenu,
|
||||
editingSetId,
|
||||
editWeight,
|
||||
setEditWeight,
|
||||
editReps,
|
||||
setEditReps,
|
||||
editDuration,
|
||||
setEditDuration,
|
||||
editDistance,
|
||||
setEditDistance,
|
||||
editHeight,
|
||||
setEditHeight,
|
||||
isSporadicMode,
|
||||
setIsSporadicMode,
|
||||
sporadicSuccess,
|
||||
filteredExercises,
|
||||
handleStart,
|
||||
confirmPlanStart,
|
||||
handleAddSet,
|
||||
handleLogSporadicSet,
|
||||
handleCreateExercise,
|
||||
handleEditSet,
|
||||
handleSaveEdit,
|
||||
handleCancelEdit,
|
||||
jumpToStep,
|
||||
resetForm,
|
||||
unilateralSide,
|
||||
setUnilateralSide,
|
||||
quickLogSession, // Export this
|
||||
loadQuickLogSession, // Export reload function
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user