Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf372b28f5 | |||
| 83ccd6c601 | |||
| 53f026aee2 | |||
| 994cc9e984 |
+6
-103
@@ -1,105 +1,8 @@
|
|||||||
{
|
{
|
||||||
"lastRun": "2026-03-02T09:25:00Z",
|
"lastRun": "2026-03-02T15:11:00Z",
|
||||||
"status": "ready",
|
"status": "blocked",
|
||||||
"currentPhase": "05",
|
"blockedReason": "Gemini API quota exceeded (free tier limit)",
|
||||||
"currentPhaseName": "Exercise Encyclopedia - AI-Powered Exercise Database",
|
"result": "Task 05-03 attempted: Frontend integration for research display. Subagent spawned but blocked by API quota.",
|
||||||
"nextPlannedTask": "phase-05-tasks",
|
"nextTask": "05-03: Frontend integration for research display (retry when API quota available)",
|
||||||
"result": "Phase 04 (Workout Modification) complete and rebased onto main. Ready to start Phase 05.",
|
"action": "REQUIRES HUMAN ACTION: Configure paid Gemini API key or wait for quota reset"
|
||||||
|
|
||||||
"phase05Tasks": [
|
|
||||||
{
|
|
||||||
"id": "05-01",
|
|
||||||
"name": "Exercise Database Schema & CRUD API",
|
|
||||||
"description": "Create exercise table with: id, name, primaryMuscles[], secondaryMuscles[], instructions, difficulty, equipment, videoUrl, sources[], addedBy, addedAt. Implement GET/POST/PUT/DELETE endpoints.",
|
|
||||||
"subtasks": [
|
|
||||||
"Create migration: exercises table",
|
|
||||||
"Implement API routes: GET /exercises, POST /exercises, PUT /exercises/:id, DELETE /exercises/:id",
|
|
||||||
"Add search: GET /exercises?search=clean-jerk&muscle=chest",
|
|
||||||
"Test with Postman/curl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "05-02",
|
|
||||||
"name": "Research Agent Integration",
|
|
||||||
"description": "When user searches for non-existent exercise (e.g., 'clean-jerk'), spawn research agent to find credible sources and structure data.",
|
|
||||||
"subtasks": [
|
|
||||||
"Setup exa-search integration (via exa-search skill)",
|
|
||||||
"Create research prompt template (exercises, muscles, technique)",
|
|
||||||
"Parse results into exercise object",
|
|
||||||
"Store in database automatically"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "05-03",
|
|
||||||
"name": "Video Generation (Demo Videos)",
|
|
||||||
"description": "Generate demo videos for exercises using Google Veo (via claude-multimedia skill).",
|
|
||||||
"subtasks": [
|
|
||||||
"Create video generation prompt template",
|
|
||||||
"Test Veo API integration",
|
|
||||||
"Store video URLs in exercise record"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "05-04",
|
|
||||||
"name": "Exercise Search & Add UI",
|
|
||||||
"description": "Frontend search component that triggers research on unknown exercises.",
|
|
||||||
"subtasks": [
|
|
||||||
"ExerciseSearchPage component with autocomplete",
|
|
||||||
"Show existing exercises from DB",
|
|
||||||
"Button to 'Research & Add' unknown exercises",
|
|
||||||
"Display research progress/status"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "05-05",
|
|
||||||
"name": "Exercise Detail View",
|
|
||||||
"description": "Display full exercise profile with muscles, instructions, demo video, difficulty, equipment.",
|
|
||||||
"subtasks": [
|
|
||||||
"ExerciseDetailPage component",
|
|
||||||
"Show muscle tags (highlight primary/secondary)",
|
|
||||||
"Render instructions with styling",
|
|
||||||
"Embed demo video player",
|
|
||||||
"Show metadata (difficulty, equipment, sources)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "05-06",
|
|
||||||
"name": "User Ratings & Community Feedback",
|
|
||||||
"description": "Allow users to rate exercises (1-5 stars) and leave feedback.",
|
|
||||||
"subtasks": [
|
|
||||||
"Add rating fields to exercise record",
|
|
||||||
"Implement rating UI (star picker)",
|
|
||||||
"POST /exercises/:id/ratings endpoint",
|
|
||||||
"Display average rating & user reviews"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
"phase04Verification": {
|
|
||||||
"status": "complete",
|
|
||||||
"completedFeatures": [
|
|
||||||
"Custom workout creation & editing",
|
|
||||||
"Reset to original (revert customizations)",
|
|
||||||
"Visual distinction (custom vs program)",
|
|
||||||
"Persistence with draft auto-save",
|
|
||||||
"Error handling & retry logic"
|
|
||||||
],
|
|
||||||
"gitCommits": "14 commits rebased onto main",
|
|
||||||
"stagedInProduction": true
|
|
||||||
},
|
|
||||||
|
|
||||||
"branchStructure": {
|
|
||||||
"main": "a72deba (Phase 02 baseline)",
|
|
||||||
"feature/03-design-polish": "f6e98ae (Design Polish — 50 commits, ready for PR)",
|
|
||||||
"feature/04-workout-modification": "fac53a3 (Workout Modification — 14 commits, rebased, ready for merge)",
|
|
||||||
"feature/05-exercise-encyclopedia": "fac53a3 (NEW — Starting from Phase 04 baseline)"
|
|
||||||
},
|
|
||||||
|
|
||||||
"recommendations": [
|
|
||||||
"05-01 is backend foundation — start there",
|
|
||||||
"05-02 requires exa-search skill (verify it's available)",
|
|
||||||
"05-03 requires claude-multimedia skill with Veo integration",
|
|
||||||
"05-04 & 05-05 depend on 05-01 API being ready",
|
|
||||||
"Consider E2E tests for research flow (05-02)"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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,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