Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.output
|
||||
.nuxt
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
.DS_Store
|
||||
dist
|
||||
Executable
+13
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Nuxt Starter Template
|
||||
|
||||
[](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
|
||||
|
||||
[](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.
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
// @ts-check
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt(
|
||||
// Your custom configs here
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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' } }
|
||||
}
|
||||
})
|
||||
Generated
+23020
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": [
|
||||
"github>nuxt/renovate-config-nuxt"
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
},
|
||||
"packageRules": [{
|
||||
"matchDepTypes": ["resolutions"],
|
||||
"enabled": false
|
||||
}],
|
||||
"postUpdateOptions": ["pnpmDedupe"]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user