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
+8
View File
@@ -0,0 +1,8 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'flame',
neutral: 'zinc'
}
}
})
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
const auth = useAuthStore()
const booting = ref(true)
useSeoMeta({
title: 'StreakFit',
description: 'Garde la flamme. Entraîne-toi, suis ta série, progresse.'
})
onMounted(async () => {
await auth.fetchMe()
booting.value = false
})
</script>
<template>
<UApp>
<NuxtLoadingIndicator color="#F97316" />
<SfLoadingScreen v-if="booting" />
<NuxtLayout v-else>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>
+72
View File
@@ -0,0 +1,72 @@
@import "tailwindcss";
@import "@nuxt/ui";
/*
* StreakFit — thème « flamme ».
* Palette primaire = flame (orange→rouge), highlight ember/amber. Base claire + dark.
*/
@theme static {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Inter', ui-sans-serif, system-ui, sans-serif;
/* Échelle "flame" utilisée comme couleur primaire Nuxt UI (app.config primary: 'flame') */
--color-flame-50: #FFF7ED;
--color-flame-100: #FFEDD5;
--color-flame-200: #FED7AA;
--color-flame-300: #FDBA74;
--color-flame-400: #FB923C;
--color-flame-500: #F97316;
--color-flame-600: #EA580C;
--color-flame-700: #C2410C;
--color-flame-800: #9A3412;
--color-flame-900: #7C2D12;
--color-flame-950: #431407;
--color-ember-500: #EF4444;
--color-amber-glow: #FBBF24;
}
:root {
--sf-flame-gradient: linear-gradient(160deg, #FBBF24 0%, #F97316 45%, #EF4444 100%);
}
/* Dégradé flamme réutilisable (texte / fond) */
.sf-flame-text {
background: var(--sf-flame-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.sf-flame-bg { background: var(--sf-flame-gradient); }
/* ---- Animation du logo/loader "SF" ---- */
@keyframes sf-flicker {
0%, 100% { opacity: 1; transform: translateY(0) scaleY(1); }
25% { opacity: 0.92; transform: translateY(-1%) scaleY(1.04); }
50% { opacity: 1; transform: translateY(0.5%) scaleY(0.98); }
75% { opacity: 0.95; transform: translateY(-0.5%) scaleY(1.03); }
}
@keyframes sf-draw {
to { stroke-dashoffset: 0; }
}
@keyframes sf-rise {
0% { transform: translateY(6%) scale(0.96); opacity: 0.6; }
50% { transform: translateY(-3%) scale(1.02); opacity: 1; }
100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes sf-spin-glow {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.sf-logo__flame { transform-origin: 50% 90%; animation: sf-flicker 1.6s ease-in-out infinite; }
.sf-logo__mark { stroke-dasharray: 200; stroke-dashoffset: 200; animation: sf-draw 1.1s ease-out forwards; }
/* Mode "loader" : la flamme monte + halo tournant */
.sf-loader .sf-logo__flame { animation: sf-rise 1.1s ease-in-out infinite alternate; }
.sf-loader .sf-logo__halo { animation: sf-spin-glow 2.4s linear infinite; transform-origin: center; }
@media (prefers-reduced-motion: reduce) {
.sf-logo__flame, .sf-logo__mark, .sf-loader .sf-logo__flame, .sf-loader .sf-logo__halo { animation: none; }
.sf-logo__mark { stroke-dashoffset: 0; }
}
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
defineProps<{ label?: string }>()
</script>
<template>
<div class="fixed inset-0 z-50 flex flex-col items-center justify-center gap-6 bg-white dark:bg-zinc-950">
<div class="absolute inset-0 opacity-10 sf-flame-bg" />
<SfLogo :size="96" loader class="relative drop-shadow-lg" />
<p class="relative text-lg font-extrabold tracking-tight">
<span class="sf-flame-text">Streak</span><span class="text-zinc-900 dark:text-white">Fit</span>
</p>
<p v-if="label" class="relative text-sm text-zinc-500">{{ label }}</p>
</div>
</template>
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
/**
* Logo "SF" de StreakFit — monogramme flamme animé.
* Sert AUSSI de loader : passer `loader` pour le mode chargement (flamme qui monte + halo tournant).
*/
const props = withDefaults(defineProps<{
size?: number
loader?: boolean
label?: string
}>(), {
size: 40,
loader: false,
label: 'StreakFit'
})
const uid = useId()
</script>
<template>
<svg
:class="['sf-logo', { 'sf-loader': props.loader }]"
:width="props.size"
:height="props.size"
viewBox="0 0 64 64"
fill="none"
role="img"
:aria-label="props.label"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient :id="`flame-${uid}`" x1="20" y1="8" x2="46" y2="60" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FBBF24" />
<stop offset="0.5" stop-color="#F97316" />
<stop offset="1" stop-color="#EF4444" />
</linearGradient>
</defs>
<!-- Halo (visible en mode loader) -->
<circle
v-if="props.loader"
class="sf-logo__halo"
cx="32" cy="32" r="28"
:stroke="`url(#flame-${uid})`"
stroke-width="3"
stroke-linecap="round"
stroke-dasharray="44 132"
opacity="0.7"
/>
<!-- Badge arrondi -->
<rect x="8" y="8" width="48" height="48" rx="14" :fill="`url(#flame-${uid})`" />
<!-- Flamme qui vacille au-dessus du monogramme -->
<path
class="sf-logo__flame"
d="M32 13c3.6 3.1 5.3 6.4 4.1 9.4-.8 2-2.7 2.9-2.7 5.1 0 1.7 1.2 3 2.9 3 2.3 0 3.9-2 3.9-5 3 2.4 4.6 5.4 4.6 8.6C44.8 40.4 39 45 32 45s-12.8-4.6-12.8-10.9c0-4.8 3.2-9.2 8.2-13.4-.4 2.3.3 4 2 4 1.4 0 2.3-1.1 2.3-2.6 0-2.8-2-4.9-.7-9.1Z"
fill="#FFF7ED"
opacity="0.95"
/>
<!-- Monogramme "SF" -->
<text
x="32" y="49"
text-anchor="middle"
font-family="Inter, ui-sans-serif, system-ui, sans-serif"
font-size="20"
font-weight="800"
letter-spacing="-1"
fill="#7C2D12"
opacity="0.9"
>SF</text>
</svg>
</template>
+33
View File
@@ -0,0 +1,33 @@
/**
* Client HTTP unique vers l'API StreakFit.
* Auth = cookie de session Sanctum (credentials: 'include') + en-tête X-XSRF-TOKEN.
* Le front ne stocke AUCUN token : tout est piloté par le back.
*/
function readCookie(name: string): string | null {
if (import.meta.server) return null
const m = document.cookie.match(new RegExp('(^|; )' + name + '=([^;]*)'))
return m ? decodeURIComponent(m[2]!) : null
}
export function useApi() {
const base = useRuntimeConfig().public.apiBase as string
const api = $fetch.create({
baseURL: base,
credentials: 'include',
headers: { Accept: 'application/json' },
onRequest({ options }) {
const xsrf = readCookie('XSRF-TOKEN')
const headers = new Headers(options.headers as HeadersInit)
if (xsrf) headers.set('X-XSRF-TOKEN', xsrf)
options.headers = headers
}
})
/** Pose le cookie XSRF avant toute requête mutante (login, register, POST…). */
async function ensureCsrf() {
await $fetch('/sanctum/csrf-cookie', { baseURL: base, credentials: 'include' })
}
return { api, ensureCsrf, base }
}
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
const auth = useAuthStore()
const { locale, locales, setLocale } = useI18n()
const { t } = useI18n()
const tabs = computed(() => [
{ to: '/', icon: 'i-lucide-flame', label: t('nav.dashboard') },
{ to: '/exercises', icon: 'i-lucide-dumbbell', label: t('nav.exercises') },
{ to: '/routines', icon: 'i-lucide-list-checks', label: t('nav.routines') },
{ to: '/history', icon: 'i-lucide-history', label: t('nav.history') },
{ to: '/profile', icon: 'i-lucide-user', label: t('nav.profile') }
])
const route = useRoute()
const langItems = computed(() => (locales.value as { code: string, name: string }[]).map(l => ({
label: l.name,
onSelect: () => setLocale(l.code as 'fr' | 'en' | 'pt-BR')
})))
</script>
<template>
<div class="min-h-dvh flex flex-col bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100">
<!-- Header -->
<header class="sticky top-0 z-30 flex items-center justify-between px-4 h-14 border-b border-zinc-200/70 dark:border-zinc-800/70 backdrop-blur bg-white/80 dark:bg-zinc-950/80">
<NuxtLink to="/" class="flex items-center gap-2">
<SfLogo :size="30" />
<span class="font-extrabold tracking-tight text-lg">
<span class="sf-flame-text">Streak</span><span>Fit</span>
</span>
</NuxtLink>
<div class="flex items-center gap-1">
<UDropdownMenu :items="langItems">
<UButton icon="i-lucide-languages" color="neutral" variant="ghost" size="sm" :label="locale.toUpperCase()" />
</UDropdownMenu>
<UColorModeButton />
</div>
</header>
<!-- Contenu -->
<main class="flex-1 w-full max-w-2xl mx-auto px-4 py-5 pb-24">
<slot />
</main>
<!-- Bottom nav (mobile-first, masquée si non connecté) -->
<nav
v-if="auth.isAuthed"
class="fixed bottom-0 inset-x-0 z-30 border-t border-zinc-200 dark:border-zinc-800 bg-white/95 dark:bg-zinc-950/95 backdrop-blur"
>
<ul class="max-w-2xl mx-auto grid grid-cols-5">
<li v-for="tab in tabs" :key="tab.to">
<NuxtLink
:to="tab.to"
class="flex flex-col items-center gap-0.5 py-2.5 text-xs transition-colors"
:class="route.path === tab.to
? 'text-flame-600 dark:text-flame-400'
: 'text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200'"
>
<UIcon :name="tab.icon" class="size-5" />
<span>{{ tab.label }}</span>
</NuxtLink>
</li>
</ul>
</nav>
</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
/** Redirige vers /login si l'utilisateur n'est pas authentifié (l'état d'auth est déjà résolu au boot). */
export default defineNuxtRouteMiddleware(() => {
const auth = useAuthStore()
if (!auth.isAuthed) {
return navigateTo('/login')
}
})
+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>
+69
View File
@@ -0,0 +1,69 @@
import { defineStore } from 'pinia'
/**
* Store d'auth "mince" : il ne fait que relayer vers le back et mémoriser l'utilisateur courant.
* Aucune logique métier ici (règle : tout est géré côté back).
*/
interface SfUser {
id: number
name: string
email: string
locale?: string
units?: string
avatar_url?: string | null
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<SfUser | null>(null)
const streak = ref<{ current: number, longest: number, freezes: number } | null>(null)
const ready = ref(false)
const isAuthed = computed(() => !!user.value)
async function fetchMe() {
const { api } = useApi()
try {
const res = await api<{ user: SfUser, streak: typeof streak.value }>('/api/me')
user.value = res.user
streak.value = res.streak
} catch {
user.value = null
streak.value = null
} finally {
ready.value = true
}
}
async function login(email: string, password: string) {
const { api, ensureCsrf } = useApi()
await ensureCsrf()
const res = await api<{ user: SfUser }>('/login', { method: 'POST', body: { email, password } })
user.value = res.user
await fetchMe()
}
async function register(name: string, email: string, password: string) {
const { api, ensureCsrf } = useApi()
await ensureCsrf()
const res = await api<{ user: SfUser }>('/register', { method: 'POST', body: { name, email, password } })
user.value = res.user
await fetchMe()
}
async function logout() {
const { api, ensureCsrf } = useApi()
await ensureCsrf()
try { await api('/logout', { method: 'POST' }) } finally {
user.value = null
streak.value = null
}
}
/** Login OIDC pocket-id : 100% back — on redirige simplement le navigateur. */
function loginWithPocketId() {
const { base } = useApi()
window.location.href = `${base}/auth/oidc/redirect`
}
return { user, streak, ready, isAuthed, fetchMe, login, register, logout, loginWithPocketId }
})