Files
streakfit-web/app/pages/session/[id].vue
T
neckfireandClaude Opus 4.8 8ee9596529
Build & Deploy / build (push) Failing after 5s
StreakFit — PWA Nuxt (thème flamme, logo SF animé)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 03:00:18 +02:00

129 lines
5.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>