diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3bf5f5f..6e2dbf8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ import ProgressPage from './pages/ProgressPage' import WorkoutPage from './pages/WorkoutPage' import WorkoutSelectPage from './pages/WorkoutSelectPage' import ChatOnboarding from './pages/ChatOnboarding' +import ExerciseEncyclopediaPage from './pages/ExerciseEncyclopediaPage' import './App.css' const API_URL = '/api' @@ -144,6 +145,11 @@ function App() { return setView('dashboard')} /> } + // Exercise encyclopedia + if (view === 'encyclopedia') { + return setView('dashboard')} /> + } + // Workout select page if (view === 'select-workout') { return ( diff --git a/frontend/src/components/ExerciseResearchPanel.jsx b/frontend/src/components/ExerciseResearchPanel.jsx new file mode 100644 index 0000000..3145577 --- /dev/null +++ b/frontend/src/components/ExerciseResearchPanel.jsx @@ -0,0 +1,107 @@ +import { useState } from 'react' + +const API_URL = '/api' + +function ExerciseResearchPanel({ exerciseId, exerciseName }) { + const [loading, setLoading] = useState(false) + const [research, setResearch] = useState(null) + const [error, setError] = useState(null) + + const fetchResearch = async () => { + setLoading(true) + setError(null) + try { + const res = await fetch(`${API_URL}/exercises/${exerciseId}/research`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error || 'Failed to fetch research') + } + const data = await res.json() + setResearch(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+
+

Research

+ {!research && ( + + )} + {research && ( + + )} +
+ + {loading && ( +
+
+ Searching for information on {exerciseName}... +
+ )} + + {error && ( +
+ {error} + +
+ )} + + {research && !loading && ( +
+ {research.summary && ( +
+

Summary

+

{research.summary}

+
+ )} + + {research.results && research.results.length > 0 && ( +
+

Sources

+
    + {research.results.map((result, i) => ( +
  • + + {result.title} + + {result.snippet && ( +

    {result.snippet}

    + )} +
  • + ))} +
+
+ )} +
+ )} +
+ ) +} + +export default ExerciseResearchPanel diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 9f6f479..8c7d392 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -98,6 +98,7 @@ function Dashboard({ onStartWorkout, onNavigate }) { diff --git a/frontend/src/pages/ExerciseEncyclopediaPage.jsx b/frontend/src/pages/ExerciseEncyclopediaPage.jsx new file mode 100644 index 0000000..66ccf96 --- /dev/null +++ b/frontend/src/pages/ExerciseEncyclopediaPage.jsx @@ -0,0 +1,128 @@ +import { useState, useEffect } from 'react' +import ExerciseResearchPanel from '../components/ExerciseResearchPanel' +import './WorkoutEditPage.css' + +const API_URL = '/api' + +function ExerciseEncyclopediaPage({ onBack }) { + const [exercises, setExercises] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [search, setSearch] = useState('') + const [selected, setSelected] = useState(null) + + useEffect(() => { + const fetchExercises = async () => { + try { + const res = await fetch(`${API_URL}/exercises?limit=100`) + if (!res.ok) throw new Error('Failed to load exercises') + const data = await res.json() + setExercises(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + fetchExercises() + }, []) + + const filtered = exercises.filter(ex => + ex.name.toLowerCase().includes(search.toLowerCase()) + ) + + return ( +
+
+ +

Exercise Encyclopedia

+
+
+ +
+
+ setSearch(e.target.value)} + className="encyclopedia-search" + /> +
+ + {loading && ( +
+

Loading exercises...

+
+ )} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && ( +
+ {filtered.length === 0 && ( +
+

No exercises found.

+
+ )} + {filtered.map(exercise => ( +
+
setSelected(selected?.id === exercise.id ? null : exercise)} + style={{ cursor: 'pointer' }} + > +
+

{exercise.name}

+
+ {exercise.difficulty && ( + {exercise.difficulty} + )} + {(exercise.muscle_groups || []).map(mg => ( + {mg} + ))} +
+ {exercise.description && ( +

+ {exercise.description} +

+ )} +
+ + {selected?.id === exercise.id ? '▲' : '▼'} + +
+ + {selected?.id === exercise.id && ( +
+ {exercise.instructions && ( +
+

Instructions

+

{exercise.instructions}

+
+ )} + +
+ )} +
+ ))} +
+ )} +
+
+ ) +} + +export default ExerciseEncyclopediaPage diff --git a/frontend/src/pages/WorkoutEditPage.css b/frontend/src/pages/WorkoutEditPage.css index 4d24f44..95c2edc 100644 --- a/frontend/src/pages/WorkoutEditPage.css +++ b/frontend/src/pages/WorkoutEditPage.css @@ -484,3 +484,169 @@ justify-content: space-between; } } + +/* Encyclopedia search input */ +.encyclopedia-search { + width: 100%; + padding: 0.625rem 0.75rem; + border: 1px solid #ddd; + border-radius: 0.25rem; + font-size: 1rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + min-height: 44px; + box-sizing: border-box; +} + +.encyclopedia-search:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25); +} + +/* Selected exercise highlight */ +.edit-exercise-card.exercise-selected { + border: 2px solid #007bff; +} + +/* Expanded exercise detail */ +.exercise-detail-expanded { + border-top: 1px solid #eee; + padding-top: 1rem; + margin-top: 0.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.exercise-instructions h4 { + margin: 0 0 0.5rem; + font-size: 0.9rem; + color: #555; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.exercise-instructions p { + margin: 0; + font-size: 0.9rem; + color: #444; + line-height: 1.6; +} + +/* Research panel */ +.research-panel { + background: #f8f9fa; + border-radius: 0.375rem; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.research-panel-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.research-panel-title { + margin: 0; + font-size: 0.9rem; + color: #555; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.research-btn { + padding: 0.4rem 0.9rem; + font-size: 0.875rem; + min-height: 36px; +} + +.research-loading { + display: flex; + align-items: center; + gap: 0.75rem; + color: #666; + font-size: 0.9rem; + padding: 0.5rem 0; +} + +.research-spinner { + width: 18px; + height: 18px; + border: 2px solid #ddd; + border-top-color: #007bff; + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex-shrink: 0; +} + +.research-error { + display: flex; + align-items: center; + justify-content: space-between; + background: #f8d7da; + color: #721c24; + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + font-size: 0.875rem; +} + +.research-results { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.research-summary h4, +.research-sources h4 { + margin: 0 0 0.5rem; + font-size: 0.85rem; + color: #555; + font-weight: 600; +} + +.research-summary p { + margin: 0; + font-size: 0.9rem; + color: #333; + line-height: 1.6; +} + +.research-sources-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.625rem; +} + +.research-source-item { + background: white; + border: 1px solid #e0e0e0; + border-radius: 0.25rem; + padding: 0.625rem 0.75rem; +} + +.research-source-link { + color: #007bff; + font-size: 0.9rem; + font-weight: 500; + text-decoration: none; + display: block; + margin-bottom: 0.25rem; + word-break: break-word; +} + +.research-source-link:hover { + text-decoration: underline; +} + +.research-source-snippet { + margin: 0; + font-size: 0.825rem; + color: #555; + line-height: 1.5; +}