01f013c9d8
- Created 04-06-PLAN.md outlining persistence improvements phases - Phase 04-06-01: Draft persistence via localStorage - Added useDraftWorkout hook for auto-saving/loading drafts - Integrated hook into WorkoutEditPage - Added draft recovery prompt UI - Drafts cleared after successful save - Phase 04-06-02: Save error handling & retry (scaffolding) - Added error state and syncStatus tracking - Added handleRetry() for failed saves - Error banner with retry button - Phase 04-06-03: Sync status UI (scaffolding) - Added visual feedback for save progress - Status indicators: saving, saved, error - Disabled UI during save to prevent conflicts - Created comprehensive styles for new UI components Status: 04-06-01 complete and integrated. Ready for testing.
293 lines
8.8 KiB
React
293 lines
8.8 KiB
React
import { useState } from 'react'
|
||
import { Icon } from '../components/Icons'
|
||
import ExercisePicker from '../components/ExercisePicker'
|
||
import { useDraftWorkout } from '../hooks/useDraftWorkout'
|
||
import './WorkoutEditPage.css'
|
||
|
||
export default function WorkoutEditPage({ workout, onBack, onSave }) {
|
||
const { exercises, setExercises, clearDraft, hasDraft, restoreDraft } =
|
||
useDraftWorkout(workout.id, workout.exercises || [])
|
||
|
||
const [pickerOpen, setPickerOpen] = useState(false)
|
||
const [swapIndex, setSwapIndex] = useState(null) // null = adding, number = swapping
|
||
const [saving, setSaving] = useState(false)
|
||
const [error, setError] = useState(null)
|
||
const [syncStatus, setSyncStatus] = useState('idle') // idle | saving | saved | error
|
||
const [draftPromptShown, setDraftPromptShown] = useState(false)
|
||
|
||
// Show draft recovery prompt on first render
|
||
const handleRecoverDraft = () => {
|
||
if (hasDraft && !draftPromptShown) {
|
||
setDraftPromptShown(true)
|
||
// Prompt is shown via conditional rendering below
|
||
}
|
||
}
|
||
|
||
const handleOpenPicker = (index = null) => {
|
||
setSwapIndex(index)
|
||
setPickerOpen(true)
|
||
}
|
||
|
||
const handleSelectExercise = (exercise) => {
|
||
if (swapIndex !== null) {
|
||
// Swap
|
||
setExercises(prev => prev.map((ex, i) => {
|
||
if (i === swapIndex) {
|
||
return {
|
||
...ex,
|
||
exercise_id: exercise.id,
|
||
name: exercise.name,
|
||
muscle_group: exercise.muscle_group,
|
||
// Keep existing sets/reps
|
||
}
|
||
}
|
||
return ex
|
||
}))
|
||
} else {
|
||
// Add
|
||
setExercises(prev => [...prev, {
|
||
exercise_id: exercise.id,
|
||
name: exercise.name,
|
||
muscle_group: exercise.muscle_group,
|
||
sets: 3,
|
||
reps_min: 8,
|
||
reps_max: 12
|
||
}])
|
||
}
|
||
setPickerOpen(false)
|
||
}
|
||
|
||
const handleRemove = (index) => {
|
||
setExercises(prev => prev.filter((_, i) => i !== index))
|
||
}
|
||
|
||
const handleUpdate = (index, field, value) => {
|
||
setExercises(prev => prev.map((ex, i) => {
|
||
if (i === index) {
|
||
return { ...ex, [field]: value }
|
||
}
|
||
return ex
|
||
}))
|
||
// Clear error state on user edit
|
||
if (error) setError(null)
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true)
|
||
setSyncStatus('saving')
|
||
setError(null)
|
||
try {
|
||
// Format for API
|
||
const payload = {
|
||
exercises: exercises.map(ex => ({
|
||
exercise_id: ex.exercise_id || ex.id, // Handle both structures
|
||
sets: parseInt(ex.sets) || 3,
|
||
reps_min: parseInt(ex.reps_min) || 8,
|
||
reps_max: parseInt(ex.reps_max) || 12
|
||
}))
|
||
}
|
||
await onSave(workout.id, payload)
|
||
|
||
// Success: clear draft and show confirmation
|
||
clearDraft()
|
||
setSyncStatus('saved')
|
||
|
||
// Reset status after 2 seconds
|
||
setTimeout(() => setSyncStatus('idle'), 2000)
|
||
} catch (err) {
|
||
console.error('Failed to save workout:', err)
|
||
setError(err.message || 'Sparning misslyckades. Försök igen.')
|
||
setSyncStatus('error')
|
||
// Keep draft on error so user doesn't lose work
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleRetry = () => {
|
||
handleSave()
|
||
}
|
||
|
||
const handleDiscardDraft = () => {
|
||
clearDraft()
|
||
setDraftPromptShown(true)
|
||
// Reset exercises to original
|
||
setExercises(workout.exercises || [])
|
||
}
|
||
|
||
// Show draft recovery prompt if we have a draft and haven't shown it yet
|
||
const showDraftPrompt = hasDraft && !draftPromptShown
|
||
if (showDraftPrompt) {
|
||
handleRecoverDraft()
|
||
}
|
||
|
||
return (
|
||
<div className="edit-page">
|
||
{/* Draft Recovery Prompt */}
|
||
{showDraftPrompt && (
|
||
<div className="draft-prompt-overlay">
|
||
<div className="draft-prompt-modal">
|
||
<h2>Du har sparat ändringar</h2>
|
||
<p>Vi hittade ett utkast från din senaste redigering. Vill du fortsätta eller börja om?</p>
|
||
<div className="draft-prompt-actions">
|
||
<button
|
||
className="btn btn-secondary"
|
||
onClick={handleDiscardDraft}
|
||
>
|
||
Börja om
|
||
</button>
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={() => setDraftPromptShown(true)}
|
||
>
|
||
Fortsätt redigering
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<header className="page-header">
|
||
<button className="back-btn" onClick={onBack} disabled={saving}>
|
||
<Icon name="arrowLeft" size={18} /> Avbryt
|
||
</button>
|
||
<h1>Redigera pass</h1>
|
||
<div className="save-header-group">
|
||
{syncStatus === 'saved' && (
|
||
<span className="sync-status saved">
|
||
<Icon name="checkmark" size={16} /> Sparat
|
||
</span>
|
||
)}
|
||
{syncStatus === 'error' && (
|
||
<span className="sync-status error">
|
||
<Icon name="alert" size={16} /> Fel
|
||
</span>
|
||
)}
|
||
<button
|
||
className="save-header-btn"
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
>
|
||
{syncStatus === 'saving' && (
|
||
<>
|
||
<Icon name="spinner" size={16} /> Sparar...
|
||
</>
|
||
)}
|
||
{syncStatus !== 'saving' && 'Spara'}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Error Banner */}
|
||
{error && (
|
||
<div className="error-banner">
|
||
<div className="error-message">
|
||
<Icon name="alert" size={18} />
|
||
<span>{error}</span>
|
||
</div>
|
||
<div className="error-actions">
|
||
<button className="btn-retry" onClick={handleRetry}>
|
||
Försök igen
|
||
</button>
|
||
<button
|
||
className="btn-close"
|
||
onClick={() => setError(null)}
|
||
aria-label="Stäng"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<main className="edit-main">
|
||
<div className="workout-meta-card">
|
||
<h2>{workout.name}</h2>
|
||
<p>{exercises.length} övningar</p>
|
||
</div>
|
||
|
||
<div className="edit-exercises-list">
|
||
{exercises.map((ex, i) => (
|
||
<div key={i} className="edit-exercise-card">
|
||
<div className="edit-card-header">
|
||
<div className="edit-card-info">
|
||
<h3>{ex.name}</h3>
|
||
<span className="muscle-group">{ex.muscle_group}</span>
|
||
</div>
|
||
<div className="edit-card-actions">
|
||
<button
|
||
className="icon-btn"
|
||
onClick={() => handleOpenPicker(i)}
|
||
aria-label="Byt övning"
|
||
disabled={saving}
|
||
>
|
||
<Icon name="swap" size={18} />
|
||
</button>
|
||
<button
|
||
className="icon-btn delete"
|
||
onClick={() => handleRemove(i)}
|
||
aria-label="Ta bort övning"
|
||
disabled={saving}
|
||
>
|
||
<Icon name="trash" size={18} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="edit-card-settings">
|
||
<div className="setting-group">
|
||
<label>Set</label>
|
||
<input
|
||
type="number"
|
||
value={ex.sets}
|
||
onChange={e => handleUpdate(i, 'sets', e.target.value)}
|
||
min="1"
|
||
disabled={saving}
|
||
/>
|
||
</div>
|
||
<div className="setting-group">
|
||
<label>Reps min</label>
|
||
<input
|
||
type="number"
|
||
value={ex.reps_min}
|
||
onChange={e => handleUpdate(i, 'reps_min', e.target.value)}
|
||
min="1"
|
||
disabled={saving}
|
||
/>
|
||
</div>
|
||
<div className="setting-group">
|
||
<label>Reps max</label>
|
||
<input
|
||
type="number"
|
||
value={ex.reps_max}
|
||
onChange={e => handleUpdate(i, 'reps_max', e.target.value)}
|
||
min="1"
|
||
disabled={saving}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
className="add-exercise-btn"
|
||
onClick={() => handleOpenPicker(null)}
|
||
disabled={saving}
|
||
>
|
||
<Icon name="plus" size={20} />
|
||
Lägg till övning
|
||
</button>
|
||
</main>
|
||
|
||
{pickerOpen && (
|
||
<ExercisePicker
|
||
open={pickerOpen}
|
||
onSelect={handleSelectExercise}
|
||
onClose={() => setPickerOpen(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|