diff --git a/frontend/src/hooks/useDraftWorkout.js b/frontend/src/hooks/useDraftWorkout.js new file mode 100644 index 0000000..0f6b286 --- /dev/null +++ b/frontend/src/hooks/useDraftWorkout.js @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react' + +/** + * useDraftWorkout - Manages draft workout state with localStorage persistence + * + * @param {number} workoutId - Unique workout ID (used as localStorage key) + * @param {array} initialExercises - Initial exercise list + * @returns {object} { exercises, setExercises, clearDraft, hasDraft, restoreDraft } + */ +export function useDraftWorkout(workoutId, initialExercises = []) { + const [exercises, setExercises] = useState(initialExercises) + const [hasDraft, setHasDraft] = useState(false) + + const draftKey = `workout-draft-${workoutId}` + + // Load draft from localStorage on mount + useEffect(() => { + const saved = localStorage.getItem(draftKey) + if (saved) { + try { + const draft = JSON.parse(saved) + setExercises(draft) + setHasDraft(true) + } catch (err) { + console.error('Failed to parse draft:', err) + localStorage.removeItem(draftKey) // Clear corrupted draft + } + } + }, [workoutId, draftKey]) + + // Auto-save to localStorage whenever exercises change + useEffect(() => { + if (exercises.length > 0) { + localStorage.setItem(draftKey, JSON.stringify(exercises)) + } + }, [exercises, draftKey]) + + const clearDraft = () => { + localStorage.removeItem(draftKey) + setHasDraft(false) + } + + const restoreDraft = () => { + const saved = localStorage.getItem(draftKey) + if (saved) { + try { + const draft = JSON.parse(saved) + setExercises(draft) + return true + } catch (err) { + console.error('Failed to restore draft:', err) + return false + } + } + return false + } + + return { + exercises, + setExercises, + clearDraft, + hasDraft, + restoreDraft + } +} diff --git a/frontend/src/pages/WorkoutEditPage.css b/frontend/src/pages/WorkoutEditPage.css new file mode 100644 index 0000000..28c09ff --- /dev/null +++ b/frontend/src/pages/WorkoutEditPage.css @@ -0,0 +1,436 @@ +.edit-page { + display: flex; + flex-direction: column; + height: 100vh; + background: #f5f5f5; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: white; + border-bottom: 1px solid #ddd; + gap: 1rem; +} + +.page-header h1 { + flex: 1; + text-align: center; + font-size: 1.25rem; + margin: 0; + color: #333; +} + +.back-btn, +.save-header-btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 44px; + min-width: 44px; +} + +.back-btn { + background: #f0f0f0; + color: #333; +} + +.back-btn:hover:not(:disabled) { + background: #e0e0e0; +} + +.back-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.save-header-group { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.sync-status { + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.4rem; + white-space: nowrap; +} + +.sync-status.saved { + background: #d4edda; + color: #155724; +} + +.sync-status.error { + background: #f8d7da; + color: #721c24; +} + +.save-header-btn { + background: #007bff; + color: white; + padding: 0.5rem 1.25rem; +} + +.save-header-btn:hover:not(:disabled) { + background: #0056b3; +} + +.save-header-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Draft Recovery Prompt */ +.draft-prompt-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.draft-prompt-modal { + background: white; + border-radius: 0.5rem; + padding: 2rem; + max-width: 400px; + width: 90%; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.draft-prompt-modal h2 { + margin-top: 0; + margin-bottom: 0.5rem; + color: #333; + font-size: 1.25rem; +} + +.draft-prompt-modal p { + color: #666; + margin-bottom: 1.5rem; + line-height: 1.5; +} + +.draft-prompt-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; +} + +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.95rem; + font-weight: 500; + min-height: 44px; + transition: all 0.2s; +} + +.btn-primary { + background: #007bff; + color: white; +} + +.btn-primary:hover { + background: #0056b3; +} + +.btn-secondary { + background: #e9ecef; + color: #333; +} + +.btn-secondary:hover { + background: #dee2e6; +} + +/* Error Banner */ +.error-banner { + background: #f8d7da; + border-bottom: 1px solid #f5c6cb; + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + color: #721c24; + animation: slideDown 0.3s ease-out; +} + +@keyframes slideDown { + from { + transform: translateY(-100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.error-message { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; +} + +.error-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.btn-retry { + padding: 0.5rem 1rem; + background: #721c24; + color: white; + border: none; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.85rem; + min-height: 40px; + transition: background 0.2s; +} + +.btn-retry:hover { + background: #5a1520; +} + +.btn-close { + background: transparent; + border: none; + color: #721c24; + font-size: 1.5rem; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-close:hover { + opacity: 0.7; +} + +/* Main Content */ +.edit-main { + flex: 1; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.workout-meta-card { + background: white; + padding: 1rem; + border-radius: 0.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.workout-meta-card h2 { + margin: 0 0 0.5rem 0; + font-size: 1.25rem; + color: #333; +} + +.workout-meta-card p { + margin: 0; + color: #666; + font-size: 0.9rem; +} + +.edit-exercises-list { + display: flex; + flex-direction: column; + gap: 1rem; + flex: 1; +} + +.edit-exercise-card { + background: white; + border-radius: 0.5rem; + padding: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.edit-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + gap: 1rem; +} + +.edit-card-info h3 { + margin: 0 0 0.5rem 0; + color: #333; + font-size: 1.05rem; +} + +.muscle-group { + display: inline-block; + background: #f0f0f0; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.8rem; + color: #666; +} + +.edit-card-actions { + display: flex; + gap: 0.5rem; +} + +.icon-btn { + padding: 0.5rem; + border: none; + background: #f0f0f0; + color: #333; + cursor: pointer; + border-radius: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + min-height: 40px; + min-width: 40px; + transition: all 0.2s; +} + +.icon-btn:hover:not(:disabled) { + background: #e0e0e0; +} + +.icon-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.icon-btn.delete { + color: #dc3545; +} + +.icon-btn.delete:hover:not(:disabled) { + background: #ffe0e0; +} + +.edit-card-settings { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 1rem; +} + +.setting-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.setting-group label { + font-size: 0.85rem; + color: #666; + font-weight: 500; +} + +.setting-group input { + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 0.25rem; + font-size: 1rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + min-height: 40px; +} + +.setting-group input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25); +} + +.setting-group input:disabled { + background: #f5f5f5; + color: #999; + cursor: not-allowed; +} + +.add-exercise-btn { + padding: 1rem; + background: #28a745; + color: white; + border: none; + border-radius: 0.5rem; + cursor: pointer; + font-size: 1rem; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 50px; + transition: background 0.2s; + align-self: center; + max-width: 300px; + width: 100%; +} + +.add-exercise-btn:hover:not(:disabled) { + background: #218838; +} + +.add-exercise-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Mobile/Tablet Adjustments */ +@media (max-width: 600px) { + .page-header { + padding: 0.75rem; + } + + .page-header h1 { + font-size: 1rem; + } + + .back-btn, + .save-header-btn { + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + } + + .edit-main { + padding: 0.75rem; + } + + .edit-card-settings { + grid-template-columns: 1fr; + } + + .draft-prompt-modal { + padding: 1.5rem; + max-width: 90%; + } +} diff --git a/frontend/src/pages/WorkoutEditPage.jsx b/frontend/src/pages/WorkoutEditPage.jsx index be87396..8f461b5 100644 --- a/frontend/src/pages/WorkoutEditPage.jsx +++ b/frontend/src/pages/WorkoutEditPage.jsx @@ -1,13 +1,27 @@ 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] = useState(workout.exercises || []) + 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) @@ -54,10 +68,14 @@ export default function WorkoutEditPage({ workout, onBack, onSave }) { } 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 = { @@ -69,29 +87,119 @@ export default function WorkoutEditPage({ workout, onBack, onSave }) { })) } 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 (
Vi hittade ett utkast från din senaste redigering. Vill du fortsätta eller börja om?
+