# Mini-projet — FareMarks Gestionnaire de bookmarks taggés. Pour découvrir Prisma, Supabase, server actions Next 16, TanStack Form, Zod, Biome, Shadcn, Sonner. Commit quand ça te semble adapté (à chaque étape par exemple) --- ## Prérequis Node 24, [`pnpm`](https://pnpm.io/installation), compte Supabase, éditeur de code au choix (je recommande [Zed](https://zed.dev/)) avec extension Biome. --- ## Setup ### 1. Créer le projet ```bash pnpm create next-app@latest faremarks cd faremarks ``` TS oui, ESLint **non**, Tailwind oui, `src/` oui, App Router oui, Turbopack oui, alias `@/*` oui. ### 2. Dépendances ```bash pnpm add @prisma/client @tanstack/react-form zod sonner nuqs clsx \ tailwind-merge class-variance-authority lucide-react pnpm add -D prisma @biomejs/biome tsx ``` ### 3. Biome [biomejs.dev](https://biomejs.dev/) ```bash pnpm biome init ``` Remplace `biome.json` par [Annexe A](#annexe-a--biomejson). Test : `pnpm biome check --write`. ### 4. Shadcn [ui.shadcn.com](https://ui.shadcn.com/) ```bash # Initialise shadcn, avec les styles et paramètres par défaut pnpm dlx shadcn@latest init # Commence par ajouter quelques composants, tu pourras en ajouter plus tard si besoin pnpm dlx shadcn@latest add button card input textarea label badge dialog sonner ``` Style Default, Neutral, CSS variables oui. ### 5. Supabase [supabase.com](https://supabase.com) **Accueil > Get Connected > ORM** : - **Transaction pooler** (port 6543) → `DATABASE_URL` - **Direct connection** (port 5432) → `DIRECT_URL` ### 6. `.env` Voir [Annexe B](#annexe-b--env). Ajoute `.env` à `.gitignore` si pas déjà dedans. ### 7. Prisma ```bash pnpm prisma init ``` Remplace `prisma/schema.prisma` : ```prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") } model Bookmark { id String @id @default(cuid()) url String title String createdAt DateTime @default(now()) } ``` ```bash pnpm prisma migrate dev --name init ``` ### 8. Client Prisma — `src/lib/prisma.ts` ```ts import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; ``` > Cela évite de créer N instances en dev (hot reload). ### 9. Providers — `src/app/layout.tsx` [sonner.emilkowal.ski](https://sonner.emilkowal.ski/) [nuqs.dev](https://nuqs.47ng.com/) ```tsx import { NuqsAdapter } from "nuqs/adapters/next/app"; import { Toaster } from "@/components/ui/sonner"; // dans le , enveloppe {children} : {children} ``` ### 10. Run ```bash pnpm dev ``` --- ## Étape 1 — CRUD minimal À construire : - `src/app/page.tsx` (Server Component) : liste les bookmarks + form + bouton delete par item - `src/actions/bookmarks/create.ts` (server action) — voir [Annexe C](#annexe-c--squelette-server-action) - `src/actions/bookmarks/delete.ts` (même pattern, prend `id`) - `src/components/BookmarkForm.tsx` (Client) — voir [Annexe D](#annexe-d--squelette-client-form) - `src/components/DeleteButton.tsx` (Client, `startTransition` + toast) Schéma Zod déclaré côté server action, types importés côté client via `import type`. Toast vert au succès, rouge en erreur. Docs : [TanStack Form](https://tanstack.com/form/latest) · [Zod v4](https://zod.dev/) --- ## Étape 2 — Enrichir le modèle Ajoute au modèle `Bookmark` : ```prisma description String? updatedAt DateTime @updatedAt ``` ```bash pnpm prisma migrate dev --name add_description_and_updated_at ``` Lis le SQL généré dans `prisma/migrations/`. À faire : champ description (Textarea) dans le form + feature édition (modale `` Shadcn **ou** route `/bookmarks/[id]/edit`) + action `updateBookmark`. --- ## Étape 3 — Tags (many-to-many) ```prisma model Bookmark { id String @id @default(cuid()) url String title String description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt tags Tag[] } model Tag { id String @id @default(cuid()) name String @unique bookmarks Bookmark[] } ``` ```bash pnpm prisma migrate dev --name add_tags ``` À faire : input tags (séparés virgule ou Enter), affichage Badges supprimables, création à la volée des tags inexistants. Affiche les tags sur chaque card. Indice : ```ts await prisma.bookmark.create({ data: { url, title, tags: { connectOrCreate: tagNames.map((name) => ({ where: { name }, create: { name }, })), }, }, }); ``` Pour la lecture : `include: { tags: true }`. --- ## Bonus 1 — Filtre par tag Tags cliquables → `/?tag=design`. URL state géré avec **nuqs** (typé, côté serveur + client). **Côté serveur** (page d'accueil) — `createLoader` : ```tsx import { createLoader, parseAsString } from "nuqs/server"; const loadSearchParams = createLoader({ tag: parseAsString, }); export default async function Home({ searchParams }) { const { tag } = await loadSearchParams(searchParams); const bookmarks = await prisma.bookmark.findMany({ where: tag ? { tags: { some: { name: tag } } } : undefined, include: { tags: true }, }); // ... } ``` **Côté client** (sélecteur de tag, reset) — `useQueryState` : ```tsx "use client"; import { useQueryState } from "nuqs"; export function TagFilter() { const [tag, setTag] = useQueryState("tag"); return tag ? ( ) : null; } ``` Les badges peuvent être de simples ``, ou alors appeler `setTag(name)` sur un bouton depuis un client component. --- ## Bonus 2 — Page détail Route `src/app/bookmarks/[id]/page.tsx` : ```tsx export default async function BookmarkDetail({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const bookmark = await prisma.bookmark.findUnique({ where: { id }, include: { tags: true }, }); if (!bookmark) notFound(); } ``` Rends les titres cliquables vers cette page. --- ## Bonus 3 — Dockeriser l'app Produis une image Docker qui run l'app en prod, avec les migrations Prisma appliquées au démarrage. Pistes : - mode `output: "standalone"` de Next.js - `Dockerfile` multi-stage (deps → builder → runner) sur `node:24-alpine` - `.dockerignore` (n'embarque pas `node_modules`, `.next`, `.env*`, `.git`) - `prisma migrate deploy` (pas `migrate dev`) au démarrage du conteneur (à mettre dans un `docker-entrypoint.sh`) Docs : [Next.js Docker](https://nextjs.org/docs/app/guides/self-hosting#docker) [Prisma in Docker](https://www.prisma.io/docs/guides/deployment/deployment-guides/deploying-to-docker) [pnpm in Docker](https://pnpm.io/docker) `compose.yml` pour te simplifier le lancement : ```yaml services: web: build: . ports: - "3000:3000" env_file: - .env ``` Exécuter avec : ```bash docker compose up --build ``` --- ## Seed `prisma/seed.ts` : ```ts import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { await prisma.bookmark.deleteMany(); await prisma.tag.deleteMany(); await prisma.bookmark.create({ data: { url: "https://nextjs.org", title: "Next.js", description: "Le framework React de référence", tags: { connectOrCreate: [ { where: { name: "framework" }, create: { name: "framework" }, }, { where: { name: "react" }, create: { name: "react" } }, ], }, }, }); // ~10 entrées variées } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect()); ``` `package.json` : ```json "prisma": { "seed": "tsx prisma/seed.ts" } ``` ```bash pnpm prisma db seed ``` --- ## Critères d'auto-évaluation - [ ] `pnpm tsc --noEmit` → 0 erreur - [ ] `pnpm biome check --write` → 0 erreur - [ ] Server actions validés avec Zod - [ ] `revalidatePath` après chaque mutation - [ ] Pas de `any` ou `unknown` - [ ] UI responsive - [ ] Messages de commit clairs - [ ] App utilisable de bout en bout --- ## Annexes ### Annexe A — `biome.json` ```json { "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "ignoreUnknown": false, "includes": [ "**", "!**/pnpm-lock.yaml", "!**/next-env.d.ts", "!**/migrations/**/*", "!**/node_modules/**/*", "!**/.next", "!**/dist" ] }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 4, "lineEnding": "lf", "lineWidth": 80 }, "linter": { "enabled": true, "domains": { "next": "recommended" }, "rules": { "recommended": true, "style": { "useImportType": "on", "useExportType": "on", "useNodejsImportProtocol": "error", "useConst": "on", "noNonNullAssertion": "warn" }, "nursery": { "useSortedClasses": { "level": "on", "fix": "safe", "options": { "functions": ["cn", "twMerge", "cva"] } } }, "suspicious": { "noExplicitAny": "error" } } }, "javascript": { "formatter": { "jsxQuoteStyle": "double", "quoteStyle": "double", "semicolons": "asNeeded", "trailingCommas": "none", "arrowParentheses": "always" } }, "assist": { "enabled": true, "actions": { "source": { "organizeImports": "on" } } } } ``` ### Annexe B — `.env` ```bash DATABASE_URL="<à récupérer de supabase>" DIRECT_URL="<à récupérer de supabase>" ``` ### Annexe C — Squelette server action ```ts "use server"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { prisma } from "@/lib/prisma"; export const CreateBookmarkSchema = z.object({ url: z.url("URL invalide"), title: z.string().min(1, "Titre requis"), }); export type CreateBookmarkInput = z.infer; type ActionResult = { success: true } | { success: false; error: string }; export async function createBookmark( input: CreateBookmarkInput, ): Promise { const parsed = CreateBookmarkSchema.safeParse(input); if (!parsed.success) { return { success: false, error: parsed.error.message }; } try { await prisma.bookmark.create({ data: parsed.data }); } catch (e) { console.error(e); return { success: false, error: "Erreur lors de la création" }; } revalidatePath("/"); return { success: true }; } ``` ### Annexe D — Squelette client form Lire la documentation : [https://ui.shadcn.com/docs/forms/tanstack-form](https://ui.shadcn.com/docs/forms/tanstack-form) ```tsx "use client"; import { useForm } from "@tanstack/react-form"; import { useTransition } from "react"; import { toast } from "sonner"; import { createBookmark, CreateBookmarkSchema, } from "@/actions/bookmarks/create"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; export function BookmarkForm() { const [isPending, startTransition] = useTransition(); const form = useForm({ defaultValues: { url: "", title: "" }, validators: { onChange: CreateBookmarkSchema }, onSubmit: ({ value }) => { startTransition(async () => { const result = await createBookmark(value); if (result.success) { toast.success("Bookmark ajouté"); form.reset(); } else { toast.error(result.error); } }); }, }); return (
{ e.preventDefault(); form.handleSubmit(); }} className="flex flex-col gap-3" > {(field) => (
field.handleChange(e.target.value)} placeholder="https://..." /> {field.state.meta.errors.length > 0 && (

{field.state.meta.errors[0]?.message}

)}
)}
{/* À toi : champ title */}
); } ``` ### Annexe E — Commandes utiles | Commande | Effet | | ------------------------------------ | ----------------------------- | | `pnpm dev` | Dev server | | `pnpm build` | Build prod | | `pnpm biome check --write` | Lint + format auto-fix | | `pnpm biome ci` | Check sans modifier | | `pnpm tsc --noEmit` | Type-check | | `pnpm prisma studio` | GUI BDD (au lieu de supabase) | | `pnpm prisma migrate dev --name xxx` | Nouvelle migration dev | | `pnpm prisma migrate reset` | Reset BDD (data perdue) | | `pnpm prisma generate` | Régénère le client | | `pnpm prisma db seed` | Lance le seed |