this string has no description
0
mini-projet-fare.md
edited
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 |