Add Workout Library feature with browse, create, and favorites
This commit is contained in:
499
src/components/WorkoutLibraryDetail.vue
Normal file
499
src/components/WorkoutLibraryDetail.vue
Normal file
@@ -0,0 +1,499 @@
|
||||
<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-category" :class="`category-${workout.category}`">
|
||||
{{ formatCategory(workout.category) }}
|
||||
</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
|
||||
:intervals="intervals"
|
||||
: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 intervals = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const userRating = ref(0)
|
||||
|
||||
const categoryLabels = {
|
||||
endurance: 'Endurance',
|
||||
threshold: 'Threshold',
|
||||
vo2max: 'VO2 Max',
|
||||
sprint: 'Sprint',
|
||||
recovery: 'Recovery'
|
||||
}
|
||||
|
||||
function formatCategory(category) {
|
||||
return categoryLabels[category] || category
|
||||
}
|
||||
|
||||
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)
|
||||
intervals.value = await store.fetchWorkoutIntervals(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-category {
|
||||
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;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.category-endurance {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.category-threshold {
|
||||
background: rgba(241, 196, 15, 0.15);
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.category-vo2max {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.category-sprint {
|
||||
background: rgba(155, 89, 182, 0.15);
|
||||
color: #8e44ad;
|
||||
}
|
||||
|
||||
.category-recovery {
|
||||
background: rgba(52, 152, 219, 0.15);
|
||||
color: #2980b9;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user