this string has no description
0
mini-projet-fare.md edited
599 lines 15 kB view raw view code

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, compte Supabase, éditeur de code au choix (je recommande Zed) avec extension Biome.


Setup#

1. Créer le projet#

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#

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

pnpm biome init

Remplace biome.json par Annexe A. Test : pnpm biome check --write.

4. Shadcn#

ui.shadcn.com

# 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

Accueil > Get Connected > ORM :

  • Transaction pooler (port 6543) → DATABASE_URL
  • Direct connection (port 5432) → DIRECT_URL

6. .env#

Voir Annexe B. Ajoute .env à .gitignore si pas déjà dedans.

7. Prisma#

pnpm prisma init

Remplace prisma/schema.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())
}
pnpm prisma migrate dev --name init

8. Client Prisma — src/lib/prisma.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 nuqs.dev

import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Toaster } from "@/components/ui/sonner";

// dans le <body>, enveloppe {children} :
<NuqsAdapter>{children}</NuqsAdapter>
<Toaster richColors position="top-right" />

10. Run#

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
  • src/actions/bookmarks/delete.ts (même pattern, prend id)
  • src/components/BookmarkForm.tsx (Client) — voir Annexe D
  • 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 · Zod v4


Étape 2 — Enrichir le modèle#

Ajoute au modèle Bookmark :

description String?
updatedAt   DateTime @updatedAt
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 <Dialog> Shadcn ou route /bookmarks/[id]/edit) + action updateBookmark.


Étape 3 — Tags (many-to-many)#

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[]
}
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 :

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 :

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 :

"use client";
import { useQueryState } from "nuqs";

export function TagFilter() {
    const [tag, setTag] = useQueryState("tag");
    return tag ? (
        <button onClick={() => setTag(null)}>Réinitialiser ({tag})</button>
    ) : null;
}

Les badges peuvent être de simples <Link href={/?tag=${name}}>, ou alors appeler setTag(name) sur un bouton depuis un client component.


Bonus 2 — Page détail#

Route src/app/bookmarks/[id]/page.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 Prisma in Docker pnpm in Docker

compose.yml pour te simplifier le lancement :

services:
    web:
        build: .
        ports:
            - "3000:3000"
        env_file:
            - .env

Exécuter avec :

docker compose up --build

Seed#

prisma/seed.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 :

"prisma": { "seed": "tsx prisma/seed.ts" }
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#

{
    "$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#

DATABASE_URL="<à récupérer de supabase>"
DIRECT_URL="<à récupérer de supabase>"

Annexe C — Squelette server action#

"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<typeof CreateBookmarkSchema>;

type ActionResult = { success: true } | { success: false; error: string };

export async function createBookmark(
    input: CreateBookmarkInput,
): Promise<ActionResult> {
    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

"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 (
        <form
            onSubmit={(e) => {
                e.preventDefault();
                form.handleSubmit();
            }}
            className="flex flex-col gap-3"
        >
            <form.Field name="url">
                {(field) => (
                    <div>
                        <Label htmlFor={field.name}>URL</Label>
                        <Input
                            id={field.name}
                            value={field.state.value}
                            onChange={(e) => field.handleChange(e.target.value)}
                            placeholder="https://..."
                        />
                        {field.state.meta.errors.length > 0 && (
                            <p className="text-sm text-red-500">
                                {field.state.meta.errors[0]?.message}
                            </p>
                        )}
                    </div>
                )}
            </form.Field>

            {/* À toi : champ title */}

            <Button type="submit" disabled={isPending}>
                {isPending ? "..." : "Ajouter"}
            </Button>
        </form>
    );
}

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