5 Commits

Author SHA1 Message Date
clawd bf372b28f5 checkpoint: 05-03 completed 2026-03-02 19:22:50 +01:00
clawd 83ccd6c601 feat(05-03): Exercise research frontend integration
- Add ExerciseResearchPanel component with Get Research button, loading state, summary display, and source links
- Add ExerciseEncyclopediaPage with exercise list and integrated research panel
- Wire encyclopedia view into App.jsx navigation
- Add encyclopedia nav button to Dashboard
- Add CSS for research panel and encyclopedia search

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 19:20:40 +01:00
clawd 53f026aee2 feat(05-02): exa-search research integration 2026-03-02 14:10:32 +01:00
clawd 994cc9e984 feat(05-01): Exercise database schema + CRUD API 2026-03-02 13:03:30 +01:00
clawd 5a9ea9c9a8 checkpoint: phase 05 (exercise encyclopedia) ready to start
Phase 04 (workout modification) complete:
- 14 commits rebased onto main
- All features verified and staged
- Ready for merge

Phase 05 structure defined:
- 05-01: Exercise DB schema & CRUD API
- 05-02: AI research integration (exa-search)
- 05-03: Demo video generation (Veo)
- 05-04: Search & Add UI
- 05-05: Exercise detail view
- 05-06: User ratings & feedback

