From 34a1c9ab1928d7948f383371eecb14d228d8fbcd Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Sun, 12 Apr 2026 11:41:52 +0200 Subject: [PATCH] feat: connect to api --- AGENTS.md | 26 +++++++ app/page.tsx | 65 ------------------ bun.lock | 11 +++ package.json | 3 + {app => src/app}/favicon.ico | Bin {app => src/app}/globals.css | 0 {app => src/app}/layout.tsx | 5 +- src/app/login/page.tsx | 79 +++++++++++++++++++++ src/app/page.tsx | 71 +++++++++++++++++++ src/app/register/page.tsx | 114 +++++++++++++++++++++++++++++++ src/hooks/queries/auth.ts | 77 +++++++++++++++++++++ src/lib/api-client.ts | 41 +++++++++++ src/lib/query-client.ts | 35 ++++++++++ src/providers/index.tsx | 12 ++++ src/providers/query-provider.tsx | 14 ++++ src/types/auth.ts | 13 ++++ tsconfig.json | 2 +- 17 files changed, 501 insertions(+), 67 deletions(-) delete mode 100644 app/page.tsx rename {app => src/app}/favicon.ico (100%) rename {app => src/app}/globals.css (100%) rename {app => src/app}/layout.tsx (82%) create mode 100644 src/app/login/page.tsx create mode 100644 src/app/page.tsx create mode 100644 src/app/register/page.tsx create mode 100644 src/hooks/queries/auth.ts create mode 100644 src/lib/api-client.ts create mode 100644 src/lib/query-client.ts create mode 100644 src/providers/index.tsx create mode 100644 src/providers/query-provider.tsx create mode 100644 src/types/auth.ts diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..95c0bed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,4 +2,30 @@ # This is NOT the Next.js you know This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + +# Guide d'Architecture : Approche Hybride Next.js + TanStack Query + +Ce document définit les standards pour l'intégration de données via l'architecture hybride (Server-Side Prefetching + Client-Side Hydration). + +## 1. Vision Globale +L'objectif est de supprimer les états de chargement (spinners) au premier chargement tout en conservant la puissance du cache de TanStack Query sur le client. + +**Flux de données :** +1. **Serveur :** Récupère les données (`prefetchQuery`). +2. **Transfert :** Sérialise le cache et l'envoie au client (`dehydrate`). +3. **Client :** Réhydrate le cache et prend le relais pour l'interactivité. + +## 2. Structure de Dossiers Recommandée +```text +src/ +├── app/ +│ ├── providers.tsx # Configuration du QueryClientProvider +│ └── posts/ +│ ├── page.tsx # Server Component (Prefetching) +│ └── posts-list.tsx # Client Component (Display + Logic) +├── hooks/ +│ └── queries/ # Centralisation des query keys et fonctions fetch +└── lib/ + └── query-client.ts # Configuration singleton du client +``` diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index 3f36f7c..0000000 --- a/app/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
- ); -} diff --git a/bun.lock b/bun.lock index 5a7758b..da2e5dc 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,15 @@ "": { "name": "next-starter", "dependencies": { + "@tanstack/react-query": "^5.99.0", + "js-cookie": "^3.0.5", "next": "16.2.3", "react": "19.2.4", "react-dom": "19.2.4", }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -216,10 +219,16 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "postcss": "^8.5.6", "tailwindcss": "4.2.2" } }, "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ=="], + "@tanstack/query-core": ["@tanstack/query-core@5.99.0", "", {}, "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.99.0", "", { "dependencies": { "@tanstack/query-core": "5.99.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], @@ -580,6 +589,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], diff --git a/package.json b/package.json index f3d7f3b..554cdf5 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,15 @@ "lint": "eslint" }, "dependencies": { + "@tanstack/react-query": "^5.99.0", + "js-cookie": "^3.0.5", "next": "16.2.3", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/app/favicon.ico b/src/app/favicon.ico similarity index 100% rename from app/favicon.ico rename to src/app/favicon.ico diff --git a/app/globals.css b/src/app/globals.css similarity index 100% rename from app/globals.css rename to src/app/globals.css diff --git a/app/layout.tsx b/src/app/layout.tsx similarity index 82% rename from app/layout.tsx rename to src/app/layout.tsx index 976eb90..c48648d 100644 --- a/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import Providers from "@/providers"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -27,7 +28,9 @@ export default function RootLayout({ lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + {children} + ); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..2925085 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useState } from 'react' +import { useLogin } from '@/hooks/queries/auth' +import { useRouter } from 'next/navigation' +import Link from 'next/link' + +export default function LoginPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const login = useLogin() + const router = useRouter() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + login.mutate({ email, password }, { + onSuccess: () => { + router.push('/') + } + }) + } + + return ( +
+
+
+

+ Connectez-vous à votre compte +

+
+
+
+
+ setEmail(e.target.value)} + /> +
+
+ setPassword(e.target.value)} + /> +
+
+ + {login.isError && ( +
+ {(login.error as any).message} +
+ )} + +
+ +
+
+
+ + Pas encore de compte ? S'inscrire + +
+
+
+ ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..9b26d43 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,71 @@ +'use client' + +import { useUser, useLogout } from '@/hooks/queries/auth' +import Link from 'next/link' + +export default function Home() { + const { data: user, isLoading } = useUser() + const logout = useLogout() + + if (isLoading) { + return ( +
+

Chargement...

+
+ ) + } + + return ( +
+
+

+ Bienvenue sur Next Starter +

+ + {user ? ( +
+
+ {user.avatar_url && ( + {user.name} + )} +

Bonjour, {user.name} !

+

{user.email}

+
+ + +
+ ) : ( +
+

+ Connectez-vous pour accéder à toutes les fonctionnalités. +

+
+ + Se connecter + + + S'inscrire + +
+
+ )} +
+
+ ) +} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx new file mode 100644 index 0000000..f53260f --- /dev/null +++ b/src/app/register/page.tsx @@ -0,0 +1,114 @@ +'use client' + +import { useState } from 'react' +import { useRegister } from '@/hooks/queries/auth' +import { useRouter } from 'next/navigation' +import Link from 'next/link' + +export default function RegisterPage() { + const [formData, setFormData] = useState({ + name: '', + email: '', + password: '', + password_confirmation: '', + avatar: null as File | null, + }) + const register = useRegister() + const router = useRouter() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + register.mutate(formData, { + onSuccess: () => { + router.push('/') + } + }) + } + + return ( +
+
+
+

+ Créez votre compte +

+
+
+
+
+ setFormData({ ...formData, name: e.target.value })} + /> +
+
+ setFormData({ ...formData, email: e.target.value })} + /> +
+
+ setFormData({ ...formData, password: e.target.value })} + /> +
+
+ setFormData({ ...formData, password_confirmation: e.target.value })} + /> +
+
+ +
+ + setFormData({ ...formData, avatar: e.target.files?.[0] || null })} + /> +
+ + {register.isError && ( +
+ {(register.error as any).message} +
+ )} + +
+ +
+
+
+ + Déjà un compte ? Se connecter + +
+
+
+ ) +} diff --git a/src/hooks/queries/auth.ts b/src/hooks/queries/auth.ts new file mode 100644 index 0000000..63f5988 --- /dev/null +++ b/src/hooks/queries/auth.ts @@ -0,0 +1,77 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api-client' +import Cookies from 'js-cookie' +import { User, AuthResponse } from '@/types/auth' + +export const authKeys = { + all: ['auth'] as const, + user: () => [...authKeys.all, 'user'] as const, +} + +export function useUser() { + return useQuery({ + queryKey: authKeys.user(), + queryFn: () => apiFetch('/auth/me'), + retry: false, + enabled: typeof window !== 'undefined' && !!Cookies.get('token'), + }) +} + +export function useLogin() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (credentials: any) => + apiFetch('/auth/login', { + method: 'POST', + body: JSON.stringify(credentials), + }), + onSuccess: (data) => { + Cookies.set('token', data.token, { expires: 30, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' }) + queryClient.setQueryData(authKeys.user(), data.user) + }, + }) +} + +export function useRegister() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (data: any) => { + const formData = new FormData(); + Object.keys(data).forEach(key => { + if (data[key] !== null && data[key] !== undefined) { + formData.append(key, data[key]); + } + }); + + return apiFetch('/auth/register', { + method: 'POST', + body: formData, + headers: { + 'Content-Type': 'undefined', + } + }) + }, + onSuccess: (data) => { + Cookies.set('token', data.token, { expires: 30, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' }) + queryClient.setQueryData(authKeys.user(), data.user) + }, + }) +} + +export function useLogout() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => + apiFetch('/auth/logout', { + method: 'POST', + }), + onSuccess: () => { + Cookies.remove('token') + queryClient.setQueryData(authKeys.user(), null) + queryClient.clear() + }, + }) +} diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..dfc6eac --- /dev/null +++ b/src/lib/api-client.ts @@ -0,0 +1,41 @@ +import Cookies from 'js-cookie'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'; + +export async function apiFetch(endpoint: string, options: RequestInit = {}) { + const token = typeof window !== 'undefined' ? Cookies.get('token') : null; + + const headers: Record = { + 'Accept': 'application/json', + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + }; + + + // Only set Content-Type if it's NOT FormData + if (!(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + + // Convert HeadersInit to a plain object + const optionsHeaders = Object.fromEntries(new Headers(options.headers).entries()); + + // Override headers from options if provided + const finalHeaders: Record = { ...headers, ...optionsHeaders }; + + // Delete Content-Type if set to 'undefined' to let the browser handle it (for FormData) + if (finalHeaders['Content-Type'] === 'undefined') { + delete finalHeaders['Content-Type']; + } + + const response = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers: finalHeaders, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'An error occurred'); + } + + return response.json(); +} diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts new file mode 100644 index 0000000..c0d3c1e --- /dev/null +++ b/src/lib/query-client.ts @@ -0,0 +1,35 @@ +import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query' + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + // With SSR, we usually want to set some default staleTime + // above 0 to avoid refetching immediately on the client + staleTime: 60 * 1000, + }, + dehydrate: { + // include pending queries in dehydration + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || + query.state.status === 'pending', + }, + }, + }) +} + +let browserQueryClient: QueryClient | undefined = undefined + +export function getQueryClient() { + if (typeof window === 'undefined') { + // Server: always make a new query client + return makeQueryClient() + } else { + // Browser: make a new query client if we don't already have one + // This is very important, so we don't re-make a new client if React + // suspends during the initial render. This may not be needed if we + // have a suspense boundary BELOW the creation of the query client + if (!browserQueryClient) browserQueryClient = makeQueryClient() + return browserQueryClient + } +} diff --git a/src/providers/index.tsx b/src/providers/index.tsx new file mode 100644 index 0000000..8d7373c --- /dev/null +++ b/src/providers/index.tsx @@ -0,0 +1,12 @@ +'use client' + +import { QueryProvider } from './query-provider' + +export default function Providers({ children }: { children: React.ReactNode }) { + return ( + + {/* Ajoutez d'autres providers ici si besoin (ThemeProvider, etc.) */} + {children} + + ) +} diff --git a/src/providers/query-provider.tsx b/src/providers/query-provider.tsx new file mode 100644 index 0000000..8da161c --- /dev/null +++ b/src/providers/query-provider.tsx @@ -0,0 +1,14 @@ +'use client' + +import { QueryClientProvider } from '@tanstack/react-query' +import { getQueryClient } from '@/lib/query-client' + +export function QueryProvider({ children }: { children: React.ReactNode }) { + const queryClient = getQueryClient() + + return ( + + {children} + + ) +} diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..db1383f --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,13 @@ +export interface User { + id: string; + name: string; + email: string; + avatar_url: string | null; + created_at: string; + updated_at: string; +} + +export interface AuthResponse { + user: User; + token: string; +} diff --git a/tsconfig.json b/tsconfig.json index 3a13f90..cf9c65d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,7 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": ["./src/*"] } }, "include": [