trainUs/src/views/ExerciseDetailView.vue
David 8a96f28246
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Fix alternate tag in detail view
2026-07-03 16:41:27 +02:00

351 lines
8.7 KiB
Vue

<script setup>
import { ref, computed, onMounted } from 'vue'
import { useTranslation } from 'i18next-vue'
import { useRouter, useRoute } from 'vue-router'
import { useExercises } from '../composables/useExercises.js'
import { useJsonExport } from '../composables/useJsonExport.js'
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
import { useOnlineStatus } from '../composables/useOnlineStatus.js'
import { renderMarkdown } from '../utils/markdown.js'
import { getYouTubeId } from '../utils/youtube.js'
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
const { t } = useTranslation()
const router = useRouter()
const route = useRoute()
const { getById, remove } = useExercises()
const { exportExercise } = useJsonExport()
const { isOnline } = useOnlineStatus()
const exercise = ref(null)
const loading = ref(true)
const exerciseId = computed(() => route.params.id)
onMounted(async () => {
const data = await getById(exerciseId.value)
if (!data) {
router.replace({ name: 'exercises' })
return
}
exercise.value = data
loading.value = false
})
function goToEdit() {
router.push({ name: 'exercise-edit', params: { id: exerciseId.value } })
}
useKeyboardShortcut('e', goToEdit)
useKeyboardShortcut('escape', goBack, false)
function goBack() {
router.push({ name: 'exercises' })
}
async function onDelete() {
if (!exercise.value) return
const count = await executionLogRepository.countItemsByExercise(exerciseId.value)
const msg =
count > 0
? t('exercises.deleteConfirmWithHistory', { title: exercise.value.title, count })
: t('exercises.deleteConfirm', { title: exercise.value.title })
const confirmed = window.confirm(msg)
if (!confirmed) return
await remove(exerciseId.value)
router.push({ name: 'exercises' })
}
async function onExport() {
await exportExercise(exerciseId.value)
}
</script>
<template>
<Teleport to="#actionbar-actions" defer>
<button
data-testid="exercise-back-btn"
:title="$t('exercises.title') + ' ' + $t('shortcuts.back')"
:aria-label="$t('exercises.title')"
@click="goBack"
>
<i class="bi bi-arrow-left"></i>
</button>
<button
data-testid="exercise-export-single-btn"
:title="$t('export.exercise')"
:aria-label="$t('export.exercise')"
@click="onExport"
>
<i class="bi bi-download"></i>
</button>
<button
data-testid="exercise-edit-btn"
:title="$t('exercises.edit') + ' ' + $t('shortcuts.edit')"
:aria-label="$t('exercises.edit')"
@click="goToEdit"
>
<i class="bi bi-pencil"></i>
</button>
<button
data-testid="exercise-delete-btn"
:title="$t('exercises.delete')"
:aria-label="$t('exercises.delete')"
@click="onDelete"
>
<i class="bi bi-trash"></i>
</button>
</Teleport>
<div class="exercise-detail-view">
<p v-if="loading" class="text-muted">{{ $t('app.loading') }}</p>
<template v-else-if="exercise">
<!-- Title -->
<h2 data-testid="exercise-detail-title">{{ exercise.title }}</h2>
<!-- Description -->
<div
v-if="exercise.description"
class="exercise-description"
data-testid="exercise-detail-description"
v-html="renderMarkdown(exercise.description)"
/>
<!-- Badges -->
<div class="exercise-meta">
<span v-if="exercise.default_reps" class="badge" data-testid="exercise-detail-reps">
{{ exercise.default_reps }} {{ $t('exercises.reps') }}
</span>
<span v-if="exercise.default_weight" class="badge" data-testid="exercise-detail-weight">
{{ exercise.default_weight }} {{ $t('exercises.weight') }}
</span>
<span
v-if="exercise.asymmetric"
class="badge badge-info"
data-testid="exercise-detail-asymmetric"
>
<i class="bi bi-arrow-left-right"></i> {{ $t('exercises.asymmetricLabel') }}
</span>
<span
v-if="exercise.asymmetric && exercise.alternate"
class="badge badge-info"
data-testid="exercise-detail-alternate"
>
{{ $t('exercises.alternateLabel') }}
</span>
</div>
<!-- Images -->
<div v-if="exercise.image_urls && exercise.image_urls.length" class="media-section">
<h3>Images</h3>
<hr />
<div class="image-gallery">
<div v-for="(url, i) in exercise.image_urls" :key="'img-' + i" class="image-item">
<img
:src="url"
:alt="exercise.title + ' image ' + (i + 1)"
:data-testid="'exercise-detail-image-' + i"
loading="lazy"
@error="($event) => ($event.target.style.display = 'none')"
/>
</div>
</div>
</div>
<!-- Videos -->
<div v-if="exercise.video_urls && exercise.video_urls.length" class="media-section">
<h3>Videos</h3>
<hr />
<p v-if="!isOnline" class="video-offline">
<i class="bi bi-wifi-off"></i> {{ $t('exercises.videoOffline') }}
</p>
<div v-else class="video-gallery">
<div v-for="(url, i) in exercise.video_urls" :key="'vid-' + i" class="video-item">
<!-- YouTube thumbnail link -->
<a
v-if="getYouTubeId(url)"
:href="url"
target="_blank"
rel="noopener noreferrer"
:data-testid="'exercise-detail-video-' + i"
class="youtube-thumb"
>
<img
:src="'https://img.youtube.com/vi/' + getYouTubeId(url) + '/mqdefault.jpg'"
:alt="exercise.title + ' video ' + (i + 1)"
loading="lazy"
/>
<span class="youtube-play"><i class="bi bi-play-circle-fill"></i></span>
</a>
<!-- Generic link fallback -->
<a
v-else
:href="url"
target="_blank"
rel="noopener noreferrer"
:data-testid="'exercise-detail-video-' + i"
class="video-link"
>
<i class="bi bi-play-circle"></i> {{ url }}
</a>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.exercise-detail-view {
max-width: 800px;
}
.exercise-detail-view h2 {
font-size: 22px;
margin-bottom: 8px;
}
.exercise-description {
font-size: 15px;
line-height: 1.5;
margin-bottom: 12px;
}
.exercise-description :deep(p) {
margin: 0 0 8px;
}
.exercise-description :deep(p:last-child) {
margin-bottom: 0;
}
.exercise-description :deep(ul),
.exercise-description :deep(ol) {
margin: 0 0 8px;
padding-left: 20px;
}
.exercise-description :deep(code) {
font-family: monospace;
font-size: 13px;
background: var(--color-surface, #f4f4f4);
padding: 1px 4px;
border-radius: 3px;
}
.exercise-description :deep(pre) {
background: var(--color-surface, #f4f4f4);
padding: 8px;
border-radius: 4px;
overflow-x: auto;
margin-bottom: 8px;
}
.exercise-description :deep(strong) {
font-weight: 600;
}
.exercise-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 13px;
background-color: var(--btn-primary-bg);
color: var(--btn-primary-color);
}
.badge-info {
background-color: var(--front-color2);
}
.media-section {
margin-bottom: 20px;
}
.media-section h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 10px;
}
.image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.image-item img {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.video-gallery {
display: flex;
flex-direction: column;
gap: 12px;
}
.youtube-thumb {
position: relative;
display: inline-block;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.youtube-thumb img {
display: block;
width: 320px;
max-width: 100%;
}
.youtube-play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: white;
background: rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity 0.15s;
}
.youtube-thumb:hover .youtube-play {
opacity: 1;
}
.video-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
}
.video-offline {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
color: var(--text-muted, #888);
}
</style>