i18n complet des pages + routine builder avancé (config par exo, réordonner, dupliquer)
Build & Deploy / build (push) Successful in 8s
Build & Deploy / build (push) Successful in 8s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,8 +16,8 @@ const { data, pending } = await useAsyncData('history', () => api<{ data: Sess[]
|
|||||||
<UCard v-for="s in data.data" :key="s.id" :ui="{ body: 'p-3' }">
|
<UCard v-for="s in data.data" :key="s.id" :ui="{ body: 'p-3' }">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{{ s.title || 'Séance' }}</p>
|
<p class="font-medium">{{ s.title || t('dashboard.session') }}</p>
|
||||||
<p class="text-xs text-zinc-500">{{ new Date(s.started_at).toLocaleString() }} · {{ s.set_count }} sets</p>
|
<p class="text-xs text-zinc-500">{{ new Date(s.started_at).toLocaleString() }} · {{ s.set_count }} {{ t('dashboard.sets') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="s.total_volume" class="text-sm font-bold sf-flame-text">{{ Math.round(s.total_volume) }} kg</span>
|
<span v-if="s.total_volume" class="text-sm font-bold sf-flame-text">{{ Math.round(s.total_volume) }} kg</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -115,8 +115,8 @@ const flameScale = computed(() => 1 + Math.min(data.value?.streak.flame_level ??
|
|||||||
<UCard v-for="s in data.recent_sessions" :key="s.id" :ui="{ body: 'p-3' }">
|
<UCard v-for="s in data.recent_sessions" :key="s.id" :ui="{ body: 'p-3' }">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{{ s.title || 'Séance' }}</p>
|
<p class="font-medium">{{ s.title || t('dashboard.session') }}</p>
|
||||||
<p class="text-xs text-zinc-500">{{ new Date(s.started_at).toLocaleDateString() }} · {{ s.set_count }} sets</p>
|
<p class="text-xs text-zinc-500">{{ new Date(s.started_at).toLocaleDateString() }} · {{ s.set_count }} {{ t('dashboard.sets') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="s.total_volume" class="text-sm font-bold sf-flame-text">{{ Math.round(s.total_volume) }} kg</span>
|
<span v-if="s.total_volume" class="text-sm font-bold sf-flame-text">{{ Math.round(s.total_volume) }} kg</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ async function togglePush(val: boolean) {
|
|||||||
if (val) {
|
if (val) {
|
||||||
await enable(auth.push.vapid_public_key || '')
|
await enable(auth.push.vapid_public_key || '')
|
||||||
pushOn.value = true
|
pushOn.value = true
|
||||||
toast.add({ title: 'Rappels activés 🔥', color: 'primary' })
|
toast.add({ title: t('profile.remindersOn'), color: 'primary' })
|
||||||
} else {
|
} else {
|
||||||
await disable()
|
await disable()
|
||||||
pushOn.value = false
|
pushOn.value = false
|
||||||
@@ -54,7 +54,7 @@ async function doLogout() {
|
|||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-zinc-500 mb-2">Langue</p>
|
<p class="text-sm font-semibold text-zinc-500 mb-2">{{ t('profile.language') }}</p>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<UButton
|
<UButton
|
||||||
v-for="l in (locales as { code: string, name: string }[])"
|
v-for="l in (locales as { code: string, name: string }[])"
|
||||||
@@ -71,12 +71,12 @@ async function doLogout() {
|
|||||||
<UCard v-if="isSupported()" :ui="{ body: 'p-4' }">
|
<UCard v-if="isSupported()" :ui="{ body: 'p-4' }">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">Rappels de série 🔥</p>
|
<p class="font-semibold">{{ t('profile.reminders') }}</p>
|
||||||
<p class="text-xs text-zinc-500">Un rappel le soir si tu risques de perdre ta flamme.</p>
|
<p class="text-xs text-zinc-500">{{ t('profile.remindersDesc') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<USwitch v-model="pushOn" :loading="pushLoading" @update:model-value="togglePush" />
|
<USwitch v-model="pushOn" :loading="pushLoading" @update:model-value="togglePush" />
|
||||||
</div>
|
</div>
|
||||||
<UButton v-if="pushOn" size="xs" variant="link" color="neutral" class="mt-1 -ml-2" label="Envoyer un test" @click="sendTest" />
|
<UButton v-if="pushOn" size="xs" variant="link" color="neutral" class="mt-1 -ml-2" :label="t('profile.test')" @click="sendTest" />
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<UButton block color="neutral" variant="subtle" icon="i-lucide-log-out" :label="t('auth.logout')" @click="doLogout" />
|
<UButton block color="neutral" variant="subtle" icon="i-lucide-log-out" :label="t('auth.logout')" @click="doLogout" />
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ middleware: 'auth' })
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { api } = useApi()
|
||||||
|
const toast = useToast()
|
||||||
|
const route = useRoute()
|
||||||
|
const routineId = Number(route.params.id)
|
||||||
|
|
||||||
|
interface ExerciseLite { id: number, name: string, image_url: string | null, target?: string | null }
|
||||||
|
interface Item {
|
||||||
|
key: number
|
||||||
|
exercise_id: number
|
||||||
|
exercise: ExerciseLite
|
||||||
|
sets: number
|
||||||
|
target_reps: string
|
||||||
|
rest_seconds: number | null
|
||||||
|
tempo: string | null
|
||||||
|
target_weight: number | null
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
interface RoutineData {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
is_public: boolean
|
||||||
|
exercises: Array<{
|
||||||
|
exercise_id: number
|
||||||
|
sets: number | null
|
||||||
|
target_reps: string | null
|
||||||
|
rest_seconds: number | null
|
||||||
|
tempo: string | null
|
||||||
|
target_weight: number | null
|
||||||
|
notes: string | null
|
||||||
|
exercise: ExerciseLite
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
let keySeq = 1
|
||||||
|
const name = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
const isPublic = ref(false)
|
||||||
|
const items = ref<Item[]>([])
|
||||||
|
const dirty = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const { data } = await useAsyncData(
|
||||||
|
`routine-${routineId}`,
|
||||||
|
() => api<{ data: RoutineData }>(`/api/routines/${routineId}`).then(r => r.data)
|
||||||
|
)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (!data.value) return
|
||||||
|
name.value = data.value.name
|
||||||
|
description.value = data.value.description ?? ''
|
||||||
|
isPublic.value = !!data.value.is_public
|
||||||
|
items.value = (data.value.exercises ?? []).map(e => ({
|
||||||
|
key: keySeq++,
|
||||||
|
exercise_id: e.exercise_id,
|
||||||
|
exercise: e.exercise,
|
||||||
|
sets: e.sets ?? 3,
|
||||||
|
target_reps: e.target_reps ?? '8-12',
|
||||||
|
rest_seconds: e.rest_seconds ?? 90,
|
||||||
|
tempo: e.tempo,
|
||||||
|
target_weight: e.target_weight != null ? Number(e.target_weight) : null,
|
||||||
|
notes: e.notes
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function markDirty() { dirty.value = true }
|
||||||
|
|
||||||
|
function moveItem(i: number, dir: -1 | 1) {
|
||||||
|
const j = i + dir
|
||||||
|
if (j < 0 || j >= items.value.length) return
|
||||||
|
const arr = items.value
|
||||||
|
;[arr[i], arr[j]] = [arr[j]!, arr[i]!]
|
||||||
|
markDirty()
|
||||||
|
}
|
||||||
|
function removeItem(i: number) { items.value.splice(i, 1); markDirty() }
|
||||||
|
function duplicateItem(i: number) {
|
||||||
|
const src = items.value[i]!
|
||||||
|
items.value.splice(i + 1, 0, { ...src, key: keySeq++ })
|
||||||
|
markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picker
|
||||||
|
const pickerOpen = ref(false)
|
||||||
|
const search = ref('')
|
||||||
|
const { data: results } = await useAsyncData(
|
||||||
|
'routine-ex-picker',
|
||||||
|
() => search.value ? api<{ data: ExerciseLite[] }>('/api/exercises', { query: { search: search.value, per_page: 15 } }) : Promise.resolve({ data: [] as ExerciseLite[] }),
|
||||||
|
{ watch: [search], lazy: true }
|
||||||
|
)
|
||||||
|
function addExercise(e: ExerciseLite) {
|
||||||
|
items.value.push({ key: keySeq++, exercise_id: e.id, exercise: e, sets: 3, target_reps: '8-12', rest_seconds: 90, tempo: null, target_weight: null, notes: null })
|
||||||
|
pickerOpen.value = false
|
||||||
|
search.value = ''
|
||||||
|
markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: name.value,
|
||||||
|
description: description.value || null,
|
||||||
|
is_public: isPublic.value,
|
||||||
|
exercises: items.value.map((it, i) => ({
|
||||||
|
exercise_id: it.exercise_id,
|
||||||
|
position: i + 1,
|
||||||
|
sets: it.sets,
|
||||||
|
target_reps: it.target_reps,
|
||||||
|
rest_seconds: it.rest_seconds,
|
||||||
|
tempo: it.tempo,
|
||||||
|
target_weight: it.target_weight,
|
||||||
|
notes: it.notes
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
await api(`/api/routines/${routineId}`, { method: 'PUT', body: payload })
|
||||||
|
dirty.value = false
|
||||||
|
toast.add({ title: t('routines.builder.saved'), color: 'primary' })
|
||||||
|
} catch {
|
||||||
|
toast.add({ title: t('common.error'), color: 'error' })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4 pb-28">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<UButton to="/routines" icon="i-lucide-arrow-left" color="neutral" variant="ghost" :label="t('common.back')" />
|
||||||
|
<UBadge v-if="dirty" color="warning" variant="soft" size="sm">{{ t('routines.builder.unsaved') }}</UBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- En-tête éditable -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<UInput v-model="name" :placeholder="t('routines.builder.namePlaceholder')" size="xl" variant="none" class="w-full text-2xl font-black -ml-1" @update:model-value="markDirty" />
|
||||||
|
<UTextarea v-model="description" :placeholder="t('routines.builder.descPlaceholder')" :rows="1" autoresize variant="soft" class="w-full" @update:model-value="markDirty" />
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<USwitch v-model="isPublic" size="sm" @update:model-value="markDirty" />
|
||||||
|
<span class="text-sm text-zinc-500">{{ t('routines.builder.public') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!items.length" class="text-center text-zinc-400 py-10">{{ t('routines.builder.empty') }}</p>
|
||||||
|
|
||||||
|
<!-- Exercices -->
|
||||||
|
<div v-for="(it, i) in items" :key="it.key" class="rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
|
||||||
|
<div class="flex items-center gap-3 p-3 bg-zinc-50 dark:bg-zinc-900/50">
|
||||||
|
<span class="shrink-0 size-7 rounded-full sf-flame-bg text-white text-sm font-bold flex items-center justify-center">{{ i + 1 }}</span>
|
||||||
|
<img v-if="it.exercise.image_url" :src="it.exercise.image_url" :alt="it.exercise.name" class="size-10 rounded-lg object-cover">
|
||||||
|
<p class="font-semibold capitalize flex-1 truncate">{{ it.exercise.name }}</p>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<UButton icon="i-lucide-chevron-up" color="neutral" variant="ghost" size="xs" :disabled="i === 0" @click="moveItem(i, -1)" />
|
||||||
|
<UButton icon="i-lucide-chevron-down" color="neutral" variant="ghost" size="xs" :disabled="i === items.length - 1" @click="moveItem(i, 1)" />
|
||||||
|
<UDropdownMenu :items="[[{ label: t('routines.builder.duplicate'), icon: 'i-lucide-copy', onSelect: () => duplicateItem(i) }, { label: t('routines.builder.remove'), icon: 'i-lucide-trash-2', color: 'error', onSelect: () => removeItem(i) }]]">
|
||||||
|
<UButton icon="i-lucide-more-vertical" color="neutral" variant="ghost" size="xs" />
|
||||||
|
</UDropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 grid grid-cols-2 gap-3">
|
||||||
|
<UFormField :label="t('routines.builder.sets')" size="xs">
|
||||||
|
<UInput v-model.number="it.sets" type="number" min="1" size="sm" class="w-full" @update:model-value="markDirty" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField :label="t('routines.builder.reps')" size="xs">
|
||||||
|
<UInput v-model="it.target_reps" placeholder="8-12" size="sm" class="w-full" @update:model-value="markDirty" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField :label="t('routines.builder.rest')" size="xs">
|
||||||
|
<UInput v-model.number="it.rest_seconds" type="number" min="0" step="15" size="sm" class="w-full" @update:model-value="markDirty" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField :label="t('routines.builder.weight')" size="xs">
|
||||||
|
<UInput v-model.number="it.target_weight" type="number" min="0" step="2.5" size="sm" class="w-full" @update:model-value="markDirty" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField :label="t('routines.builder.tempo')" size="xs">
|
||||||
|
<UInput v-model="it.tempo" placeholder="3-1-1" size="sm" class="w-full" @update:model-value="markDirty" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField :label="t('routines.builder.notes')" size="xs">
|
||||||
|
<UInput v-model="it.notes" size="sm" class="w-full" @update:model-value="markDirty" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton block color="neutral" variant="subtle" icon="i-lucide-plus" :label="t('routines.builder.addExercise')" @click="pickerOpen = true" />
|
||||||
|
|
||||||
|
<UButton v-if="items.length" block size="lg" color="primary" variant="soft" icon="i-lucide-play" :label="t('routines.builder.start')" :to="`/session/new?routine=${routineId}`" />
|
||||||
|
|
||||||
|
<!-- Barre de sauvegarde fixe -->
|
||||||
|
<div class="fixed bottom-0 inset-x-0 z-40 border-t border-zinc-200 dark:border-zinc-800 bg-white/95 dark:bg-zinc-950/95 backdrop-blur p-3">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<UButton block size="lg" color="primary" class="font-bold" icon="i-lucide-save" :label="t('routines.builder.save')" :loading="saving" :disabled="!dirty" @click="save" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Picker -->
|
||||||
|
<UModal v-model:open="pickerOpen" :title="t('routines.builder.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-96 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>
|
||||||
@@ -11,18 +11,18 @@ const { data, pending } = await useAsyncData('routines', () => api<{ data: Routi
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-black tracking-tight">{{ t('nav.routines') }}</h1>
|
<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" />
|
<UButton to="/routines/new" icon="i-lucide-plus" color="primary" size="sm" :label="t('routines.new')" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="pending && !data" class="flex justify-center py-16"><SfLogo :size="56" loader /></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>
|
<p v-else-if="!data?.data.length" class="text-center text-zinc-400 py-16">{{ t('routines.none') }}</p>
|
||||||
<div v-else class="space-y-2">
|
<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}`)">
|
<UCard v-for="r in data.data" :key="r.id" :ui="{ body: 'p-3' }" class="cursor-pointer hover:border-flame-400 transition-colors" @click="navigateTo(`/routines/${r.id}`)">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p class="font-semibold">{{ r.name }}</p>
|
<p class="font-semibold truncate">{{ 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>
|
<p class="text-xs text-zinc-500">{{ (r.exercises?.length ?? 0) }} {{ t('routines.exercises') }}<span v-if="r.estimated_minutes"> · ~{{ r.estimated_minutes }} min</span></p>
|
||||||
</div>
|
</div>
|
||||||
<UIcon name="i-lucide-play" class="text-flame-500 size-5" />
|
<UButton icon="i-lucide-play" color="primary" variant="soft" size="sm" @click.stop="navigateTo(`/session/new?routine=${r.id}`)" />
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ const saving = ref(false)
|
|||||||
async function save() {
|
async function save() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await api('/api/routines', { method: 'POST', body: { name: form.name, description: form.description } })
|
const res = await api<{ data: { id: number } }>('/api/routines', { method: 'POST', body: { name: form.name, description: form.description } })
|
||||||
await navigateTo('/routines')
|
// Redirige vers le builder pour ajouter les exercices.
|
||||||
|
await navigateTo(`/routines/${res.data.id}`)
|
||||||
} catch {
|
} catch {
|
||||||
toast.add({ title: t('common.error'), color: 'error' })
|
toast.add({ title: t('common.error'), color: 'error' })
|
||||||
saving.value = false
|
saving.value = false
|
||||||
@@ -22,16 +23,16 @@ async function save() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4 max-w-md mx-auto">
|
<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')" />
|
<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>
|
<h1 class="text-2xl font-black tracking-tight">{{ t('routines.newRoutine') }}</h1>
|
||||||
<form class="space-y-4" @submit.prevent="save">
|
<form class="space-y-4" @submit.prevent="save">
|
||||||
<UFormField label="Nom">
|
<UFormField :label="t('routines.name')">
|
||||||
<UInput v-model="form.name" size="lg" class="w-full" required />
|
<UInput v-model="form.name" size="lg" class="w-full" required />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
<UFormField label="Description">
|
<UFormField :label="t('routines.description')">
|
||||||
<UTextarea v-model="form.description" class="w-full" :rows="3" />
|
<UTextarea v-model="form.description" class="w-full" :rows="3" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
<UButton type="submit" block size="lg" color="primary" class="font-bold" :loading="saving" :label="t('common.save')" />
|
<UButton type="submit" block size="lg" color="primary" class="font-bold" :loading="saving" :label="t('common.save')" />
|
||||||
</form>
|
</form>
|
||||||
<p class="text-xs text-zinc-400 text-center">Tu pourras ajouter des exercices ensuite depuis la routine.</p>
|
<p class="text-xs text-zinc-400 text-center">{{ t('routines.addLater') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+10
-10
@@ -72,7 +72,7 @@ async function finish() {
|
|||||||
if (unlocked.length) {
|
if (unlocked.length) {
|
||||||
toast.add({ title: `🏅 ${unlocked.map(u => u.label).join(', ')}`, color: 'primary' })
|
toast.add({ title: `🏅 ${unlocked.map(u => u.label).join(', ')}`, color: 'primary' })
|
||||||
}
|
}
|
||||||
toast.add({ title: `🔥 Série : ${res.streak?.current ?? ''} jours`, color: 'primary' })
|
toast.add({ title: `🔥 ${t('session.streakToast', { n: res.streak?.current ?? 0 })}`, color: 'primary' })
|
||||||
await navigateTo('/')
|
await navigateTo('/')
|
||||||
} catch {
|
} catch {
|
||||||
toast.add({ title: t('common.error'), color: 'error' })
|
toast.add({ title: t('common.error'), color: 'error' })
|
||||||
@@ -84,8 +84,8 @@ async function finish() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4 pb-4">
|
<div class="space-y-4 pb-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-xl font-black tracking-tight">{{ session?.title || 'Séance en cours' }}</h1>
|
<h1 class="text-xl font-black tracking-tight">{{ session?.title || t('session.inProgress') }}</h1>
|
||||||
<UBadge color="primary" variant="soft" class="animate-pulse">● live</UBadge>
|
<UBadge color="primary" variant="soft" class="animate-pulse">● {{ t('session.live') }}</UBadge>
|
||||||
</div>
|
</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 v-for="ex in exercises" :key="ex.id" class="rounded-xl border border-zinc-200 dark:border-zinc-800 p-3 space-y-2">
|
||||||
@@ -96,23 +96,23 @@ async function finish() {
|
|||||||
|
|
||||||
<div v-for="s in setsFor(ex.id)" :key="s.id" class="flex items-center gap-3 text-sm pl-1">
|
<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 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>{{ s.reps ?? '–' }} {{ t('session.reps') }}</span>
|
||||||
<span v-if="s.weight" class="text-zinc-500">× {{ s.weight }} kg</span>
|
<span v-if="s.weight" class="text-zinc-500">× {{ s.weight }} {{ t('session.weight') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 pt-1">
|
<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] ??= {}).reps" type="number" :placeholder="t('session.reps')" size="sm" class="w-20" />
|
||||||
<UInput v-model.number="(draft[ex.id] ??= {}).weight" type="number" placeholder="kg" 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)" />
|
<UButton icon="i-lucide-plus" color="primary" size="sm" @click="logSet(ex.id)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UButton block color="neutral" variant="subtle" icon="i-lucide-plus" label="Ajouter un exercice" @click="pickerOpen = true" />
|
<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="Terminer la séance" :loading="finishing" @click="finish" />
|
<UButton block size="xl" color="primary" class="font-bold" icon="i-lucide-check" :label="t('session.finish')" :loading="finishing" @click="finish" />
|
||||||
|
|
||||||
<!-- Picker d'exercice -->
|
<!-- Picker d'exercice -->
|
||||||
<UModal v-model:open="pickerOpen" title="Ajouter un exercice">
|
<UModal v-model:open="pickerOpen" :title="t('session.addExercise')">
|
||||||
<template #body>
|
<template #body>
|
||||||
<UInput v-model="search" icon="i-lucide-search" :placeholder="t('exercises.search')" size="lg" class="w-full mb-3" autofocus />
|
<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">
|
<div class="max-h-80 overflow-y-auto space-y-1">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ definePageMeta({ middleware: 'auth' })
|
|||||||
const { api } = useApi()
|
const { api } = useApi()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -13,7 +14,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
await navigateTo(`/session/${res.session.id}`)
|
await navigateTo(`/session/${res.session.id}`)
|
||||||
} catch {
|
} catch {
|
||||||
toast.add({ title: 'Impossible de démarrer la séance', color: 'error' })
|
toast.add({ title: t('session.cantStart'), color: 'error' })
|
||||||
await navigateTo('/')
|
await navigateTo('/')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -22,6 +23,6 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center justify-center py-24 gap-4">
|
<div class="flex flex-col items-center justify-center py-24 gap-4">
|
||||||
<SfLogo :size="72" loader />
|
<SfLogo :size="72" loader />
|
||||||
<p class="text-sm text-zinc-500">Préparation de ta séance…</p>
|
<p class="text-sm text-zinc-500">{{ t('session.preparing') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+51
-6
@@ -22,32 +22,77 @@
|
|||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Your flame",
|
"title": "Your flame",
|
||||||
"streakDays": "day streak",
|
"streakDays": "day streak",
|
||||||
"day": "day | days",
|
|
||||||
"longest": "Best",
|
"longest": "Best",
|
||||||
"freezes": "Freezes",
|
"freezes": "Freezes",
|
||||||
"weekGoal": "This week's goal",
|
"weekGoal": "This week's goal",
|
||||||
"sessionsDone": "{done}/{goal} workouts",
|
|
||||||
"recentSessions": "Recent workouts",
|
"recentSessions": "Recent workouts",
|
||||||
"achievements": "Badges",
|
"achievements": "Badges",
|
||||||
"startWorkout": "Start a workout",
|
"startWorkout": "Start a workout",
|
||||||
"activeToday": "Active today 🔥",
|
"activeToday": "Active today 🔥",
|
||||||
"keepFlame": "Train today to keep your flame alive!",
|
"keepFlame": "Train today to keep your flame alive!",
|
||||||
"restEarned": "You've earned a rest day 😌",
|
"restEarned": "You've earned a rest day 😌",
|
||||||
"noSessions": "No workouts yet. Time to start!"
|
"noSessions": "No workouts yet. Time to start!",
|
||||||
|
"session": "Workout",
|
||||||
|
"sets": "sets"
|
||||||
},
|
},
|
||||||
"exercises": {
|
"exercises": {
|
||||||
"title": "Exercises",
|
"title": "Exercises",
|
||||||
"search": "Search an exercise…",
|
"search": "Search an exercise…",
|
||||||
"filters": "Filters",
|
|
||||||
"bodyPart": "Body part",
|
"bodyPart": "Body part",
|
||||||
"equipment": "Equipment",
|
|
||||||
"target": "Muscle",
|
"target": "Muscle",
|
||||||
"category": "Category",
|
|
||||||
"instructions": "Instructions",
|
"instructions": "Instructions",
|
||||||
"secondaryMuscles": "Secondary muscles",
|
"secondaryMuscles": "Secondary muscles",
|
||||||
"noResults": "No exercise found.",
|
"noResults": "No exercise found.",
|
||||||
"all": "All"
|
"all": "All"
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"inProgress": "Workout in progress",
|
||||||
|
"addExercise": "Add an exercise",
|
||||||
|
"finish": "Finish workout",
|
||||||
|
"reps": "reps",
|
||||||
|
"weight": "kg",
|
||||||
|
"live": "live",
|
||||||
|
"preparing": "Getting your workout ready…",
|
||||||
|
"cantStart": "Couldn't start the workout",
|
||||||
|
"streakToast": "Streak: {n} days"
|
||||||
|
},
|
||||||
|
"routines": {
|
||||||
|
"new": "New",
|
||||||
|
"newRoutine": "New routine",
|
||||||
|
"none": "No routine yet. Create your first!",
|
||||||
|
"exercises": "exercises",
|
||||||
|
"name": "Name",
|
||||||
|
"description": "Description",
|
||||||
|
"addLater": "You'll be able to add exercises to the routine afterwards.",
|
||||||
|
"builder": {
|
||||||
|
"namePlaceholder": "Routine name",
|
||||||
|
"descPlaceholder": "Description (optional)",
|
||||||
|
"public": "Public",
|
||||||
|
"empty": "No exercise yet. Add some to build your routine.",
|
||||||
|
"addExercise": "Add an exercise",
|
||||||
|
"sets": "Sets",
|
||||||
|
"reps": "Reps",
|
||||||
|
"rest": "Rest (s)",
|
||||||
|
"tempo": "Tempo",
|
||||||
|
"weight": "Weight (kg)",
|
||||||
|
"notes": "Notes",
|
||||||
|
"remove": "Remove",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"start": "Start workout",
|
||||||
|
"save": "Save",
|
||||||
|
"saved": "Routine saved ✓",
|
||||||
|
"unsaved": "Unsaved changes",
|
||||||
|
"superset": "Superset",
|
||||||
|
"restTimer": "s rest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"language": "Language",
|
||||||
|
"reminders": "Streak reminders 🔥",
|
||||||
|
"remindersDesc": "An evening reminder when your flame is at risk.",
|
||||||
|
"test": "Send a test",
|
||||||
|
"remindersOn": "Reminders enabled 🔥"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
|||||||
+51
-6
@@ -22,32 +22,77 @@
|
|||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Ta flamme",
|
"title": "Ta flamme",
|
||||||
"streakDays": "jours de série",
|
"streakDays": "jours de série",
|
||||||
"day": "jour | jours",
|
|
||||||
"longest": "Record",
|
"longest": "Record",
|
||||||
"freezes": "Freezes",
|
"freezes": "Freezes",
|
||||||
"weekGoal": "Objectif de la semaine",
|
"weekGoal": "Objectif de la semaine",
|
||||||
"sessionsDone": "{done}/{goal} séances",
|
|
||||||
"recentSessions": "Séances récentes",
|
"recentSessions": "Séances récentes",
|
||||||
"achievements": "Badges",
|
"achievements": "Badges",
|
||||||
"startWorkout": "Démarrer une séance",
|
"startWorkout": "Démarrer une séance",
|
||||||
"activeToday": "Actif aujourd'hui 🔥",
|
"activeToday": "Actif aujourd'hui 🔥",
|
||||||
"keepFlame": "Entraîne-toi aujourd'hui pour garder ta flamme !",
|
"keepFlame": "Entraîne-toi aujourd'hui pour garder ta flamme !",
|
||||||
"restEarned": "Tu as gagné un jour de repos 😌",
|
"restEarned": "Tu as gagné un jour de repos 😌",
|
||||||
"noSessions": "Aucune séance pour l'instant. C'est le moment de commencer !"
|
"noSessions": "Aucune séance pour l'instant. C'est le moment de commencer !",
|
||||||
|
"session": "Séance",
|
||||||
|
"sets": "séries"
|
||||||
},
|
},
|
||||||
"exercises": {
|
"exercises": {
|
||||||
"title": "Exercices",
|
"title": "Exercices",
|
||||||
"search": "Rechercher un exercice…",
|
"search": "Rechercher un exercice…",
|
||||||
"filters": "Filtres",
|
|
||||||
"bodyPart": "Zone",
|
"bodyPart": "Zone",
|
||||||
"equipment": "Matériel",
|
|
||||||
"target": "Muscle",
|
"target": "Muscle",
|
||||||
"category": "Catégorie",
|
|
||||||
"instructions": "Instructions",
|
"instructions": "Instructions",
|
||||||
"secondaryMuscles": "Muscles secondaires",
|
"secondaryMuscles": "Muscles secondaires",
|
||||||
"noResults": "Aucun exercice trouvé.",
|
"noResults": "Aucun exercice trouvé.",
|
||||||
"all": "Tous"
|
"all": "Tous"
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"inProgress": "Séance en cours",
|
||||||
|
"addExercise": "Ajouter un exercice",
|
||||||
|
"finish": "Terminer la séance",
|
||||||
|
"reps": "reps",
|
||||||
|
"weight": "kg",
|
||||||
|
"live": "en cours",
|
||||||
|
"preparing": "Préparation de ta séance…",
|
||||||
|
"cantStart": "Impossible de démarrer la séance",
|
||||||
|
"streakToast": "Série : {n} jours"
|
||||||
|
},
|
||||||
|
"routines": {
|
||||||
|
"new": "Nouvelle",
|
||||||
|
"newRoutine": "Nouvelle routine",
|
||||||
|
"none": "Aucune routine. Crée ta première !",
|
||||||
|
"exercises": "exercices",
|
||||||
|
"name": "Nom",
|
||||||
|
"description": "Description",
|
||||||
|
"addLater": "Tu pourras ajouter des exercices ensuite depuis la routine.",
|
||||||
|
"builder": {
|
||||||
|
"namePlaceholder": "Nom de la routine",
|
||||||
|
"descPlaceholder": "Description (optionnel)",
|
||||||
|
"public": "Publique",
|
||||||
|
"empty": "Aucun exercice. Ajoute-en pour construire ta routine.",
|
||||||
|
"addExercise": "Ajouter un exercice",
|
||||||
|
"sets": "Séries",
|
||||||
|
"reps": "Reps",
|
||||||
|
"rest": "Repos (s)",
|
||||||
|
"tempo": "Tempo",
|
||||||
|
"weight": "Poids (kg)",
|
||||||
|
"notes": "Notes",
|
||||||
|
"remove": "Retirer",
|
||||||
|
"duplicate": "Dupliquer",
|
||||||
|
"start": "Démarrer la séance",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"saved": "Routine enregistrée ✓",
|
||||||
|
"unsaved": "Modifications non enregistrées",
|
||||||
|
"superset": "Superset",
|
||||||
|
"restTimer": "s de repos"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"language": "Langue",
|
||||||
|
"reminders": "Rappels de série 🔥",
|
||||||
|
"remindersDesc": "Un rappel le soir si tu risques de perdre ta flamme.",
|
||||||
|
"test": "Envoyer un test",
|
||||||
|
"remindersOn": "Rappels activés 🔥"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Chargement…",
|
"loading": "Chargement…",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
|
|||||||
+51
-6
@@ -22,32 +22,77 @@
|
|||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Sua chama",
|
"title": "Sua chama",
|
||||||
"streakDays": "dias de sequência",
|
"streakDays": "dias de sequência",
|
||||||
"day": "dia | dias",
|
|
||||||
"longest": "Recorde",
|
"longest": "Recorde",
|
||||||
"freezes": "Freezes",
|
"freezes": "Freezes",
|
||||||
"weekGoal": "Meta da semana",
|
"weekGoal": "Meta da semana",
|
||||||
"sessionsDone": "{done}/{goal} treinos",
|
|
||||||
"recentSessions": "Treinos recentes",
|
"recentSessions": "Treinos recentes",
|
||||||
"achievements": "Conquistas",
|
"achievements": "Conquistas",
|
||||||
"startWorkout": "Iniciar treino",
|
"startWorkout": "Iniciar treino",
|
||||||
"activeToday": "Ativo hoje 🔥",
|
"activeToday": "Ativo hoje 🔥",
|
||||||
"keepFlame": "Treine hoje para manter sua chama!",
|
"keepFlame": "Treine hoje para manter sua chama!",
|
||||||
"restEarned": "Você ganhou um dia de descanso 😌",
|
"restEarned": "Você ganhou um dia de descanso 😌",
|
||||||
"noSessions": "Nenhum treino ainda. Hora de começar!"
|
"noSessions": "Nenhum treino ainda. Hora de começar!",
|
||||||
|
"session": "Treino",
|
||||||
|
"sets": "séries"
|
||||||
},
|
},
|
||||||
"exercises": {
|
"exercises": {
|
||||||
"title": "Exercícios",
|
"title": "Exercícios",
|
||||||
"search": "Buscar exercício…",
|
"search": "Buscar exercício…",
|
||||||
"filters": "Filtros",
|
|
||||||
"bodyPart": "Região",
|
"bodyPart": "Região",
|
||||||
"equipment": "Equipamento",
|
|
||||||
"target": "Músculo",
|
"target": "Músculo",
|
||||||
"category": "Categoria",
|
|
||||||
"instructions": "Instruções",
|
"instructions": "Instruções",
|
||||||
"secondaryMuscles": "Músculos secundários",
|
"secondaryMuscles": "Músculos secundários",
|
||||||
"noResults": "Nenhum exercício encontrado.",
|
"noResults": "Nenhum exercício encontrado.",
|
||||||
"all": "Todos"
|
"all": "Todos"
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"inProgress": "Treino em andamento",
|
||||||
|
"addExercise": "Adicionar exercício",
|
||||||
|
"finish": "Finalizar treino",
|
||||||
|
"reps": "reps",
|
||||||
|
"weight": "kg",
|
||||||
|
"live": "ao vivo",
|
||||||
|
"preparing": "Preparando seu treino…",
|
||||||
|
"cantStart": "Não foi possível iniciar o treino",
|
||||||
|
"streakToast": "Sequência: {n} dias"
|
||||||
|
},
|
||||||
|
"routines": {
|
||||||
|
"new": "Nova",
|
||||||
|
"newRoutine": "Nova rotina",
|
||||||
|
"none": "Nenhuma rotina. Crie a primeira!",
|
||||||
|
"exercises": "exercícios",
|
||||||
|
"name": "Nome",
|
||||||
|
"description": "Descrição",
|
||||||
|
"addLater": "Você poderá adicionar exercícios depois na rotina.",
|
||||||
|
"builder": {
|
||||||
|
"namePlaceholder": "Nome da rotina",
|
||||||
|
"descPlaceholder": "Descrição (opcional)",
|
||||||
|
"public": "Pública",
|
||||||
|
"empty": "Nenhum exercício. Adicione para montar sua rotina.",
|
||||||
|
"addExercise": "Adicionar exercício",
|
||||||
|
"sets": "Séries",
|
||||||
|
"reps": "Reps",
|
||||||
|
"rest": "Descanso (s)",
|
||||||
|
"tempo": "Tempo",
|
||||||
|
"weight": "Peso (kg)",
|
||||||
|
"notes": "Notas",
|
||||||
|
"remove": "Remover",
|
||||||
|
"duplicate": "Duplicar",
|
||||||
|
"start": "Iniciar treino",
|
||||||
|
"save": "Salvar",
|
||||||
|
"saved": "Rotina salva ✓",
|
||||||
|
"unsaved": "Alterações não salvas",
|
||||||
|
"superset": "Superset",
|
||||||
|
"restTimer": "s de descanso"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"language": "Idioma",
|
||||||
|
"reminders": "Lembretes de sequência 🔥",
|
||||||
|
"remindersDesc": "Um lembrete à noite se você corre risco de perder a chama.",
|
||||||
|
"test": "Enviar um teste",
|
||||||
|
"remindersOn": "Lembretes ativados 🔥"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Carregando…",
|
"loading": "Carregando…",
|
||||||
"save": "Salvar",
|
"save": "Salvar",
|
||||||
|
|||||||
Reference in New Issue
Block a user