Files
rideaware-ui/src/components/WorkoutLibraryDetail.vue
2026-01-21 19:04:11 -06:00

593 lines
14 KiB
Vue

<template>
<div class="workout-detail-page">
<ModernNavbar />
<div class="workout-detail-content">
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<span>Loading workout...</span>
</div>
<div v-else-if="error" class="error-state">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="8" x2="12" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="16" x2="12.01" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>{{ error }}</span>
<router-link to="/workouts" class="btn-modern btn-modern-secondary">
Back to Library
</router-link>
</div>
<template v-else-if="workout">
<div class="detail-header">
<router-link to="/workouts" class="back-link">
<svg viewBox="0 0 24 24" fill="none">
<path d="M19 12H5M12 19L5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back to Library
</router-link>
<div class="header-row">
<div class="header-info">
<div class="workout-badges">
<div class="workout-type" :class="`type-${workout.type}`">
{{ formatType(workout.type) }}
</div>
<div v-if="workout.category" class="workout-category">
{{ formatCategory(workout.category) }}
</div>
<div v-if="workout.difficulty" class="workout-difficulty" :class="`difficulty-${workout.difficulty}`">
{{ formatDifficulty(workout.difficulty) }}
</div>
</div>
<h1>{{ workout.name }}</h1>
<p v-if="workout.description">{{ workout.description }}</p>
</div>
<div class="header-actions">
<FavoriteButton
:is-favorited="store.isFavorited(workout.id)"
@toggle="toggleFavorite"
/>
<button @click="useWorkout" class="btn-modern btn-modern-primary">
<svg viewBox="0 0 24 24" fill="none" class="btn-icon">
<polygon points="5,3 19,12 5,21" fill="currentColor"/>
</svg>
Use Workout
</button>
</div>
</div>
</div>
<div class="detail-body">
<div class="main-column">
<div class="card-modern">
<h2>Workout Structure</h2>
<IntervalDisplay
:structure="workout.structure"
:show-legend="true"
:show-details="true"
/>
</div>
</div>
<div class="side-column">
<div class="card-modern stats-card">
<h3>Workout Stats</h3>
<div class="stat-row">
<span class="stat-label">Duration</span>
<span class="stat-value">{{ workout.duration_minutes }} min</span>
</div>
<div class="stat-row">
<span class="stat-label">Intensity Factor</span>
<span class="stat-value">{{ formatIF(workout.intensity_factor) }}</span>
</div>
<div class="stat-row">
<span class="stat-label">TSS</span>
<span class="stat-value">{{ workout.tss || '—' }}</span>
</div>
<div class="stat-row">
<span class="stat-label">Times Used</span>
<span class="stat-value">{{ workout.use_count || 0 }}</span>
</div>
</div>
<div class="card-modern rating-card">
<h3>Rating</h3>
<div class="rating-display">
<StarRating
:rating="workout.average_rating || 0"
:readonly="true"
size="large"
/>
<span class="rating-text">
{{ (workout.average_rating || 0).toFixed(1) }}
<span class="rating-count">({{ workout.rating_count || 0 }} ratings)</span>
</span>
</div>
<div class="user-rating" v-if="!workout.user_rating">
<p>Rate this workout:</p>
<StarRating
:rating="userRating"
@rate="submitRating"
size="large"
/>
</div>
<div class="user-rating rated" v-else>
<p>Your rating:</p>
<StarRating :rating="workout.user_rating" :readonly="true" size="large" />
</div>
</div>
<div class="card-modern" v-if="workout.is_system">
<div class="system-info">
<svg viewBox="0 0 24 24" fill="none">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="2"/>
<path d="M12 16V12M12 8H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>This is a system workout designed by RideAware coaches.</span>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ModernNavbar from './ModernNavbar.vue'
import IntervalDisplay from './workout/IntervalDisplay.vue'
import StarRating from './workout/StarRating.vue'
import FavoriteButton from './workout/FavoriteButton.vue'
import { useWorkoutLibraryStore } from '@/stores/workoutLibrary'
const route = useRoute()
const router = useRouter()
const store = useWorkoutLibraryStore()
const workout = ref(null)
const loading = ref(true)
const error = ref('')
const userRating = ref(0)
const typeLabels = {
endurance: 'Endurance',
tempo: 'Tempo',
threshold: 'Threshold',
vo2max: 'VO2 Max',
sprint: 'Sprint',
recovery: 'Recovery',
climbing: 'Climbing',
interval: 'Interval',
freeride: 'Free Ride',
race: 'Race'
}
const categoryLabels = {
base: 'Base',
build: 'Build',
peak: 'Peak',
recovery: 'Recovery',
test: 'Test',
fun: 'Fun'
}
const difficultyLabels = {
beginner: 'Beginner',
intermediate: 'Intermediate',
advanced: 'Advanced',
expert: 'Expert'
}
function formatType(type) {
return typeLabels[type] || type
}
function formatCategory(category) {
return categoryLabels[category] || category
}
function formatDifficulty(difficulty) {
return difficultyLabels[difficulty] || difficulty
}
function formatIF(value) {
if (!value) return '—'
return value.toFixed(2)
}
async function loadWorkout() {
loading.value = true
error.value = ''
try {
const workoutId = route.params.workoutId
workout.value = await store.fetchWorkout(workoutId)
await store.fetchFavorites()
} catch (err) {
error.value = err.response?.data?.error || 'Failed to load workout'
} finally {
loading.value = false
}
}
async function toggleFavorite() {
try {
await store.toggleFavorite(workout.value.id)
} catch (err) {
console.error('Failed to toggle favorite:', err)
}
}
async function submitRating(rating) {
try {
await store.rateWorkout(workout.value.id, rating)
workout.value.user_rating = rating
} catch (err) {
console.error('Failed to submit rating:', err)
}
}
async function useWorkout() {
try {
await store.recordUsage(workout.value.id)
router.push(`/training?workout=${workout.value.id}`)
} catch (err) {
console.error('Failed to record usage:', err)
}
}
onMounted(() => {
loadWorkout()
})
</script>
<style scoped>
.workout-detail-page {
min-height: 100vh;
background: var(--color-background);
}
.workout-detail-content {
margin-left: 240px;
padding: var(--spacing-xl);
transition: margin-left var(--transition-base);
}
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-3xl);
text-align: center;
}
.loading-state .spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: var(--spacing-md);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-state svg {
width: 48px;
height: 48px;
color: var(--color-text-secondary);
margin-bottom: var(--spacing-md);
}
.error-state span {
color: var(--color-danger);
margin-bottom: var(--spacing-md);
}
.detail-header {
margin-bottom: var(--spacing-xl);
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--color-text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-lg);
transition: color var(--transition-base);
}
.back-link:hover {
color: var(--color-primary);
}
.back-link svg {
width: 18px;
height: 18px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-xl);
}
.header-info {
flex: 1;
}
.workout-badges {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
margin-bottom: var(--spacing-sm);
}
.workout-type,
.workout-category,
.workout-difficulty {
display: inline-block;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.type-endurance {
background: rgba(46, 204, 113, 0.15);
color: #27ae60;
}
.type-tempo {
background: rgba(52, 152, 219, 0.15);
color: #2980b9;
}
.type-threshold {
background: rgba(241, 196, 15, 0.15);
color: #f39c12;
}
.type-vo2max {
background: rgba(231, 76, 60, 0.15);
color: #c0392b;
}
.type-sprint {
background: rgba(155, 89, 182, 0.15);
color: #8e44ad;
}
.type-recovery {
background: rgba(149, 165, 166, 0.15);
color: #7f8c8d;
}
.type-climbing {
background: rgba(230, 126, 34, 0.15);
color: #d35400;
}
.type-interval {
background: rgba(26, 188, 156, 0.15);
color: #16a085;
}
.type-freeride {
background: rgba(52, 73, 94, 0.15);
color: #2c3e50;
}
.type-race {
background: rgba(192, 57, 43, 0.15);
color: #c0392b;
}
.workout-category {
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
}
.difficulty-beginner {
background: rgba(46, 204, 113, 0.1);
color: #27ae60;
}
.difficulty-intermediate {
background: rgba(241, 196, 15, 0.1);
color: #f39c12;
}
.difficulty-advanced {
background: rgba(230, 126, 34, 0.1);
color: #d35400;
}
.difficulty-expert {
background: rgba(231, 76, 60, 0.1);
color: #c0392b;
}
.header-info h1 {
margin: 0 0 var(--spacing-sm);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.header-info p {
margin: 0;
color: var(--color-text-secondary);
line-height: 1.6;
}
.header-actions {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.btn-icon {
width: 16px;
height: 16px;
margin-right: var(--spacing-xs);
}
.detail-body {
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--spacing-xl);
}
.main-column {
min-width: 0;
}
.card-modern {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.card-modern:last-child {
margin-bottom: 0;
}
.card-modern h2 {
margin: 0 0 var(--spacing-lg);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.card-modern h3 {
margin: 0 0 var(--spacing-md);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.stats-card .stat-row {
display: flex;
justify-content: space-between;
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--color-border);
}
.stats-card .stat-row:last-child {
border-bottom: none;
}
.stat-label {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.stat-value {
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.rating-display {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.rating-text {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.rating-count {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--color-text-secondary);
}
.user-rating {
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-border);
}
.user-rating p {
margin: 0 0 var(--spacing-sm);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.user-rating.rated {
opacity: 0.7;
}
.system-info {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.system-info svg {
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--color-primary);
}
@media (max-width: 1024px) {
.detail-body {
grid-template-columns: 1fr;
}
.side-column {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--spacing-lg);
}
.side-column .card-modern {
margin-bottom: 0;
}
}
@media (max-width: 768px) {
.workout-detail-content {
margin-left: 0;
padding: var(--spacing-lg);
}
.header-row {
flex-direction: column;
}
.header-actions {
width: 100%;
justify-content: flex-start;
}
.side-column {
grid-template-columns: 1fr;
}
}
</style>