Files
streakfit-web/app/pages/session/[id].vue
T
neckfireandClaude Opus 4.8 bd739d13cf
Build & Deploy / build (push) Successful in 8s
i18n complet des pages + routine builder avancé (config par exo, réordonner, dupliquer)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 04:48:26 +02:00

133 lines
5.7 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 })[] }
// L'API peut envelopper la resource dans { data: {...} } → déballage tolérant.
const { data: session, refresh } = await useAsyncData(
`session-${sessionId}`,
() => api<Session | { data: Session }>(`/api/sessions/${sessionId}`).then((r: unknown) => (r as { data?: Session })?.data ?? r as Session)
)
// 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: `🔥 ${t('session.streakToast', { n: res.streak?.current ?? 0 })}`, 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 || t('session.inProgress') }}</h1>
<UBadge color="primary" variant="soft" class="animate-pulse"> {{ t('session.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 ?? '' }} {{ t('session.reps') }}</span>
<span v-if="s.weight" class="text-zinc-500">× {{ s.weight }} {{ t('session.weight') }}</span>
</div>
<div class="flex items-center gap-2 pt-1">
<UInput v-model.number="(draft[ex.id] ??= {}).reps" type="number" :placeholder="t('session.reps')" size="sm" class="w-20" />
<UInput v-model.number="(draft[ex.id] ??= {}).weight" type="number" :placeholder="t('session.weight')" 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="t('session.addExercise')" @click="pickerOpen = true" />
<UButton block size="xl" color="primary" class="font-bold" icon="i-lucide-check" :label="t('session.finish')" :loading="finishing" @click="finish" />
<!-- Picker d'exercice -->
<UModal v-model:open="pickerOpen" :title="t('session.addExercise')">
<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>