feat(04-03-partial): ExercisePicker and WorkoutEditPage components - swap/add/remove exercises with sets/reps editing

This commit is contained in:
2026-03-01 15:36:47 +01:00
parent 4d60a269ff
commit 843771e935
7 changed files with 446 additions and 5 deletions
+112
View File
@@ -0,0 +1,112 @@
import { useState, useEffect, useMemo } from 'react'
import { Icon } from './Icons'
const API_URL = '/api'
function ExercisePicker({ open, onSelect, onClose, excludeIds = [] }) {
const [exercises, setExercises] = useState([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [activeGroup, setActiveGroup] = useState('Alla')
useEffect(() => {
if (open) {
fetchExercises()
setSearch('')
setActiveGroup('Alla')
}
}, [open])
const fetchExercises = async () => {
setLoading(true)
try {
const res = await fetch(`${API_URL}/exercises`)
if (!res.ok) throw new Error('Failed to fetch')
const data = await res.json()
setExercises(data)
} catch (err) {
console.error('Failed to fetch exercises:', err)
} finally {
setLoading(false)
}
}
const muscleGroups = useMemo(() => {
const groups = new Set(exercises.map(e => e.muscle_group).filter(Boolean))
return ['Alla', ...Array.from(groups).sort()]
}, [exercises])
const filtered = useMemo(() => {
return exercises.filter(ex => {
if (excludeIds.includes(ex.id)) return false
if (activeGroup !== 'Alla' && ex.muscle_group !== activeGroup) return false
if (search) {
const q = search.toLowerCase()
return ex.name.toLowerCase().includes(q) ||
(ex.muscle_group || '').toLowerCase().includes(q)
}
return true
})
}, [exercises, search, activeGroup, excludeIds])
if (!open) return null
return (
<div className="exercise-picker-overlay" onClick={onClose}>
<div className="exercise-picker" onClick={e => e.stopPropagation()}>
<div className="exercise-picker-header">
<h2>Välj övning</h2>
<button className="exercise-picker-close" onClick={onClose} aria-label="Stäng">
<Icon name="chevronDown" size={20} />
</button>
</div>
<div className="exercise-picker-search">
<input
type="text"
placeholder="Sök övning..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</div>
<div className="exercise-picker-filters">
{muscleGroups.map(group => (
<button
key={group}
className={`filter-chip ${activeGroup === group ? 'active' : ''}`}
onClick={() => setActiveGroup(group)}
>
{group}
</button>
))}
</div>
<div className="exercise-picker-list">
{loading && <div className="exercise-picker-state">Laddar övningar...</div>}
{!loading && filtered.length === 0 && (
<div className="exercise-picker-state">Inga övningar hittades.</div>
)}
{!loading && filtered.map(ex => (
<button
key={ex.id}
className="exercise-picker-item"
onClick={() => onSelect(ex)}
>
<div className="exercise-picker-item-info">
<strong>{ex.name}</strong>
<span className="exercise-picker-item-group">{ex.muscle_group}</span>
</div>
<Icon name="arrowLeft" size={16} style={{ transform: 'rotate(180deg)', opacity: 0.4 }} />
</button>
))}
</div>
</div>
</div>
)
}
export default ExercisePicker
+18
View File
@@ -234,6 +234,24 @@ export const Icons = {
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
),
edit: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
),
search: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
),
x: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
),
// Brand
gravl: (