feature/05-exercise-encyclopedia #4
@@ -6,6 +6,7 @@ import ProgressPage from './pages/ProgressPage'
|
|||||||
import WorkoutPage from './pages/WorkoutPage'
|
import WorkoutPage from './pages/WorkoutPage'
|
||||||
import WorkoutSelectPage from './pages/WorkoutSelectPage'
|
import WorkoutSelectPage from './pages/WorkoutSelectPage'
|
||||||
import ChatOnboarding from './pages/ChatOnboarding'
|
import ChatOnboarding from './pages/ChatOnboarding'
|
||||||
|
import ExerciseEncyclopediaPage from './pages/ExerciseEncyclopediaPage'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
@@ -144,6 +145,11 @@ function App() {
|
|||||||
return <ProgressPage onBack={() => setView('dashboard')} />
|
return <ProgressPage onBack={() => setView('dashboard')} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exercise encyclopedia
|
||||||
|
if (view === 'encyclopedia') {
|
||||||
|
return <ExerciseEncyclopediaPage onBack={() => setView('dashboard')} />
|
||||||
|
}
|
||||||
|
|
||||||
// Workout select page
|
// Workout select page
|
||||||
if (view === 'select-workout') {
|
if (view === 'select-workout') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="research-panel">
|
||||||
|
<div className="research-panel-header">
|
||||||
|
<h3 className="research-panel-title">Research</h3>
|
||||||
|
{!research && (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary research-btn"
|
||||||
|
onClick={fetchResearch}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Fetching...' : 'Get Research'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{research && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary research-btn"
|
||||||
|
onClick={fetchResearch}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Fetching...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="research-loading">
|
||||||
|
<div className="research-spinner"></div>
|
||||||
|
<span>Searching for information on {exerciseName}...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="research-error">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button className="btn-close" onClick={() => setError(null)}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{research && !loading && (
|
||||||
|
<div className="research-results">
|
||||||
|
{research.summary && (
|
||||||
|
<div className="research-summary">
|
||||||
|
<h4>Summary</h4>
|
||||||
|
<p>{research.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{research.results && research.results.length > 0 && (
|
||||||
|
<div className="research-sources">
|
||||||
|
<h4>Sources</h4>
|
||||||
|
<ul className="research-sources-list">
|
||||||
|
{research.results.map((result, i) => (
|
||||||
|
<li key={i} className="research-source-item">
|
||||||
|
<a
|
||||||
|
href={result.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="research-source-link"
|
||||||
|
>
|
||||||
|
{result.title}
|
||||||
|
</a>
|
||||||
|
{result.snippet && (
|
||||||
|
<p className="research-source-snippet">{result.snippet}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExerciseResearchPanel
|
||||||
@@ -98,6 +98,7 @@ function Dashboard({ onStartWorkout, onNavigate }) {
|
|||||||
<nav className="nav-menu">
|
<nav className="nav-menu">
|
||||||
<button className="nav-btn active"><Icon name="home" size={18} /></button>
|
<button className="nav-btn active"><Icon name="home" size={18} /></button>
|
||||||
<button className="nav-btn" onClick={() => onNavigate('progress')}><Icon name="chart" size={18} /></button>
|
<button className="nav-btn" onClick={() => onNavigate('progress')}><Icon name="chart" size={18} /></button>
|
||||||
|
<button className="nav-btn" onClick={() => onNavigate('encyclopedia')} title="Exercise Encyclopedia"><Icon name="search" size={18} /></button>
|
||||||
<button className="nav-btn" onClick={() => onNavigate('profile')}><Icon name="user" size={18} /></button>
|
<button className="nav-btn" onClick={() => onNavigate('profile')}><Icon name="user" size={18} /></button>
|
||||||
<button className="nav-btn logout" onClick={logout}><Icon name="logout" size={18} /></button>
|
<button className="nav-btn logout" onClick={logout}><Icon name="logout" size={18} /></button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="edit-page">
|
||||||
|
<div className="page-header">
|
||||||
|
<button className="back-btn" onClick={onBack}>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<h1>Exercise Encyclopedia</h1>
|
||||||
|
<div style={{ width: 70 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="edit-main">
|
||||||
|
<div className="workout-meta-card">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search exercises..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="encyclopedia-search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="workout-meta-card">
|
||||||
|
<p>Loading exercises...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-banner">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="edit-exercises-list">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="workout-meta-card">
|
||||||
|
<p>No exercises found.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filtered.map(exercise => (
|
||||||
|
<div
|
||||||
|
key={exercise.id}
|
||||||
|
className={`edit-exercise-card${selected?.id === exercise.id ? ' exercise-selected' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="edit-card-header"
|
||||||
|
onClick={() => setSelected(selected?.id === exercise.id ? null : exercise)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div className="edit-card-info">
|
||||||
|
<h3>{exercise.name}</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
{exercise.difficulty && (
|
||||||
|
<span className="muscle-group">{exercise.difficulty}</span>
|
||||||
|
)}
|
||||||
|
{(exercise.muscle_groups || []).map(mg => (
|
||||||
|
<span key={mg} className="muscle-group">{mg}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{exercise.description && (
|
||||||
|
<p style={{ margin: '0.5rem 0 0', fontSize: '0.875rem', color: '#666' }}>
|
||||||
|
{exercise.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{ color: '#999', fontSize: '1.25rem' }}>
|
||||||
|
{selected?.id === exercise.id ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected?.id === exercise.id && (
|
||||||
|
<div className="exercise-detail-expanded">
|
||||||
|
{exercise.instructions && (
|
||||||
|
<div className="exercise-instructions">
|
||||||
|
<h4>Instructions</h4>
|
||||||
|
<p>{exercise.instructions}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ExerciseResearchPanel
|
||||||
|
exerciseId={exercise.id}
|
||||||
|
exerciseName={exercise.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExerciseEncyclopediaPage
|
||||||
@@ -484,3 +484,169 @@
|
|||||||
justify-content: space-between;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user