Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'flame',
|
||||
neutral: 'zinc'
|
||||
}
|
||||
}
|
||||
})
|
||||
+24
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
})
|
||||
Reference in New Issue
Block a user