Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
const { t, locale } = useI18n()
|
||||
const { api } = useApi()
|
||||
const route = useRoute()
|
||||
|
||||
interface ExerciseDetail {
|
||||
id: number
|
||||
name: string
|
||||
body_part: string | null
|
||||
category: string | null
|
||||
equipment: string | null
|
||||
target: string | null
|
||||
muscle_group: string | null
|
||||
secondary_muscles: string[]
|
||||
instructions: string[]
|
||||
image_url: string | null
|
||||
gif_url: string | null
|
||||
attribution: string | null
|
||||
}
|
||||
|
||||
const { data, pending } = await useAsyncData(
|
||||
() => `exercise-${route.params.id}`,
|
||||
() => api<ExerciseDetail>(`/api/exercises/${route.params.id}`, { query: { locale: locale.value } }),
|
||||
{ watch: [locale] }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<UButton to="/exercises" icon="i-lucide-arrow-left" color="neutral" variant="ghost" :label="t('common.back')" />
|
||||
|
||||
<div v-if="pending && !data" class="flex justify-center py-16"><SfLogo :size="56" loader /></div>
|
||||
|
||||
<template v-else-if="data">
|
||||
<div class="rounded-2xl overflow-hidden bg-zinc-100 dark:bg-zinc-800 aspect-square max-w-sm mx-auto">
|
||||
<img v-if="data.gif_url || data.image_url" :src="data.gif_url || data.image_url || ''" :alt="data.name" class="w-full h-full object-cover">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-black tracking-tight capitalize">{{ data.name }}</h1>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<UBadge v-if="data.target" color="primary" variant="soft" class="capitalize">{{ data.target }}</UBadge>
|
||||
<UBadge v-if="data.equipment" color="neutral" variant="soft" class="capitalize">{{ data.equipment }}</UBadge>
|
||||
<UBadge v-if="data.body_part" color="neutral" variant="outline" class="capitalize">{{ data.body_part }}</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.secondary_muscles?.length">
|
||||
<h2 class="text-sm font-semibold text-zinc-500 mb-1">{{ t('exercises.secondaryMuscles') }}</h2>
|
||||
<p class="text-sm capitalize">{{ data.secondary_muscles.join(', ') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="data.instructions?.length">
|
||||
<h2 class="text-sm font-semibold text-zinc-500 mb-2">{{ t('exercises.instructions') }}</h2>
|
||||
<ol class="space-y-2">
|
||||
<li v-for="(step, i) in data.instructions" :key="i" class="flex gap-3">
|
||||
<span class="shrink-0 size-6 rounded-full sf-flame-bg text-white text-xs font-bold flex items-center justify-center">{{ i + 1 }}</span>
|
||||
<span class="text-sm">{{ step }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<p v-if="data.attribution" class="text-xs text-zinc-400 pt-4">{{ data.attribution }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const { api } = useApi()
|
||||
|
||||
interface ExerciseRow { id: number, name: string, slug: string, body_part: string | null, equipment: string | null, target: string | null, image_url: string | null }
|
||||
interface Facets { body_parts: string[], equipments: string[], targets: string[], categories: string[] }
|
||||
interface Paginated { data: ExerciseRow[], meta: { current_page: number, last_page: number, total: number } }
|
||||
|
||||
const search = ref('')
|
||||
const bodyPart = ref<string | undefined>()
|
||||
const page = ref(1)
|
||||
|
||||
const { data: facets } = await useAsyncData('ex-facets', () => api<Facets>('/api/exercises/facets'), { lazy: true })
|
||||
|
||||
const query = computed(() => ({ search: search.value || undefined, body_part: bodyPart.value || undefined, page: page.value }))
|
||||
const { data, pending } = await useAsyncData<Paginated>(
|
||||
'exercises',
|
||||
() => api<Paginated>('/api/exercises', { query: query.value }),
|
||||
{ watch: [query], lazy: true }
|
||||
)
|
||||
|
||||
watch([search, bodyPart], () => { page.value = 1 })
|
||||
|
||||
const bodyPartOptions = computed(() => [
|
||||
{ label: t('exercises.all'), value: undefined },
|
||||
...(facets.value?.body_parts ?? []).map(b => ({ label: b, value: b }))
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-black tracking-tight">{{ t('exercises.title') }}</h1>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<UInput
|
||||
v-model="search"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('exercises.search')"
|
||||
size="lg"
|
||||
class="flex-1"
|
||||
/>
|
||||
<USelectMenu
|
||||
v-model="bodyPart"
|
||||
:items="bodyPartOptions"
|
||||
value-key="value"
|
||||
:placeholder="t('exercises.bodyPart')"
|
||||
size="lg"
|
||||
class="w-36"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="pending && !data" class="flex justify-center py-16">
|
||||
<SfLogo :size="56" loader />
|
||||
</div>
|
||||
|
||||
<p v-else-if="!data?.data.length" class="text-center text-zinc-400 py-16">{{ t('exercises.noResults') }}</p>
|
||||
|
||||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<NuxtLink
|
||||
v-for="ex in data.data"
|
||||
:key="ex.id"
|
||||
:to="`/exercises/${ex.id}`"
|
||||
class="group rounded-xl overflow-hidden border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 hover:border-flame-400 transition-colors"
|
||||
>
|
||||
<div class="aspect-square bg-zinc-100 dark:bg-zinc-800 overflow-hidden">
|
||||
<img
|
||||
v-if="ex.image_url"
|
||||
:src="ex.image_url"
|
||||
:alt="ex.name"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<p class="text-sm font-semibold capitalize truncate">{{ ex.name }}</p>
|
||||
<p class="text-xs text-zinc-500 capitalize truncate">{{ ex.target }}</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="data && data.meta.last_page > 1" class="flex justify-center pt-2">
|
||||
<UPagination
|
||||
v-model:page="page"
|
||||
:total="data.meta.total"
|
||||
:items-per-page="Math.ceil(data.meta.total / data.meta.last_page)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user