Next: PM starts 05-01 (backend foundation)
2026-03-02 09:26:32 +01:00
14 changed files with 867 additions and 23 deletions
+6 -18
View File
@@ -1,20 +1,8 @@
{ {
"lastRun": "2026-03-01T20:42:00+01:00", "lastRun": "2026-03-02T15:11:00Z",
"status": "completed", "status": "blocked",
"phase": "04-workout-modification", "blockedReason": "Gemini API quota exceeded (free tier limit)",
"activeTask": "04-05-reset-to-original", "result": "Task 05-03 attempted: Frontend integration for research display. Subagent spawned but blocked by API quota.",
"tasksCompleted": ["01-input-ux", "02-flexible-sets", "03-design-polish", "04-01-schema-migration", "04-02-backend-api", "04-03-frontend-workout-edit", "04-04-visual-distinction", "04-05-reset-to-original"], "nextTask": "05-03: Frontend integration for research display (retry when API quota available)",
"nextTask": "04-06-persistence-improvements", "action": "REQUIRES HUMAN ACTION: Configure paid Gemini API key or wait for quota reset"
"agentSession": "local-exec",
"agentType": "gravl-pm-cron",
"spawnTime": "2026-03-01T20:42:00+01:00",
"result": "Phase 04-05 complete. Reset to Original feature fully implemented. Changes: 1) Added refresh button to custom workout cards (visible only on 'Anpassad' workouts). 2) Implemented handleResetClick + handleConfirmReset flow with confirmation dialog ('Är du säker? Dina ändringar kommer att försvinna...'). 3) DELETE /api/custom-workouts/:id endpoint verified (exists in backend). 4) Added CSS styling: .reset-btn (orange icon button with hover effects), .success-message (green slide-down animation), .modal-overlay/.modal-dialog/.modal-btn (reusable confirmation dialog). 5) Added refresh icon to Icons.jsx SVG library. Frontend build successful with no errors.",
"notes": "Task 04-05 complete. Custom workouts can now be reset to original program versions. User gets confirmation dialog before deletion. UI updates show badge change from 'Anpassad' to 'Program' after reset. Next: 04-06 (persistence improvements or advanced features like workout export/backup).",
"filesModified": [
"frontend/src/pages/WorkoutSelectPage.jsx",
"frontend/src/App.css",
"frontend/src/components/Icons.jsx"
],
"buildStatus": "success",
"buildTime": "3.59s"
} }
+4 -2
View File
@@ -5,7 +5,8 @@
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "nodemon src/index.js" "dev": "nodemon src/index.js",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@@ -15,6 +16,7 @@
"pg": "^8.11.3" "pg": "^8.11.3"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2",
"supertest": "^6.3.3"
} }
} }
+8 -2
View File
@@ -3,6 +3,8 @@ const cors = require('cors');
const { Pool } = require('pg'); const { Pool } = require('pg');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { createExerciseResearchRouter } = require('./routes/exerciseResearch');
const { searchExerciseResearch } = require('./services/exaSearch');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@@ -18,6 +20,7 @@ const pool = new Pool({
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch: searchExerciseResearch }));
const authMiddleware = (req, res, next) => { const authMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1]; const token = req.headers.authorization?.split(' ')[1];
@@ -394,9 +397,11 @@ app.get('/api/today/:programId', async (req, res) => {
} }
}); });
app.listen(PORT, '0.0.0.0', () => { if (require.main === module) {
app.listen(PORT, '0.0.0.0', () => {
console.log(`Gravl API running on port ${PORT}`); console.log(`Gravl API running on port ${PORT}`);
}); });
}
// ============================================ // ============================================
// Custom Workouts API (Phase 4: Workout Modification) // Custom Workouts API (Phase 4: Workout Modification)
@@ -764,3 +769,4 @@ app.delete('/api/logs', async (req, res) => {
} }
}); });
module.exports = app;
+81
View File
@@ -0,0 +1,81 @@
const express = require('express');
const normalizeQuery = (exerciseName, body) => {
if (body && typeof body.query === 'string' && body.query.trim()) {
return body.query.trim();
}
if (body && typeof body.name === 'string' && body.name.trim()) {
return body.name.trim();
}
return `${exerciseName} exercise`;
};
const createExerciseResearchRouter = ({ pool, exaSearch }) => {
if (!pool || typeof pool.query !== 'function') {
throw new Error('Pool with query function is required');
}
if (!exaSearch || typeof exaSearch !== 'function') {
throw new Error('exaSearch function is required');
}
const router = express.Router();
router.post('/:id/research', async (req, res) => {
try {
const exerciseId = Number.parseInt(req.params.id, 10);
if (!Number.isInteger(exerciseId)) {
return res.status(400).json({ error: 'Exercise id must be an integer' });
}
const exerciseResult = await pool.query(
'SELECT id, name, description, muscle_groups, difficulty, equipment_needed FROM exercises WHERE id = $1',
[exerciseId]
);
if (!exerciseResult.rows.length) {
return res.status(404).json({ error: 'Exercise not found' });
}
const exercise = exerciseResult.rows[0];
const query = normalizeQuery(exercise.name, req.body);
const requestedResults = req.body?.num_results;
const numResults = Number.isInteger(requestedResults) && requestedResults > 0
? Math.min(requestedResults, 10)
: 5;
const { summary, results } = await exaSearch({ query, numResults });
let researchRecord = null;
try {
const insertResult = await pool.query(
`INSERT INTO research_results (exercise_id, query, summary, results, provider)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at`,
[exerciseId, query, summary, JSON.stringify(results), 'exa']
);
researchRecord = insertResult.rows[0] || null;
} catch (err) {
console.warn('Failed to store research results:', err.message);
}
res.json({
exercise,
query,
summary,
results,
stored: researchRecord
});
} catch (err) {
console.error('Error running exercise research:', err);
res.status(500).json({ error: 'Failed to fetch research' });
}
});
return router;
};
module.exports = {
createExerciseResearchRouter
};
+173
View File
@@ -0,0 +1,173 @@
const express = require('express');
const pool = require('../db/pool');
const router = express.Router();
// Validation helper
const validateExercise = (data) => {
const errors = [];
if (!data.name || typeof data.name !== 'string' || !data.name.trim()) {
errors.push('name is required and must be non-empty');
}
if (data.difficulty && !['beginner', 'intermediate', 'advanced'].includes(data.difficulty)) {
errors.push('difficulty must be beginner, intermediate, or advanced');
}
if (data.muscle_groups && !Array.isArray(data.muscle_groups)) {
errors.push('muscle_groups must be an array');
}
if (data.equipment_needed && !Array.isArray(data.equipment_needed)) {
errors.push('equipment_needed must be an array');
}
return errors;
};
// CREATE - Add new exercise
router.post('/', async (req, res) => {
try {
const { name, description, instructions, muscle_groups, difficulty, equipment_needed, video_url, created_by } = req.body;
const errors = validateExercise({ name, difficulty, muscle_groups, equipment_needed });
if (errors.length > 0) {
return res.status(400).json({ error: 'Validation failed', details: errors });
}
const query = `
INSERT INTO exercises (name, description, instructions, muscle_groups, difficulty, equipment_needed, video_url, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const result = await pool.query(query, [
name.trim(),
description || null,
instructions || null,
muscle_groups || [],
difficulty || 'intermediate',
equipment_needed || [],
video_url || null,
created_by || 'system'
]);
res.status(201).json(result.rows[0]);
} catch (err) {
if (err.code === '23505') {
return res.status(409).json({ error: 'Exercise name already exists' });
}
console.error('Error creating exercise:', err);
res.status(500).json({ error: 'Failed to create exercise' });
}
});
// READ - Get all exercises with search/filter
router.get('/', async (req, res) => {
try {
const { search, difficulty, muscle_group, limit = 50, offset = 0 } = req.query;
let query = 'SELECT * FROM exercises WHERE 1=1';
const params = [];
let paramCount = 1;
if (search) {
query += ` AND (name ILIKE $${paramCount} OR description ILIKE $${paramCount})`;
params.push(`%${search}%`);
paramCount++;
}
if (difficulty) {
query += ` AND difficulty = $${paramCount}`;
params.push(difficulty);
paramCount++;
}
if (muscle_group) {
query += ` AND $${paramCount} = ANY(muscle_groups)`;
params.push(muscle_group);
paramCount++;
}
query += ` ORDER BY name ASC LIMIT $${paramCount} OFFSET $${paramCount + 1}`;
params.push(parseInt(limit), parseInt(offset));
const result = await pool.query(query, params);
res.json(result.rows);
} catch (err) {
console.error('Error fetching exercises:', err);
res.status(500).json({ error: 'Failed to fetch exercises' });
}
});
// READ - Get single exercise
router.get('/:id', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM exercises WHERE id = $1', [req.params.id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Exercise not found' });
}
res.json(result.rows[0]);
} catch (err) {
console.error('Error fetching exercise:', err);
res.status(500).json({ error: 'Failed to fetch exercise' });
}
});
// UPDATE - Modify exercise
router.put('/:id', async (req, res) => {
try {
const { name, description, instructions, muscle_groups, difficulty, equipment_needed, video_url } = req.body;
const errors = validateExercise({ name, difficulty, muscle_groups, equipment_needed });
if (errors.length > 0) {
return res.status(400).json({ error: 'Validation failed', details: errors });
}
const query = `
UPDATE exercises
SET name = $1, description = $2, instructions = $3, muscle_groups = $4,
difficulty = $5, equipment_needed = $6, video_url = $7, updated_at = CURRENT_TIMESTAMP
WHERE id = $8
RETURNING *
`;
const result = await pool.query(query, [
name.trim(),
description || null,
instructions || null,
muscle_groups || [],
difficulty || 'intermediate',
equipment_needed || [],
video_url || null,
req.params.id
]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Exercise not found' });
}
res.json(result.rows[0]);
} catch (err) {
if (err.code === '23505') {
return res.status(409).json({ error: 'Exercise name already exists' });
}
console.error('Error updating exercise:', err);
res.status(500).json({ error: 'Failed to update exercise' });
}
});
// DELETE - Remove exercise
router.delete('/:id', async (req, res) => {
try {
const result = await pool.query('DELETE FROM exercises WHERE id = $1 RETURNING *', [req.params.id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Exercise not found' });
}
res.json({ message: 'Exercise deleted', id: req.params.id });
} catch (err) {
console.error('Error deleting exercise:', err);
res.status(500).json({ error: 'Failed to delete exercise' });
}
});
module.exports = router;
+75
View File
@@ -0,0 +1,75 @@
const DEFAULT_EXA_API_URL = 'https://api.exa.ai/search';
const buildSummary = (results) => {
if (!results || results.length === 0) {
return '';
}
const snippets = results
.map((result) => result.snippet || result.highlight)
.filter(Boolean);
if (snippets.length === 0) {
return results
.slice(0, 3)
.map((result) => result.title)
.filter(Boolean)
.join(' · ');
}
return snippets.slice(0, 3).join(' ');
};
const searchExerciseResearch = async ({ query, numResults = 5 }) => {
if (!query || typeof query !== 'string') {
throw new Error('Query must be a non-empty string');
}
const apiKey = process.env.EXA_API_KEY;
if (!apiKey) {
throw new Error('EXA_API_KEY is not configured');
}
const apiUrl = process.env.EXA_API_URL || DEFAULT_EXA_API_URL;
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': apiKey
},
body: JSON.stringify({
query,
numResults,
type: 'neural',
useAutoprompt: true
})
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Exa search failed: ${response.status} ${text}`);
}
const data = await response.json();
const results = (data.results || []).map((result) => ({
id: result.id,
title: result.title,
url: result.url,
snippet: Array.isArray(result.highlights) && result.highlights.length > 0
? result.highlights[0]
: result.snippet,
highlight: result.highlight,
publishedDate: result.publishedDate,
score: result.score
}));
return {
summary: buildSummary(results),
results
};
};
module.exports = {
searchExerciseResearch
};
@@ -0,0 +1,80 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const express = require('express');
const request = require('supertest');
const { createExerciseResearchRouter } = require('../../src/routes/exerciseResearch');
const buildPoolMock = ({ exerciseRow }) => ({
query: async (text) => {
if (text.includes('FROM exercises')) {
return { rows: exerciseRow ? [exerciseRow] : [] };
}
if (text.includes('INSERT INTO research_results')) {
return { rows: [{ id: 1, created_at: '2026-03-02T00:00:00.000Z' }] };
}
return { rows: [] };
}
});
const buildApp = ({ pool, exaSearch }) => {
const app = express();
app.use(express.json());
app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch }));
return app;
};
test('Exercise research returns summary and results', async () => {
const pool = buildPoolMock({
exerciseRow: {
id: 1,
name: 'Bench Press',
description: 'Barbell press'
}
});
const exaSearch = async ({ query, numResults }) => ({
summary: `Summary for ${query} (${numResults})`,
results: [
{ title: 'Guide', url: 'https://example.com', snippet: 'Bench press form.' }
]
});
const app = buildApp({ pool, exaSearch });
const response = await request(app)
.post('/api/exercises/1/research')
.send({ query: 'Bench press technique', num_results: 3 });
assert.equal(response.statusCode, 200);
assert.equal(response.body.exercise.id, 1);
assert.equal(response.body.summary, 'Summary for Bench press technique (3)');
assert.equal(response.body.results.length, 1);
assert.ok(response.body.stored);
});
test('Exercise research returns 404 when exercise missing', async () => {
const pool = buildPoolMock({ exerciseRow: null });
const exaSearch = async () => {
throw new Error('Should not call exa');
};
const app = buildApp({ pool, exaSearch });
const response = await request(app)
.post('/api/exercises/999/research')
.send({ query: 'Missing' });
assert.equal(response.statusCode, 404);
assert.equal(response.body.error, 'Exercise not found');
});
test('Exercise research validates id', async () => {
const pool = buildPoolMock({ exerciseRow: null });
const exaSearch = async () => ({ summary: '', results: [] });
const app = buildApp({ pool, exaSearch });
const response = await request(app)
.post('/api/exercises/not-a-number/research')
.send({ query: 'Bench' });
assert.equal(response.statusCode, 400);
assert.equal(response.body.error, 'Exercise id must be an integer');
});
@@ -0,0 +1,18 @@
-- Create exercises table for exercise encyclopedia
CREATE TABLE IF NOT EXISTS exercises (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
instructions TEXT,
muscle_groups TEXT[] DEFAULT ARRAY[]::text[],
difficulty VARCHAR(20) DEFAULT 'intermediate' CHECK (difficulty IN ('beginner', 'intermediate', 'advanced')),
equipment_needed TEXT[] DEFAULT ARRAY[]::text[],
video_url VARCHAR(255),
created_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_exercises_name ON exercises(name);
CREATE INDEX idx_exercises_difficulty ON exercises(difficulty);
CREATE INDEX idx_exercises_muscle_groups ON exercises USING GIN(muscle_groups);
@@ -0,0 +1,13 @@
-- Store exercise research summaries and sources
CREATE TABLE IF NOT EXISTS research_results (
id SERIAL PRIMARY KEY,
exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
query TEXT NOT NULL,
summary TEXT,
results JSONB NOT NULL DEFAULT '[]'::jsonb,
provider VARCHAR(50) NOT NULL DEFAULT 'exa',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_research_results_exercise_id ON research_results(exercise_id);
CREATE INDEX IF NOT EXISTS idx_research_results_created_at ON research_results(created_at);
+6
View File
@@ -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
+1
View File
@@ -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
+166
View File
@@ -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;
}