StreakFit — PWA Nuxt (thème flamme, logo SF animé)
Build & Deploy / build (push) Failing after 5s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 03:00:18 +02:00
co-authored by Claude Opus 4.8
commit 8ee9596529
41 changed files with 24630 additions and 0 deletions
+66
View File
@@ -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>
+89
View File
@@ -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>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
const { t } = useI18n()
const { api } = useApi()
interface Sess { id: number, title: string | null, started_at: string, total_volume: number | null, set_count: number }
const { data, pending } = await useAsyncData('history', () => api<{ data: Sess[] }>('/api/history'), { lazy: true })
</script>
<template>
<div class="space-y-4">
<h1 class="text-2xl font-black tracking-tight">{{ t('nav.history') }}</h1>
<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('dashboard.noSessions') }}</p>
<div v-else class="space-y-2">
<UCard v-for="s in data.data" :key="s.id" :ui="{ body: 'p-3' }">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{{ s.title || 'Séance' }}</p>
<p class="text-xs text-zinc-500">{{ new Date(s.started_at).toLocaleString() }} · {{ s.set_count }} sets</p>
</div>
<span v-if="s.total_volume" class="text-sm font-bold sf-flame-text">{{ Math.round(s.total_volume) }} kg</span>
</div>
</UCard>
</div>
</div>
</template>
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
const { t } = useI18n()
const { api } = useApi()
interface Dashboard {
streak: { current: number, longest: number, freezes_available: number, last_active_date: string | null, flame_level: number, active_today: boolean }
week: { goal_sessions: number, done_sessions: number, days: { date: string, done: boolean, source: string | null }[] }
recent_sessions: { id: number, title: string | null, started_at: string, total_volume: number | null, set_count: number }[]
achievements: { key: string, earned_at: string, label: string }[]
suggestion: { type: 'routine' | 'rest', routine_id?: number, label: string } | null
}
const { data, pending, error, refresh } = await useAsyncData('dashboard', () => api<Dashboard>('/api/dashboard'), { lazy: true })
const flameScale = computed(() => 1 + Math.min(data.value?.streak.flame_level ?? 0, 5) * 0.12)
</script>
<template>
<div class="space-y-6">
<SfLogo v-if="pending && !data" :size="72" loader class="mx-auto mt-16" />
<UAlert
v-else-if="error"
color="error"
variant="soft"
:title="t('common.error')"
:actions="[{ label: t('common.retry'), onClick: () => refresh() }]"
/>
<template v-else-if="data">
<!-- Héros flamme -->
<section class="relative flex flex-col items-center pt-6 pb-2 text-center">
<div class="relative">
<UIcon
name="i-lucide-flame"
class="text-flame-500 drop-shadow-[0_0_25px_rgba(249,115,22,0.55)] transition-transform"
:style="{ fontSize: `${7 * flameScale}rem`, filter: data.streak.active_today ? 'none' : 'grayscale(0.4) opacity(0.75)' }"
/>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-4xl font-black text-white drop-shadow">{{ data.streak.current }}</span>
</div>
</div>
<p class="mt-2 text-lg font-bold">
{{ data.streak.current }} {{ t('dashboard.streakDays') }}
</p>
<p class="text-sm" :class="data.streak.active_today ? 'text-flame-600 dark:text-flame-400' : 'text-zinc-500'">
{{ data.streak.active_today ? t('dashboard.activeToday') : t('dashboard.keepFlame') }}
</p>
</section>
<!-- Stats -->
<section class="grid grid-cols-3 gap-3">
<UCard :ui="{ body: 'p-3 text-center' }">
<p class="text-2xl font-black sf-flame-text">{{ data.streak.longest }}</p>
<p class="text-xs text-zinc-500">{{ t('dashboard.longest') }}</p>
</UCard>
<UCard :ui="{ body: 'p-3 text-center' }">
<p class="text-2xl font-black text-sky-500">{{ data.streak.freezes_available }}</p>
<p class="text-xs text-zinc-500">{{ t('dashboard.freezes') }}</p>
</UCard>
<UCard :ui="{ body: 'p-3 text-center' }">
<p class="text-2xl font-black">{{ data.week.done_sessions }}/{{ data.week.goal_sessions }}</p>
<p class="text-xs text-zinc-500">{{ t('dashboard.weekGoal') }}</p>
</UCard>
</section>
<!-- Semaine (7 jours) -->
<section class="flex justify-between gap-1.5">
<div v-for="d in data.week.days" :key="d.date" class="flex-1 flex flex-col items-center gap-1">
<div
class="w-full aspect-square rounded-lg flex items-center justify-center text-xs"
:class="d.done ? 'sf-flame-bg text-white' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-400'"
>
<UIcon v-if="d.done" name="i-lucide-flame" class="size-4" />
</div>
<span class="text-[10px] text-zinc-400">{{ new Date(d.date).toLocaleDateString(undefined, { weekday: 'narrow' }) }}</span>
</div>
</section>
<!-- CTA -->
<UButton
block
size="xl"
color="primary"
icon="i-lucide-play"
class="font-bold"
:label="data.suggestion?.type === 'rest' ? t('dashboard.restEarned') : t('dashboard.startWorkout')"
to="/session/new"
/>
<!-- Badges -->
<section v-if="data.achievements.length">
<h2 class="text-sm font-semibold text-zinc-500 mb-2">{{ t('dashboard.achievements') }}</h2>
<div class="flex gap-2 overflow-x-auto pb-1">
<UBadge
v-for="a in data.achievements"
:key="a.key"
color="primary"
variant="soft"
size="lg"
class="shrink-0"
>
🏅 {{ a.label }}
</UBadge>
</div>
</section>
<!-- Séances récentes -->
<section>
<h2 class="text-sm font-semibold text-zinc-500 mb-2">{{ t('dashboard.recentSessions') }}</h2>
<p v-if="!data.recent_sessions.length" class="text-sm text-zinc-400">{{ t('dashboard.noSessions') }}</p>
<div v-else class="space-y-2">
<UCard v-for="s in data.recent_sessions" :key="s.id" :ui="{ body: 'p-3' }">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{{ s.title || 'Séance' }}</p>
<p class="text-xs text-zinc-500">{{ new Date(s.started_at).toLocaleDateString() }} · {{ s.set_count }} sets</p>
</div>
<span v-if="s.total_volume" class="text-sm font-bold sf-flame-text">{{ Math.round(s.total_volume) }} kg</span>
</div>
</UCard>
</div>
</section>
</template>
</div>
</template>
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
definePageMeta({ layout: false })
const { t } = useI18n()
const auth = useAuthStore()
const toast = useToast()
const mode = ref<'login' | 'register'>('login')
const form = reactive({ name: '', email: '', password: '' })
const loading = ref(false)
onMounted(() => {
if (auth.isAuthed) navigateTo('/')
})
async function submit() {
loading.value = true
try {
if (mode.value === 'login') {
await auth.login(form.email, form.password)
} else {
await auth.register(form.name, form.email, form.password)
}
await navigateTo('/')
} catch (e: unknown) {
const msg = (e as { data?: { message?: string } })?.data?.message || t('common.error')
toast.add({ title: msg, color: 'error' })
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-dvh flex flex-col items-center justify-center px-6 gap-6">
<div class="flex flex-col items-center gap-3 text-center">
<SfLogo :size="80" />
<h1 class="text-3xl font-black tracking-tight">
<span class="sf-flame-text">Streak</span><span>Fit</span>
</h1>
<p class="text-sm text-zinc-500">{{ t('app.tagline') }}</p>
</div>
<UCard class="w-full max-w-sm">
<form class="space-y-4" @submit.prevent="submit">
<UFormField v-if="mode === 'register'" :label="t('auth.name')">
<UInput v-model="form.name" size="lg" class="w-full" icon="i-lucide-user" required />
</UFormField>
<UFormField :label="t('auth.email')">
<UInput v-model="form.email" type="email" size="lg" class="w-full" icon="i-lucide-mail" required />
</UFormField>
<UFormField :label="t('auth.password')">
<UInput v-model="form.password" type="password" size="lg" class="w-full" icon="i-lucide-lock" required />
</UFormField>
<UButton type="submit" block size="lg" color="primary" class="font-bold" :loading="loading">
{{ mode === 'login' ? t('auth.signIn') : t('auth.signUp') }}
</UButton>
</form>
<USeparator :label="t('auth.or')" class="my-4" />
<UButton
block
size="lg"
color="neutral"
variant="subtle"
icon="i-lucide-key-round"
:label="t('auth.loginWithPocketId')"
@click="auth.loginWithPocketId()"
/>
<template #footer>
<button
class="w-full text-center text-sm text-flame-600 dark:text-flame-400 hover:underline"
@click="mode = mode === 'login' ? 'register' : 'login'"
>
{{ mode === 'login' ? t('auth.noAccount') + ' ' + t('auth.signUp') : t('auth.haveAccount') + ' ' + t('auth.signIn') }}
</button>
</template>
</UCard>
</div>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
const { t, locale, locales, setLocale } = useI18n()
const auth = useAuthStore()
async function doLogout() {
await auth.logout()
await navigateTo('/login')
}
</script>
<template>
<div class="space-y-6">
<div class="flex items-center gap-4">
<div class="size-16 rounded-full sf-flame-bg flex items-center justify-center text-white text-2xl font-black">
{{ auth.user?.name?.[0]?.toUpperCase() }}
</div>
<div>
<h1 class="text-xl font-black">{{ auth.user?.name }}</h1>
<p class="text-sm text-zinc-500">{{ auth.user?.email }}</p>
</div>
</div>
<UCard v-if="auth.streak" :ui="{ body: 'p-4' }">
<div class="grid grid-cols-3 text-center">
<div><p class="text-2xl font-black sf-flame-text">{{ auth.streak.current }}</p><p class="text-xs text-zinc-500">{{ t('dashboard.streakDays') }}</p></div>
<div><p class="text-2xl font-black">{{ auth.streak.longest }}</p><p class="text-xs text-zinc-500">{{ t('dashboard.longest') }}</p></div>
<div><p class="text-2xl font-black text-sky-500">{{ auth.streak.freezes }}</p><p class="text-xs text-zinc-500">{{ t('dashboard.freezes') }}</p></div>
</div>
</UCard>
<div>
<p class="text-sm font-semibold text-zinc-500 mb-2">Langue</p>
<div class="flex gap-2">
<UButton
v-for="l in (locales as { code: string, name: string }[])"
:key="l.code"
:color="locale === l.code ? 'primary' : 'neutral'"
:variant="locale === l.code ? 'solid' : 'subtle'"
size="sm"
:label="l.name"
@click="setLocale(l.code as 'fr' | 'en' | 'pt-BR')"
/>
</div>
</div>
<UButton block color="neutral" variant="subtle" icon="i-lucide-log-out" :label="t('auth.logout')" @click="doLogout" />
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
const { t } = useI18n()
const { api } = useApi()
interface Routine { id: number, name: string, description: string | null, estimated_minutes: number | null, exercises?: unknown[] }
const { data, pending } = await useAsyncData('routines', () => api<{ data: Routine[] }>('/api/routines'), { lazy: true })
</script>
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-black tracking-tight">{{ t('nav.routines') }}</h1>
<UButton to="/routines/new" icon="i-lucide-plus" color="primary" size="sm" label="Nouvelle" />
</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">Aucune routine. Crée ta première !</p>
<div v-else class="space-y-2">
<UCard v-for="r in data.data" :key="r.id" :ui="{ body: 'p-3' }" class="cursor-pointer" @click="navigateTo(`/session/new?routine=${r.id}`)">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold">{{ r.name }}</p>
<p class="text-xs text-zinc-500">{{ (r.exercises?.length ?? 0) }} exercices<span v-if="r.estimated_minutes"> · ~{{ r.estimated_minutes }} min</span></p>
</div>
<UIcon name="i-lucide-play" class="text-flame-500 size-5" />
</div>
</UCard>
</div>
</div>
</template>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
const { t } = useI18n()
const { api } = useApi()
const toast = useToast()
const form = reactive({ name: '', description: '' })
const saving = ref(false)
async function save() {
saving.value = true
try {
await api('/api/routines', { method: 'POST', body: { name: form.name, description: form.description } })
await navigateTo('/routines')
} catch {
toast.add({ title: t('common.error'), color: 'error' })
saving.value = false
}
}
</script>
<template>
<div class="space-y-4 max-w-md mx-auto">
<UButton to="/routines" icon="i-lucide-arrow-left" color="neutral" variant="ghost" :label="t('common.back')" />
<h1 class="text-2xl font-black tracking-tight">Nouvelle routine</h1>
<form class="space-y-4" @submit.prevent="save">
<UFormField label="Nom">
<UInput v-model="form.name" size="lg" class="w-full" required />
</UFormField>
<UFormField label="Description">
<UTextarea v-model="form.description" class="w-full" :rows="3" />
</UFormField>
<UButton type="submit" block size="lg" color="primary" class="font-bold" :loading="saving" :label="t('common.save')" />
</form>
<p class="text-xs text-zinc-400 text-center">Tu pourras ajouter des exercices ensuite depuis la routine.</p>
</div>
</template>
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
const { t } = useI18n()
const { api } = useApi()
const route = useRoute()
const toast = useToast()
const sessionId = Number(route.params.id)
interface SetLog { id: number, exercise_id: number, set_number: number, reps: number | null, weight: number | null }
interface Exercise { id: number, name: string, image_url: string | null, target?: string | null }
interface Session { id: number, title: string | null, status: string, set_logs: (SetLog & { exercise?: Exercise })[] }
const { data: session, refresh } = await useAsyncData(`session-${sessionId}`, () => api<Session>(`/api/sessions/${sessionId}`))
// Exercices actifs = ceux ayant des sets + ceux ajoutés manuellement
const added = ref<Exercise[]>([])
const exercises = computed<Exercise[]>(() => {
const map = new Map<number, Exercise>()
for (const sl of session.value?.set_logs ?? []) {
if (sl.exercise) map.set(sl.exercise.id, sl.exercise)
}
for (const e of added.value) map.set(e.id, e)
return [...map.values()]
})
function setsFor(exId: number) {
return (session.value?.set_logs ?? []).filter(s => s.exercise_id === exId).sort((a, b) => a.set_number - b.set_number)
}
// Ajout d'exercice
const pickerOpen = ref(false)
const search = ref('')
const { data: results } = await useAsyncData(
'ex-picker',
() => search.value ? api<{ data: Exercise[] }>('/api/exercises', { query: { search: search.value, per_page: 12 } }) : Promise.resolve({ data: [] }),
{ watch: [search], lazy: true }
)
function addExercise(e: Exercise) {
if (!added.value.find(x => x.id === e.id)) added.value.push(e)
pickerOpen.value = false
search.value = ''
}
// Log d'une série
const draft = reactive<Record<number, { reps?: number, weight?: number }>>({})
async function logSet(exId: number) {
const d = draft[exId] || {}
const setNumber = setsFor(exId).length + 1
try {
await api(`/api/sessions/${sessionId}/sets`, {
method: 'POST',
body: { exercise_id: exId, set_number: setNumber, reps: d.reps ?? null, weight: d.weight ?? null }
})
draft[exId] = { reps: d.reps, weight: d.weight } // pré-remplit la série suivante
await refresh()
} catch {
toast.add({ title: t('common.error'), color: 'error' })
}
}
// Fin de séance
const finishing = ref(false)
async function finish() {
finishing.value = true
try {
const res = await api<{ unlocked_achievements?: { label: string }[], streak?: { current: number } }>(`/api/sessions/${sessionId}/complete`, { method: 'POST' })
const unlocked = res.unlocked_achievements ?? []
if (unlocked.length) {
toast.add({ title: `🏅 ${unlocked.map(u => u.label).join(', ')}`, color: 'primary' })
}
toast.add({ title: `🔥 Série : ${res.streak?.current ?? ''} jours`, color: 'primary' })
await navigateTo('/')
} catch {
toast.add({ title: t('common.error'), color: 'error' })
finishing.value = false
}
}
</script>
<template>
<div class="space-y-4 pb-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-black tracking-tight">{{ session?.title || 'Séance en cours' }}</h1>
<UBadge color="primary" variant="soft" class="animate-pulse"> live</UBadge>
</div>
<div v-for="ex in exercises" :key="ex.id" class="rounded-xl border border-zinc-200 dark:border-zinc-800 p-3 space-y-2">
<div class="flex items-center gap-3">
<img v-if="ex.image_url" :src="ex.image_url" :alt="ex.name" class="size-10 rounded-lg object-cover">
<p class="font-semibold capitalize flex-1">{{ ex.name }}</p>
</div>
<div v-for="s in setsFor(ex.id)" :key="s.id" class="flex items-center gap-3 text-sm pl-1">
<span class="size-6 rounded-full bg-flame-100 dark:bg-flame-950 text-flame-600 dark:text-flame-400 text-xs font-bold flex items-center justify-center">{{ s.set_number }}</span>
<span>{{ s.reps ?? '' }} reps</span>
<span v-if="s.weight" class="text-zinc-500">× {{ s.weight }} kg</span>
</div>
<div class="flex items-center gap-2 pt-1">
<UInput v-model.number="(draft[ex.id] ??= {}).reps" type="number" placeholder="reps" size="sm" class="w-20" />
<UInput v-model.number="(draft[ex.id] ??= {}).weight" type="number" placeholder="kg" size="sm" class="w-20" />
<UButton icon="i-lucide-plus" color="primary" size="sm" @click="logSet(ex.id)" />
</div>
</div>
<UButton block color="neutral" variant="subtle" icon="i-lucide-plus" label="Ajouter un exercice" @click="pickerOpen = true" />
<UButton block size="xl" color="primary" class="font-bold" icon="i-lucide-check" label="Terminer la séance" :loading="finishing" @click="finish" />
<!-- Picker d'exercice -->
<UModal v-model:open="pickerOpen" title="Ajouter un exercice">
<template #body>
<UInput v-model="search" icon="i-lucide-search" :placeholder="t('exercises.search')" size="lg" class="w-full mb-3" autofocus />
<div class="max-h-80 overflow-y-auto space-y-1">
<button
v-for="e in results?.data ?? []"
:key="e.id"
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-left"
@click="addExercise(e)"
>
<img v-if="e.image_url" :src="e.image_url" :alt="e.name" class="size-9 rounded object-cover">
<span class="text-sm font-medium capitalize">{{ e.name }}</span>
</button>
</div>
</template>
</UModal>
</div>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
const { api } = useApi()
const route = useRoute()
const toast = useToast()
onMounted(async () => {
try {
const routineId = route.query.routine ? Number(route.query.routine) : undefined
const res = await api<{ session: { id: number } }>('/api/sessions', {
method: 'POST',
body: { routine_id: routineId }
})
await navigateTo(`/session/${res.session.id}`)
} catch {
toast.add({ title: 'Impossible de démarrer la séance', color: 'error' })
await navigateTo('/')
}
})
</script>
<template>
<div class="flex flex-col items-center justify-center py-24 gap-4">
<SfLogo :size="72" loader />
<p class="text-sm text-zinc-500">Préparation de ta séance</p>
</div>
</template>