287 lines
16 KiB
TypeScript
287 lines
16 KiB
TypeScript
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;
|