this string has no description
0
mini-projet-fare.md edited
599 lines 15 kB view raw view rendered
1# Mini-projet — FareMarks 2 3Gestionnaire de bookmarks taggés. 4Pour découvrir Prisma, Supabase, server actions Next 16, TanStack Form, Zod, Biome, Shadcn, Sonner. 5 6Commit quand ça te semble adapté (à chaque étape par exemple) 7 8--- 9 10## Prérequis 11 12Node 24, [`pnpm`](https://pnpm.io/installation), compte Supabase, éditeur de code au choix (je recommande [Zed](https://zed.dev/)) avec extension Biome. 13 14--- 15 16## Setup 17 18### 1. Créer le projet 19 20```bash 21pnpm create next-app@latest faremarks 22cd faremarks 23``` 24 25TS oui, ESLint **non**, Tailwind oui, `src/` oui, App Router oui, Turbopack 26oui, alias `@/*` oui. 27 28### 2. Dépendances 29 30```bash 31pnpm add @prisma/client @tanstack/react-form zod sonner nuqs clsx \ 32 tailwind-merge class-variance-authority lucide-react 33 34pnpm add -D prisma @biomejs/biome tsx 35``` 36 37### 3. Biome 38 39[biomejs.dev](https://biomejs.dev/) 40 41```bash 42pnpm biome init 43``` 44 45Remplace `biome.json` par [Annexe A](#annexe-a--biomejson). Test : 46`pnpm biome check --write`. 47 48### 4. Shadcn 49 50[ui.shadcn.com](https://ui.shadcn.com/) 51 52```bash 53# Initialise shadcn, avec les styles et paramètres par défaut 54pnpm dlx shadcn@latest init 55# Commence par ajouter quelques composants, tu pourras en ajouter plus tard si besoin 56pnpm dlx shadcn@latest add button card input textarea label badge dialog sonner 57``` 58 59Style Default, Neutral, CSS variables oui. 60 61### 5. Supabase 62 63[supabase.com](https://supabase.com) 64 65**Accueil > Get Connected > ORM** : 66 67- **Transaction pooler** (port 6543) → `DATABASE_URL` 68- **Direct connection** (port 5432) → `DIRECT_URL` 69 70### 6. `.env` 71 72Voir [Annexe B](#annexe-b--env). Ajoute `.env` à `.gitignore` si pas déjà dedans. 73 74### 7. Prisma 75 76```bash 77pnpm prisma init 78``` 79 80Remplace `prisma/schema.prisma` : 81 82```prisma 83generator client { 84 provider = "prisma-client-js" 85} 86 87datasource db { 88 provider = "postgresql" 89 url = env("DATABASE_URL") 90 directUrl = env("DIRECT_URL") 91} 92 93model Bookmark { 94 id String @id @default(cuid()) 95 url String 96 title String 97 createdAt DateTime @default(now()) 98} 99``` 100 101```bash 102pnpm prisma migrate dev --name init 103``` 104 105### 8. Client Prisma — `src/lib/prisma.ts` 106 107```ts 108import { PrismaClient } from "@prisma/client"; 109 110const globalForPrisma = globalThis as unknown as { 111 prisma: PrismaClient | undefined; 112}; 113 114export const prisma = globalForPrisma.prisma ?? new PrismaClient(); 115 116if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; 117``` 118 119> Cela évite de créer N instances en dev (hot reload). 120 121### 9. Providers — `src/app/layout.tsx` 122 123[sonner.emilkowal.ski](https://sonner.emilkowal.ski/) 124[nuqs.dev](https://nuqs.47ng.com/) 125 126```tsx 127import { NuqsAdapter } from "nuqs/adapters/next/app"; 128import { Toaster } from "@/components/ui/sonner"; 129 130// dans le <body>, enveloppe {children} : 131<NuqsAdapter>{children}</NuqsAdapter> 132<Toaster richColors position="top-right" /> 133``` 134 135### 10. Run 136 137```bash 138pnpm dev 139``` 140 141--- 142 143## Étape 1 — CRUD minimal 144 145À construire : 146 147- `src/app/page.tsx` (Server Component) : liste les bookmarks + form + 148 bouton delete par item 149- `src/actions/bookmarks/create.ts` (server action) — voir [Annexe C](#annexe-c--squelette-server-action) 150- `src/actions/bookmarks/delete.ts` (même pattern, prend `id`) 151- `src/components/BookmarkForm.tsx` (Client) — voir [Annexe D](#annexe-d--squelette-client-form) 152- `src/components/DeleteButton.tsx` (Client, `startTransition` + toast) 153 154Schéma Zod déclaré côté server action, types importés côté client via 155`import type`. Toast vert au succès, rouge en erreur. 156 157Docs : [TanStack Form](https://tanstack.com/form/latest) 158· [Zod v4](https://zod.dev/) 159 160--- 161 162## Étape 2 — Enrichir le modèle 163 164Ajoute au modèle `Bookmark` : 165 166```prisma 167description String? 168updatedAt DateTime @updatedAt 169``` 170 171```bash 172pnpm prisma migrate dev --name add_description_and_updated_at 173``` 174 175Lis le SQL généré dans `prisma/migrations/`. 176 177À faire : champ description (Textarea) dans le form + feature édition 178(modale `<Dialog>` Shadcn **ou** route `/bookmarks/[id]/edit`) + action 179`updateBookmark`. 180 181--- 182 183## Étape 3 — Tags (many-to-many) 184 185```prisma 186model Bookmark { 187 id String @id @default(cuid()) 188 url String 189 title String 190 description String? 191 createdAt DateTime @default(now()) 192 updatedAt DateTime @updatedAt 193 tags Tag[] 194} 195 196model Tag { 197 id String @id @default(cuid()) 198 name String @unique 199 bookmarks Bookmark[] 200} 201``` 202 203```bash 204pnpm prisma migrate dev --name add_tags 205``` 206 207À faire : input tags (séparés virgule ou Enter), affichage Badges 208supprimables, création à la volée des tags inexistants. Affiche les tags 209sur chaque card. 210 211Indice : 212 213```ts 214await prisma.bookmark.create({ 215 data: { 216 url, 217 title, 218 tags: { 219 connectOrCreate: tagNames.map((name) => ({ 220 where: { name }, 221 create: { name }, 222 })), 223 }, 224 }, 225}); 226``` 227 228Pour la lecture : `include: { tags: true }`. 229 230--- 231 232## Bonus 1 — Filtre par tag 233 234Tags cliquables → `/?tag=design`. URL state géré avec **nuqs** (typé, 235côté serveur + client). 236 237**Côté serveur** (page d'accueil) — `createLoader` : 238 239```tsx 240import { createLoader, parseAsString } from "nuqs/server"; 241 242const loadSearchParams = createLoader({ 243 tag: parseAsString, 244}); 245 246export default async function Home({ searchParams }) { 247 const { tag } = await loadSearchParams(searchParams); 248 const bookmarks = await prisma.bookmark.findMany({ 249 where: tag ? { tags: { some: { name: tag } } } : undefined, 250 include: { tags: true }, 251 }); 252 // ... 253} 254``` 255 256**Côté client** (sélecteur de tag, reset) — `useQueryState` : 257 258```tsx 259"use client"; 260import { useQueryState } from "nuqs"; 261 262export function TagFilter() { 263 const [tag, setTag] = useQueryState("tag"); 264 return tag ? ( 265 <button onClick={() => setTag(null)}>Réinitialiser ({tag})</button> 266 ) : null; 267} 268``` 269 270Les badges peuvent être de simples `<Link href={`/?tag=${name}`}>`, ou alors 271appeler `setTag(name)` sur un bouton depuis un client component. 272 273--- 274 275## Bonus 2 — Page détail 276 277Route `src/app/bookmarks/[id]/page.tsx` : 278 279```tsx 280export default async function BookmarkDetail({ 281 params, 282}: { 283 params: Promise<{ id: string }>; 284}) { 285 const { id } = await params; 286 const bookmark = await prisma.bookmark.findUnique({ 287 where: { id }, 288 include: { tags: true }, 289 }); 290 if (!bookmark) notFound(); 291} 292``` 293 294Rends les titres cliquables vers cette page. 295 296--- 297 298## Bonus 3 — Dockeriser l'app 299 300Produis une image Docker qui run l'app en prod, avec les migrations Prisma 301appliquées au démarrage. 302 303Pistes : 304 305- mode `output: "standalone"` de Next.js 306- `Dockerfile` multi-stage (deps → builder → runner) sur `node:24-alpine` 307- `.dockerignore` (n'embarque pas `node_modules`, `.next`, `.env*`, `.git`) 308- `prisma migrate deploy` (pas `migrate dev`) au démarrage du conteneur (à mettre dans un `docker-entrypoint.sh`) 309 310Docs : 311[Next.js Docker](https://nextjs.org/docs/app/guides/self-hosting#docker) 312[Prisma in Docker](https://www.prisma.io/docs/guides/deployment/deployment-guides/deploying-to-docker) 313[pnpm in Docker](https://pnpm.io/docker) 314 315`compose.yml` pour te simplifier le lancement : 316 317```yaml 318services: 319 web: 320 build: . 321 ports: 322 - "3000:3000" 323 env_file: 324 - .env 325``` 326 327Exécuter avec : 328 329```bash 330docker compose up --build 331``` 332 333--- 334 335## Seed 336 337`prisma/seed.ts` : 338 339```ts 340import { PrismaClient } from "@prisma/client"; 341 342const prisma = new PrismaClient(); 343 344async function main() { 345 await prisma.bookmark.deleteMany(); 346 await prisma.tag.deleteMany(); 347 348 await prisma.bookmark.create({ 349 data: { 350 url: "https://nextjs.org", 351 title: "Next.js", 352 description: "Le framework React de référence", 353 tags: { 354 connectOrCreate: [ 355 { 356 where: { name: "framework" }, 357 create: { name: "framework" }, 358 }, 359 { where: { name: "react" }, create: { name: "react" } }, 360 ], 361 }, 362 }, 363 }); 364 // ~10 entrées variées 365} 366 367main() 368 .catch((e) => { 369 console.error(e); 370 process.exit(1); 371 }) 372 .finally(() => prisma.$disconnect()); 373``` 374 375`package.json` : 376 377```json 378"prisma": { "seed": "tsx prisma/seed.ts" } 379``` 380 381```bash 382pnpm prisma db seed 383``` 384 385--- 386 387## Critères d'auto-évaluation 388 389- [ ] `pnpm tsc --noEmit` → 0 erreur 390- [ ] `pnpm biome check --write` → 0 erreur 391- [ ] Server actions validés avec Zod 392- [ ] `revalidatePath` après chaque mutation 393- [ ] Pas de `any` ou `unknown` 394- [ ] UI responsive 395- [ ] Messages de commit clairs 396- [ ] App utilisable de bout en bout 397 398--- 399 400## Annexes 401 402### Annexe A — `biome.json` 403 404```json 405{ 406 "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", 407 "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, 408 "files": { 409 "ignoreUnknown": false, 410 "includes": [ 411 "**", 412 "!**/pnpm-lock.yaml", 413 "!**/next-env.d.ts", 414 "!**/migrations/**/*", 415 "!**/node_modules/**/*", 416 "!**/.next", 417 "!**/dist" 418 ] 419 }, 420 "formatter": { 421 "enabled": true, 422 "indentStyle": "space", 423 "indentWidth": 4, 424 "lineEnding": "lf", 425 "lineWidth": 80 426 }, 427 "linter": { 428 "enabled": true, 429 "domains": { "next": "recommended" }, 430 "rules": { 431 "recommended": true, 432 "style": { 433 "useImportType": "on", 434 "useExportType": "on", 435 "useNodejsImportProtocol": "error", 436 "useConst": "on", 437 "noNonNullAssertion": "warn" 438 }, 439 "nursery": { 440 "useSortedClasses": { 441 "level": "on", 442 "fix": "safe", 443 "options": { "functions": ["cn", "twMerge", "cva"] } 444 } 445 }, 446 "suspicious": { 447 "noExplicitAny": "error" 448 } 449 } 450 }, 451 "javascript": { 452 "formatter": { 453 "jsxQuoteStyle": "double", 454 "quoteStyle": "double", 455 "semicolons": "asNeeded", 456 "trailingCommas": "none", 457 "arrowParentheses": "always" 458 } 459 }, 460 "assist": { 461 "enabled": true, 462 "actions": { "source": { "organizeImports": "on" } } 463 } 464} 465``` 466 467### Annexe B — `.env` 468 469```bash 470DATABASE_URL="<à récupérer de supabase>" 471DIRECT_URL="<à récupérer de supabase>" 472``` 473 474### Annexe C — Squelette server action 475 476```ts 477"use server"; 478 479import { revalidatePath } from "next/cache"; 480import { z } from "zod"; 481import { prisma } from "@/lib/prisma"; 482 483export const CreateBookmarkSchema = z.object({ 484 url: z.url("URL invalide"), 485 title: z.string().min(1, "Titre requis"), 486}); 487 488export type CreateBookmarkInput = z.infer<typeof CreateBookmarkSchema>; 489 490type ActionResult = { success: true } | { success: false; error: string }; 491 492export async function createBookmark( 493 input: CreateBookmarkInput, 494): Promise<ActionResult> { 495 const parsed = CreateBookmarkSchema.safeParse(input); 496 if (!parsed.success) { 497 return { success: false, error: parsed.error.message }; 498 } 499 500 try { 501 await prisma.bookmark.create({ data: parsed.data }); 502 } catch (e) { 503 console.error(e); 504 return { success: false, error: "Erreur lors de la création" }; 505 } 506 507 revalidatePath("/"); 508 return { success: true }; 509} 510``` 511 512### Annexe D — Squelette client form 513 514Lire la documentation : [https://ui.shadcn.com/docs/forms/tanstack-form](https://ui.shadcn.com/docs/forms/tanstack-form) 515 516```tsx 517"use client"; 518 519import { useForm } from "@tanstack/react-form"; 520import { useTransition } from "react"; 521import { toast } from "sonner"; 522import { 523 createBookmark, 524 CreateBookmarkSchema, 525} from "@/actions/bookmarks/create"; 526import { Button } from "@/components/ui/button"; 527import { Input } from "@/components/ui/input"; 528import { Label } from "@/components/ui/label"; 529 530export function BookmarkForm() { 531 const [isPending, startTransition] = useTransition(); 532 533 const form = useForm({ 534 defaultValues: { url: "", title: "" }, 535 validators: { onChange: CreateBookmarkSchema }, 536 onSubmit: ({ value }) => { 537 startTransition(async () => { 538 const result = await createBookmark(value); 539 if (result.success) { 540 toast.success("Bookmark ajouté"); 541 form.reset(); 542 } else { 543 toast.error(result.error); 544 } 545 }); 546 }, 547 }); 548 549 return ( 550 <form 551 onSubmit={(e) => { 552 e.preventDefault(); 553 form.handleSubmit(); 554 }} 555 className="flex flex-col gap-3" 556 > 557 <form.Field name="url"> 558 {(field) => ( 559 <div> 560 <Label htmlFor={field.name}>URL</Label> 561 <Input 562 id={field.name} 563 value={field.state.value} 564 onChange={(e) => field.handleChange(e.target.value)} 565 placeholder="https://..." 566 /> 567 {field.state.meta.errors.length > 0 && ( 568 <p className="text-sm text-red-500"> 569 {field.state.meta.errors[0]?.message} 570 </p> 571 )} 572 </div> 573 )} 574 </form.Field> 575 576 {/* À toi : champ title */} 577 578 <Button type="submit" disabled={isPending}> 579 {isPending ? "..." : "Ajouter"} 580 </Button> 581 </form> 582 ); 583} 584``` 585 586### Annexe E — Commandes utiles 587 588| Commande | Effet | 589| ------------------------------------ | ----------------------------- | 590| `pnpm dev` | Dev server | 591| `pnpm build` | Build prod | 592| `pnpm biome check --write` | Lint + format auto-fix | 593| `pnpm biome ci` | Check sans modifier | 594| `pnpm tsc --noEmit` | Type-check | 595| `pnpm prisma studio` | GUI BDD (au lieu de supabase) | 596| `pnpm prisma migrate dev --name xxx` | Nouvelle migration dev | 597| `pnpm prisma migrate reset` | Reset BDD (data perdue) | 598| `pnpm prisma generate` | Régénère le client | 599| `pnpm prisma db seed` | Lance le seed |