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 @@
node_modules
.output
.nuxt
.git
.gitignore
*.log
.DS_Store
dist
Executable
+13
View File
@@ -0,0 +1,13 @@
# editorconfig.org
root = true
[*]
indent_size = 2
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
+31
View File
@@ -0,0 +1,31 @@
name: Build & Deploy
on:
push:
branches: [main]
workflow_dispatch: {}
env:
IMAGE: git.nfteam.ovh/neckfire/streakfit-web
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build & push image
run: |
set -e
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin
SHA="${GITHUB_SHA::12}"
docker build -t "${IMAGE}:latest" -t "${IMAGE}:${SHA}" -f Dockerfile .
docker push --all-tags "${IMAGE}"
- name: Notify ntfy
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then EMOJI="white_check_mark"; PRIO="default"; else EMOJI="rotating_light"; PRIO="high"; fi
curl -s -H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" -H "Title: ${GITHUB_REPOSITORY} — ${{ job.status }}" \
-H "Priority: ${PRIO}" -H "Tags: ${EMOJI}" -H "Click: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions" \
-d "${GITHUB_WORKFLOW} (${GITHUB_REF_NAME} #${GITHUB_RUN_NUMBER}) : ${{ job.status }}" \
"${{ secrets.NTFY_URL }}/${{ secrets.NTFY_TOPIC }}" || true
+34
View File
@@ -0,0 +1,34 @@
name: ci
on: push
jobs:
ci:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
node: [22]
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6
- name: Install node
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Lint
run: pnpm run lint
- name: Typecheck
run: pnpm run typecheck
+24
View File
@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
+17
View File
@@ -0,0 +1,17 @@
# StreakFit — front Nuxt (SPA/PWA), servi par le serveur Nitro (node).
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOST=0.0.0.0
COPY --from=build /app/.output ./.output
EXPOSE 3000
# NUXT_PUBLIC_API_BASE est injecté au runtime (voir docker-compose.yml)
CMD ["node", ".output/server/index.mjs"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Nuxt UI Templates
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+64
View File
@@ -0,0 +1,64 @@
# Nuxt Starter Template
[![Nuxt UI](https://img.shields.io/badge/Made%20with-Nuxt%20UI-00DC82?logo=nuxt&labelColor=020420)](https://ui.nuxt.com)
Use this template to get started with [Nuxt UI](https://ui.nuxt.com) quickly.
- [Live demo](https://starter-template.nuxt.dev/)
- [Documentation](https://ui.nuxt.com/docs/getting-started/installation/nuxt)
<a href="https://starter-template.nuxt.dev/" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://ui.nuxt.com/assets/templates/nuxt/starter-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://ui.nuxt.com/assets/templates/nuxt/starter-light.png">
<img alt="Nuxt Starter Template" src="https://ui.nuxt.com/assets/templates/nuxt/starter-light.png" width="830" height="466">
</picture>
</a>
> The starter template for Vue is on https://github.com/nuxt-ui-templates/starter-vue.
## Quick Start
```bash [Terminal]
npm create nuxt@latest -- -t ui
```
## Deploy your own
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-name=starter&repository-url=https%3A%2F%2Fgithub.com%2Fnuxt-ui-templates%2Fstarter&demo-image=https%3A%2F%2Fui.nuxt.com%2Fassets%2Ftemplates%2Fnuxt%2Fstarter-dark.png&demo-url=https%3A%2F%2Fstarter-template.nuxt.dev%2F&demo-title=Nuxt%20Starter%20Template&demo-description=A%20minimal%20template%20to%20get%20started%20with%20Nuxt%20UI.)
## Setup
Make sure to install the dependencies:
```bash
pnpm install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
pnpm dev
```
## Production
Build the application for production:
```bash
pnpm build
```
Locally preview production build:
```bash
pnpm preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
## Renovate integration
Install [Renovate GitHub app](https://github.com/apps/renovate/installations/select_target) on your repository and you are good to go.
+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 }
})
+19
View File
@@ -0,0 +1,19 @@
# StreakFit — front Nuxt (SPA/PWA). Image buildée par la CI Gitea (neckfire/streakfit-web) : main -> :latest.
# Watchtower auto-deploy. streakfit.nfteam.ovh -> :3000 (via NPM).
services:
streakfit-web:
image: git.nfteam.ovh/neckfire/streakfit-web:latest
pull_policy: always
container_name: streakfit-web
environment:
- NUXT_PUBLIC_API_BASE=https://api.streakfit.nfteam.ovh
ports:
- "192.168.1.136:8110:3000"
labels:
- homepage.group=Développement
- homepage.name=StreakFit
- homepage.icon=mdi-fire
- homepage.href=https://streakfit.nfteam.ovh
- homepage.description=Cross-training & suivi de série (PWA)
- com.centurylinklabs.watchtower.enable=true
restart: unless-stopped
+6
View File
@@ -0,0 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)
+60
View File
@@ -0,0 +1,60 @@
{
"app": { "tagline": "Keep the flame." },
"nav": {
"dashboard": "Home",
"exercises": "Exercises",
"routines": "Routines",
"history": "History",
"profile": "Profile"
},
"auth": {
"signIn": "Sign in",
"signUp": "Create account",
"email": "Email",
"password": "Password",
"name": "Name",
"loginWithPocketId": "Continue with pocket-id",
"logout": "Log out",
"noAccount": "No account yet?",
"haveAccount": "Already have an account?",
"or": "or"
},
"dashboard": {
"title": "Your flame",
"streakDays": "day streak",
"day": "day | days",
"longest": "Best",
"freezes": "Freezes",
"weekGoal": "This week's goal",
"sessionsDone": "{done}/{goal} workouts",
"recentSessions": "Recent workouts",
"achievements": "Badges",
"startWorkout": "Start a workout",
"activeToday": "Active today 🔥",
"keepFlame": "Train today to keep your flame alive!",
"restEarned": "You've earned a rest day 😌",
"noSessions": "No workouts yet. Time to start!"
},
"exercises": {
"title": "Exercises",
"search": "Search an exercise…",
"filters": "Filters",
"bodyPart": "Body part",
"equipment": "Equipment",
"target": "Muscle",
"category": "Category",
"instructions": "Instructions",
"secondaryMuscles": "Secondary muscles",
"noResults": "No exercise found.",
"all": "All"
},
"common": {
"loading": "Loading…",
"save": "Save",
"cancel": "Cancel",
"close": "Close",
"retry": "Retry",
"error": "Something went wrong.",
"back": "Back"
}
}
+60
View File
@@ -0,0 +1,60 @@
{
"app": { "tagline": "Garde la flamme." },
"nav": {
"dashboard": "Accueil",
"exercises": "Exercices",
"routines": "Routines",
"history": "Historique",
"profile": "Profil"
},
"auth": {
"signIn": "Se connecter",
"signUp": "Créer un compte",
"email": "E-mail",
"password": "Mot de passe",
"name": "Nom",
"loginWithPocketId": "Continuer avec pocket-id",
"logout": "Déconnexion",
"noAccount": "Pas encore de compte ?",
"haveAccount": "Déjà un compte ?",
"or": "ou"
},
"dashboard": {
"title": "Ta flamme",
"streakDays": "jours de série",
"day": "jour | jours",
"longest": "Record",
"freezes": "Freezes",
"weekGoal": "Objectif de la semaine",
"sessionsDone": "{done}/{goal} séances",
"recentSessions": "Séances récentes",
"achievements": "Badges",
"startWorkout": "Démarrer une séance",
"activeToday": "Actif aujourd'hui 🔥",
"keepFlame": "Entraîne-toi aujourd'hui pour garder ta flamme !",
"restEarned": "Tu as gagné un jour de repos 😌",
"noSessions": "Aucune séance pour l'instant. C'est le moment de commencer !"
},
"exercises": {
"title": "Exercices",
"search": "Rechercher un exercice…",
"filters": "Filtres",
"bodyPart": "Zone",
"equipment": "Matériel",
"target": "Muscle",
"category": "Catégorie",
"instructions": "Instructions",
"secondaryMuscles": "Muscles secondaires",
"noResults": "Aucun exercice trouvé.",
"all": "Tous"
},
"common": {
"loading": "Chargement…",
"save": "Enregistrer",
"cancel": "Annuler",
"close": "Fermer",
"retry": "Réessayer",
"error": "Une erreur est survenue.",
"back": "Retour"
}
}
+60
View File
@@ -0,0 +1,60 @@
{
"app": { "tagline": "Mantenha a chama." },
"nav": {
"dashboard": "Início",
"exercises": "Exercícios",
"routines": "Rotinas",
"history": "Histórico",
"profile": "Perfil"
},
"auth": {
"signIn": "Entrar",
"signUp": "Criar conta",
"email": "E-mail",
"password": "Senha",
"name": "Nome",
"loginWithPocketId": "Continuar com pocket-id",
"logout": "Sair",
"noAccount": "Ainda não tem conta?",
"haveAccount": "Já tem conta?",
"or": "ou"
},
"dashboard": {
"title": "Sua chama",
"streakDays": "dias de sequência",
"day": "dia | dias",
"longest": "Recorde",
"freezes": "Freezes",
"weekGoal": "Meta da semana",
"sessionsDone": "{done}/{goal} treinos",
"recentSessions": "Treinos recentes",
"achievements": "Conquistas",
"startWorkout": "Iniciar treino",
"activeToday": "Ativo hoje 🔥",
"keepFlame": "Treine hoje para manter sua chama!",
"restEarned": "Você ganhou um dia de descanso 😌",
"noSessions": "Nenhum treino ainda. Hora de começar!"
},
"exercises": {
"title": "Exercícios",
"search": "Buscar exercício…",
"filters": "Filtros",
"bodyPart": "Região",
"equipment": "Equipamento",
"target": "Músculo",
"category": "Categoria",
"instructions": "Instruções",
"secondaryMuscles": "Músculos secundários",
"noResults": "Nenhum exercício encontrado.",
"all": "Todos"
},
"common": {
"loading": "Carregando…",
"save": "Salvar",
"cancel": "Cancelar",
"close": "Fechar",
"retry": "Tentar de novo",
"error": "Ocorreu um erro.",
"back": "Voltar"
}
}
+100
View File
@@ -0,0 +1,100 @@
// StreakFit — PWA installable (SPA), thème flamme. https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: [
'@nuxt/eslint',
'@nuxt/ui',
'@nuxt/image',
'@nuxtjs/i18n',
'@pinia/nuxt',
'@vite-pwa/nuxt'
],
// App installable + offline : SPA pur (auth par cookie côté navigateur).
ssr: false,
devtools: { enabled: true },
css: ['~/assets/css/main.css'],
runtimeConfig: {
public: {
apiBase: 'https://api.streakfit.nfteam.ovh'
}
},
app: {
head: {
htmlAttrs: { lang: 'fr' },
title: 'StreakFit',
titleTemplate: '%s · StreakFit',
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1, viewport-fit=cover' },
{ name: 'theme-color', content: '#F97316' },
{ name: 'description', content: 'Garde la flamme. Entraîne-toi, suis ta série, progresse.' }
],
link: [{ rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' }]
}
},
image: {
domains: ['s3.nfteam.ovh'],
format: ['webp']
},
i18n: {
strategy: 'no_prefix',
defaultLocale: 'fr',
locales: [
{ code: 'fr', name: 'Français', file: 'fr.json' },
{ code: 'pt-BR', name: 'Português (BR)', file: 'pt-BR.json' },
{ code: 'en', name: 'English', file: 'en.json' }
],
lazy: true,
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'sf_lang',
redirectOn: 'root'
}
},
pwa: {
registerType: 'autoUpdate',
manifest: {
name: 'StreakFit',
short_name: 'StreakFit',
description: 'Garde la flamme. Entraîne-toi, suis ta série, progresse.',
lang: 'fr',
theme_color: '#F97316',
background_color: '#0B0B0F',
display: 'standalone',
orientation: 'portrait',
start_url: '/',
icons: [
{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
workbox: {
navigateFallback: '/',
globPatterns: ['**/*.{js,css,html,svg,png,wo4,woff2}'],
runtimeCaching: [
{
urlPattern: ({ url }: { url: URL }) => url.hostname === 's3.nfteam.ovh',
handler: 'CacheFirst',
options: {
cacheName: 'sf-media',
expiration: { maxEntries: 500, maxAgeSeconds: 60 * 60 * 24 * 30 }
}
}
]
},
client: { installPrompt: true }
},
compatibilityDate: '2026-06-30',
eslint: {
config: { stylistic: { commaDangle: 'never', braceStyle: '1tbs' } }
}
})
+23020
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "streakfit-web",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@iconify-json/lucide": "^1.2.117",
"@iconify-json/simple-icons": "^1.2.90",
"@nuxt/image": "^1.11.0",
"@nuxt/ui": "^4.10.0",
"@nuxtjs/i18n": "^10.2.0",
"@pinia/nuxt": "^0.11.0",
"@vite-pwa/nuxt": "^1.0.4",
"nuxt": "~4.4.8",
"pinia": "^3.0.0",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
"@nuxt/eslint": "^1.16.0",
"eslint": "^10.7.0",
"typescript": "^6.0.3",
"vue-tsc": "^3.3.7"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="f" x1="20" y1="8" x2="46" y2="60" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FBBF24"/><stop offset=".5" stop-color="#F97316"/><stop offset="1" stop-color="#EF4444"/>
</linearGradient>
</defs>
<rect x="6" y="6" width="52" height="52" rx="15" fill="url(#f)"/>
<path d="M32 12c3.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 39.4 39 44 32 44s-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=".95"/>
<text x="32" y="50" text-anchor="middle" font-family="Inter,system-ui,sans-serif" font-size="20" font-weight="800" letter-spacing="-1" fill="#7C2D12" opacity=".9">SF</text>
</svg>

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+13
View File
@@ -0,0 +1,13 @@
{
"extends": [
"github>nuxt/renovate-config-nuxt"
],
"lockFileMaintenance": {
"enabled": true
},
"packageRules": [{
"matchDepTypes": ["resolutions"],
"enabled": false
}],
"postUpdateOptions": ["pnpmDedupe"]
}
+10
View File
@@ -0,0 +1,10 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{ "path": "./.nuxt/tsconfig.app.json" },
{ "path": "./.nuxt/tsconfig.server.json" },
{ "path": "./.nuxt/tsconfig.shared.json" },
{ "path": "./.nuxt/tsconfig.node.json" }
]
}