feature/05-exercise-encyclopedia #4

Merged
sphinxen merged 28 commits from feature/05-exercise-encyclopedia into main 2026-03-06 12:29:20 +01:00
4 changed files with 751 additions and 128 deletions
Showing only changes of commit 6472eb8c6c - Show all commits
@@ -1,4 +1,5 @@
import { useState } from 'react'
import ResearchDisplay from './ResearchDisplay'
const API_URL = '/api'
@@ -33,73 +34,22 @@ function ExerciseResearchPanel({ exerciseId, exerciseName }) {
<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>
)}
<button
className={`btn ${research ? 'btn-secondary' : 'btn-primary'} research-btn`}
onClick={fetchResearch}
disabled={loading}
>
{loading ? 'Fetching…' : research ? 'Refresh' : 'Get Research'}
</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>
)}
<ResearchDisplay
loading={loading}
error={error}
data={research}
name={exerciseName}
onDismiss={() => setError(null)}
/>
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
function ResearchLoadingSkeleton({ exerciseName }) {
return (
<div className="rd-loading">
<div className="rd-spinner" aria-hidden="true" />
<span className="rd-loading-text">
Searching for information on <em>{exerciseName}</em>
</span>
</div>
)
}
function ResearchError({ message, onDismiss }) {
return (
<div className="rd-error" role="alert">
<span className="rd-error-icon" aria-hidden="true"></span>
<span className="rd-error-message">{message}</span>
{onDismiss && (
<button className="rd-dismiss" onClick={onDismiss} aria-label="Dismiss error">
×
</button>
)}
</div>
)
}
function ResearchSourceCard({ result, index }) {
return (
<li className="rd-source-item">
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className="rd-source-link"
>
<span className="rd-source-index">{index + 1}</span>
<span className="rd-source-title">{result.title}</span>
<span className="rd-source-arrow" aria-hidden="true"></span>
</a>
{result.snippet && (
<p className="rd-source-snippet">{result.snippet}</p>
)}
</li>
)
}
/**
* ResearchDisplay — pure presentational component.
*
* Props:
* loading {boolean} Show loading skeleton
* error {string} Error message to display
* data {object} Research data: { summary, results }
* name {string} Exercise name (shown during loading)
* onDismiss {function} Clear error callback
*/
function ResearchDisplay({ loading, error, data, name, onDismiss }) {
if (loading) {
return <ResearchLoadingSkeleton exerciseName={name} />
}
if (error) {
return <ResearchError message={error} onDismiss={onDismiss} />
}
if (!data) return null
const hasSummary = Boolean(data.summary)
const hasSources = Array.isArray(data.results) && data.results.length > 0
return (
<div className="rd-results">
{hasSummary && (
<div className="rd-summary">
<h4 className="rd-section-title">
<span className="rd-section-icon" aria-hidden="true">📋</span>
Summary
</h4>
<p className="rd-summary-text">{data.summary}</p>
</div>
)}
{hasSources && (
<div className="rd-sources">
<h4 className="rd-section-title">
<span className="rd-section-icon" aria-hidden="true">🔗</span>
Sources
<span className="rd-count">{data.results.length}</span>
</h4>
<ul className="rd-sources-list" aria-label="Research sources">
{data.results.map((result, i) => (
<ResearchSourceCard key={i} result={result} index={i} />
))}
</ul>
</div>
)}
{!hasSummary && !hasSources && (
<p className="rd-empty">No research data found for this exercise.</p>
)}
</div>
)
}
export default ResearchDisplay
@@ -0,0 +1,565 @@
/* ============================================
EXERCISE ENCYCLOPEDIA — Dark Theme
Uses CSS variables from index.css
============================================ */
/* Page shell */
.encyclopedia-page {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg-primary);
color: var(--text-primary);
}
/* Header */
.encyclopedia-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4) var(--space-5);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.encyclopedia-header h1 {
flex: 1;
text-align: center;
font-size: var(--font-xl);
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.encyclopedia-back-btn {
padding: var(--space-2) var(--space-4);
background: var(--bg-card);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: var(--font-sm);
font-weight: 500;
transition: all var(--transition-fast);
min-height: 40px;
display: flex;
align-items: center;
gap: var(--space-1);
}
.encyclopedia-back-btn:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
border-color: var(--border-hover);
}
/* Spacer keeps header balanced */
.encyclopedia-header-spacer {
width: 80px;
}
/* Main scrollable area */
.encyclopedia-main {
flex: 1;
overflow-y: auto;
padding: var(--space-4) var(--space-4) var(--space-8);
display: flex;
flex-direction: column;
gap: var(--space-3);
max-width: 720px;
width: 100%;
margin: 0 auto;
}
/* Search bar */
.encyclopedia-search-wrap {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.encyclopedia-search {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 16px;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
box-sizing: border-box;
}
.encyclopedia-search::placeholder {
color: var(--text-tertiary);
}
.encyclopedia-search:hover {
border-color: var(--border-hover);
}
.encyclopedia-search:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
/* State messages */
.encyclopedia-state {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-6) var(--space-4);
text-align: center;
color: var(--text-muted);
font-size: var(--font-sm);
}
.encyclopedia-error {
background: var(--error-subtle);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius-lg);
padding: var(--space-4);
color: var(--error);
font-size: var(--font-sm);
}
/* Exercise list */
.encyclopedia-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
/* Exercise card */
.exercise-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: border-color var(--transition-base);
}
.exercise-card:hover {
border-color: var(--border-hover);
}
.exercise-card.exercise-card--open {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-subtle);
}
.exercise-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: var(--space-4) var(--space-5);
cursor: pointer;
gap: var(--space-3);
user-select: none;
}
.exercise-card-header:hover .exercise-chevron {
color: var(--accent);
}
.exercise-card-info {
flex: 1;
min-width: 0;
}
.exercise-card-info h3 {
margin: 0 0 var(--space-2);
font-size: var(--font-base);
font-weight: 600;
color: var(--text-primary);
}
.exercise-card-tags {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
margin-bottom: var(--space-2);
}
.exercise-tag {
padding: var(--space-1) var(--space-2);
background: var(--bg-elevated);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-full);
font-size: var(--font-xs);
font-weight: 500;
}
.exercise-tag.exercise-tag--difficulty {
background: var(--accent-subtle);
color: var(--accent);
border-color: var(--accent-subtle);
}
.exercise-card-description {
margin: 0;
font-size: var(--font-sm);
color: var(--text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.exercise-chevron {
color: var(--text-muted);
font-size: var(--font-base);
transition: transform var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
margin-top: 2px;
}
.exercise-chevron--open {
transform: rotate(180deg);
color: var(--accent);
}
/* Expanded detail area */
.exercise-detail {
border-top: 1px solid var(--border);
padding: var(--space-4) var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
background: var(--bg-elevated);
}
.exercise-instructions h4 {
margin: 0 0 var(--space-2);
font-size: var(--font-xs);
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.exercise-instructions p {
margin: 0;
font-size: var(--font-sm);
color: var(--text-secondary);
line-height: 1.7;
}
/* ============================================
RESEARCH PANEL — Dark Theme
============================================ */
.research-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.research-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.research-panel-title {
margin: 0;
font-size: var(--font-xs);
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.research-btn {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--font-sm);
font-weight: 600;
min-height: 36px;
transition: all var(--transition-base);
}
.btn-primary.research-btn {
background: var(--accent);
color: #fff;
border: none;
box-shadow: 0 2px 8px rgba(255, 107, 74, 0.3);
}
.btn-primary.research-btn:hover:not(:disabled) {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.4);
}
.btn-secondary.research-btn {
background: var(--bg-elevated);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-secondary.research-btn:hover:not(:disabled) {
background: var(--bg-card-hover);
color: var(--text-primary);
border-color: var(--border-hover);
}
.btn-primary.research-btn:disabled,
.btn-secondary.research-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* ============================================
RESEARCH DISPLAY — rd- prefix
============================================ */
/* Loading state */
.rd-loading {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
color: var(--text-secondary);
font-size: var(--font-sm);
}
.rd-spinner {
width: 18px;
height: 18px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: rd-spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes rd-spin {
to { transform: rotate(360deg); }
}
.rd-loading-text em {
color: var(--text-primary);
font-style: normal;
font-weight: 500;
}
/* Error state */
.rd-error {
display: flex;
align-items: flex-start;
gap: var(--space-3);
background: var(--error-subtle);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
}
.rd-error-icon {
font-size: var(--font-base);
flex-shrink: 0;
}
.rd-error-message {
flex: 1;
font-size: var(--font-sm);
color: var(--error);
line-height: 1.5;
}
.rd-dismiss {
background: transparent;
border: none;
color: var(--error);
font-size: var(--font-xl);
line-height: 1;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity var(--transition-fast);
flex-shrink: 0;
}
.rd-dismiss:hover {
opacity: 1;
}
/* Results */
.rd-results {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.rd-section-title {
margin: 0 0 var(--space-3);
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.rd-section-icon {
font-size: var(--font-base);
}
.rd-count {
margin-left: auto;
background: var(--bg-elevated);
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: var(--radius-full);
padding: 1px var(--space-2);
font-size: var(--font-xs);
font-weight: 600;
}
/* Summary */
.rd-summary {
padding: var(--space-4);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.rd-summary-text {
margin: 0;
font-size: var(--font-sm);
color: var(--text-secondary);
line-height: 1.7;
}
/* Sources */
.rd-sources-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.rd-source-item {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
transition: border-color var(--transition-fast);
}
.rd-source-item:hover {
border-color: var(--border-hover);
}
.rd-source-link {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
text-decoration: none;
color: var(--accent);
transition: background var(--transition-fast);
}
.rd-source-link:hover {
background: var(--accent-subtle);
}
.rd-source-index {
width: 20px;
height: 20px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-xs);
font-weight: 700;
color: var(--text-muted);
flex-shrink: 0;
}
.rd-source-title {
flex: 1;
font-size: var(--font-sm);
font-weight: 500;
line-height: 1.4;
word-break: break-word;
}
.rd-source-arrow {
font-size: var(--font-sm);
opacity: 0.6;
flex-shrink: 0;
}
.rd-source-snippet {
margin: 0;
padding: 0 var(--space-4) var(--space-3);
font-size: var(--font-xs);
color: var(--text-muted);
line-height: 1.6;
border-top: 1px solid var(--border);
padding-top: var(--space-2);
}
/* Empty state */
.rd-empty {
margin: 0;
padding: var(--space-4);
text-align: center;
font-size: var(--font-sm);
color: var(--text-muted);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
/* ============================================
MOBILE
============================================ */
@media (max-width: 600px) {
.encyclopedia-header {
padding: var(--space-3) var(--space-4);
}
.encyclopedia-main {
padding: var(--space-3) var(--space-3) var(--space-8);
}
.exercise-card-header {
padding: var(--space-3) var(--space-4);
}
.exercise-detail {
padding: var(--space-3) var(--space-4);
}
.rd-source-link {
padding: var(--space-3);
}
.rd-source-snippet {
padding: var(--space-2) var(--space-3) var(--space-3);
}
}
+67 -63
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import ExerciseResearchPanel from '../components/ExerciseResearchPanel'
import './WorkoutEditPage.css'
import './ExerciseEncyclopediaPage.css'
const API_URL = '/api'
@@ -31,96 +31,100 @@ function ExerciseEncyclopediaPage({ onBack }) {
ex.name.toLowerCase().includes(search.toLowerCase())
)
const toggle = (exercise) =>
setSelected(prev => (prev?.id === exercise.id ? null : exercise))
return (
<div className="edit-page">
<div className="page-header">
<button className="back-btn" onClick={onBack}>
<div className="encyclopedia-page">
<header className="encyclopedia-header">
<button className="encyclopedia-back-btn" onClick={onBack}>
Back
</button>
<h1>Exercise Encyclopedia</h1>
<div style={{ width: 70 }} />
</div>
<div className="encyclopedia-header-spacer" />
</header>
<div className="edit-main">
<div className="workout-meta-card">
<main className="encyclopedia-main">
<div className="encyclopedia-search-wrap">
<input
type="text"
placeholder="Search exercises..."
placeholder="Search exercises"
value={search}
onChange={e => setSearch(e.target.value)}
className="encyclopedia-search"
aria-label="Search exercises"
/>
</div>
{loading && (
<div className="workout-meta-card">
<p>Loading exercises...</p>
</div>
<div className="encyclopedia-state">Loading exercises</div>
)}
{error && (
<div className="error-banner">
<span>{error}</span>
</div>
<div className="encyclopedia-error" role="alert">{error}</div>
)}
{!loading && !error && (
<div className="edit-exercises-list">
<div className="encyclopedia-list">
{filtered.length === 0 && (
<div className="workout-meta-card">
<p>No exercises found.</p>
</div>
<div className="encyclopedia-state">No exercises found.</div>
)}
{filtered.map(exercise => (
<div
key={exercise.id}
className={`edit-exercise-card${selected?.id === exercise.id ? ' exercise-selected' : ''}`}
>
{filtered.map(exercise => {
const isOpen = selected?.id === exercise.id
return (
<div
className="edit-card-header"
onClick={() => setSelected(selected?.id === exercise.id ? null : exercise)}
style={{ cursor: 'pointer' }}
key={exercise.id}
className={`exercise-card${isOpen ? ' exercise-card--open' : ''}`}
>
<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
className="exercise-card-header"
onClick={() => toggle(exercise)}
role="button"
aria-expanded={isOpen}
tabIndex={0}
onKeyDown={e => e.key === 'Enter' && toggle(exercise)}
>
<div className="exercise-card-info">
<h3>{exercise.name}</h3>
<div className="exercise-card-tags">
{exercise.difficulty && (
<span className="exercise-tag exercise-tag--difficulty">
{exercise.difficulty}
</span>
)}
{(exercise.muscle_groups || []).map(mg => (
<span key={mg} className="exercise-tag">{mg}</span>
))}
</div>
)}
<ExerciseResearchPanel
exerciseId={exercise.id}
exerciseName={exercise.name}
/>
{exercise.description && (
<p className="exercise-card-description">{exercise.description}</p>
)}
</div>
<span className={`exercise-chevron${isOpen ? ' exercise-chevron--open' : ''}`}>
</span>
</div>
)}
</div>
))}
{isOpen && (
<div className="exercise-detail">
{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>
</main>
</div>
)
}