Organiser son projet : Cas pratique
Création d'un Mini-Blog avec React, Vite et shadcn
Introduction
Dans ce tutoriel, nous allons créer un mini-blog complet avec trois pages principales : une page d'accueil, une page listant les articles, et une page de contact. Nous utiliserons React avec Vite pour la rapidité de développement, et shadcn pour des composants élégants et personnalisables.
Étape 1 : Configuration du Projet
Commençons par créer notre projet avec Vite. Ouvrez votre terminal et exécutez les commandes suivantes :
# Création du projet
npm create vite@latest mini-blog -- --template react
# Navigation dans le projet
cd mini-blog
# Installation des dépendances de base
npm install
# Installation des dépendances nécessaires
npm install -D tailwindcss postcss autoprefixer
npm install class-variance-authority clsx tailwind-merge
npm install lucide-react
npm install react-router-dom
Une fois les dépendances installées, nous devons configurer Tailwind CSS :
# Initialisation de Tailwind
npx tailwindcss init -p
Modifiez le fichier tailwind.config.js :
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
Modifiez le fichier index.css :
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ... */
Modifiez le fichier vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from "path"
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})
Créeez un fichier jsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
}
}
Initialisons désormais shadcn dans le projet :
npx shadcn@latest init
Créez ou modifiez src/index.css :
@tailwind base;
@tailwind components;
@tailwind utilities;
Étape 2 : Structure du Projet
Créons la structure de dossiers nécessaire :
src/
├── components/
│ ├── ui/ # Composants shadcn
│ ├── Navbar.jsx # Barre de navigation
│ └── Footer.jsx # Pied de page
├── pages/
│ ├── Home.jsx # Page d'accueil
│ ├── Blog.jsx # Liste des articles
│ └── Contact.jsx # Formulaire de contact
├── App.jsx
└── main.jsx
Étape 3 : Configuration du Routage
Commençons par mettre en place le routage dans main.jsx :
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { BrowserRouter } from 'react-router-dom'
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
Commençons par mettre en place le routage dans App.jsx :
// src/App.jsx
import { Routes, Route } from 'react-router-dom'
import Navbar from './components/Navbar'
import Footer from './components/Footer'
import Home from './pages/Home'
import Blog from './pages/Blog'
import Contact from './pages/Contact'
function App() {
return (
<div className='w-full'>
<Navbar/>
<main className='w-full'>
<Routes>
<Route path='/' element={<Home/>}/>
<Route path='/blog' element={<Blog/>}/>
<Route path='/contact' element={<Contact/>}/>
</Routes>
</main>
<Footer/>
</div>
)
}
export default App
Dans ce fichier :
- Nous utilisons BrowserRouter pour gérer la navigation
- La structure flex permet au footer de rester en bas de page
- La balise main prend tout l'espace disponible grâce à flex-grow
Étape 4 : Création des Composants de Base
4.1 Création de la Barre de Navigation
Installons d'abord les composants shadcn nécessaires :
npx shadcn@latest add button
npx shadcn@latest add navigation-menu
npx shadcn@latest add sheet
Créons maintenant notre barre de navigation :
import { Sheet, SheetTrigger, SheetContent } from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
import {Link} from "react-router-dom"
import { NavigationMenu, NavigationMenuList, NavigationMenuLink } from "@/components/ui/navigation-menu"
export default function Navbar() {
return (
<header className="flex h-20 w-full shrink-0 items-center px-4 md:px-6">
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="lg:hidden">
<MenuIcon className="h-6 w-6" />
<span className="sr-only">Toggle navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left">
<div className="grid gap-2 py-6">
<Link to='/' className="flex w-full items-center py-2 text-lg font-semibold">
Home
</Link>
<Link href="#" className="flex w-full items-center py-2 text-lg font-semibold">
About
</Link>
<Link href="#" className="flex w-full items-center py-2 text-lg font-semibold">
Services
</Link>
<Link href="#" className="flex w-full items-center py-2 text-lg font-semibold">
Portfolio
</Link>
<Link href="#" className="flex w-full items-center py-2 text-lg font-semibold">
Contact
</Link>
</div>
</SheetContent>
</Sheet>
<Link href="#" className="mr-6 hidden lg:flex">
<MountainIcon className="h-6 w-6" />
<span className="sr-only">Company Logo</span>
</Link>
<NavigationMenu className="hidden lg:flex">
<NavigationMenuList>
<NavigationMenuLink asChild>
<Link
to='/'
className="group inline-flex h-9 w-max items-center justify-center rounded-md bg-white px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-100 hover:text-gray-900 focus:bg-gray-100 focus:text-gray-900 focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-gray-100/50 data-[state=open]:bg-gray-100/50 dark:bg-gray-950 dark:hover:bg-gray-800 dark:hover:text-gray-50 dark:focus:bg-gray-800 dark:focus:text-gray-50 dark:data-[active]:bg-gray-800/50 dark:data-[state=open]:bg-gray-800/50"
>
Home
</Link>
</NavigationMenuLink>
<NavigationMenuLink asChild>
<Link
to='/blog'
className="group inline-flex h-9 w-max items-center justify-center rounded-md bg-white px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-100 hover:text-gray-900 focus:bg-gray-100 focus:text-gray-900 focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-gray-100/50 data-[state=open]:bg-gray-100/50 dark:bg-gray-950 dark:hover:bg-gray-800 dark:hover:text-gray-50 dark:focus:bg-gray-800 dark:focus:text-gray-50 dark:data-[active]:bg-gray-800/50 dark:data-[state=open]:bg-gray-800/50"
>
Blog
</Link>
</NavigationMenuLink>
<NavigationMenuLink asChild>
<Link
to='/contact'
className="group inline-flex h-9 w-max items-center justify-center rounded-md bg-white px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-100 hover:text-gray-900 focus:bg-gray-100 focus:text-gray-900 focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-gray-100/50 data-[state=open]:bg-gray-100/50 dark:bg-gray-950 dark:hover:bg-gray-800 dark:hover:text-gray-50 dark:focus:bg-gray-800 dark:focus:text-gray-50 dark:data-[active]:bg-gray-800/50 dark:data-[state=open]:bg-gray-800/50"
>
Contact
</Link>
</NavigationMenuLink>
</NavigationMenuList>
</NavigationMenu>
</header>
)
}
function MenuIcon(props) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</svg>
)
}
function MountainIcon(props) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m8 3 4 8 5-5 5 15H2L8 3z" />
</svg>
)
}
Ce composant Navbar :
- Utilise les composants Button de shadcn pour une apparence cohérente
- Implémente une navigation réactive avec react-router
- Maintient une mise en page responsive
4.2 Création du Footer
// src/components/Footer.jsx
function Footer() {
return (
<footer className="bg-gray-50 border-t">
<div className="container mx-auto px-4 py-6">
<p className="text-center text-gray-600">
© {new Date().getFullYear()} MonBlog. Tous droits réservés.
</p>
</div>
</footer>
)
}
export default Footer
Le footer :
- Reste simple et élégant
- Affiche l'année actuelle dynamiquement
- Utilise des couleurs neutres pour ne pas attirer l'attention
Étape 5 : Création de la Page d'Accueil
La page d'accueil sera notre vitrine. Elle contiendra une section CTA (Call-to-Action) et une section présentant les derniers articles.
Nous allons créer un dossier /pages dans /src dans lequel nous placerons l'ensemble des pages qui contiendront nos components.
Commencer par créer /pages puis Home.jsx comme suivant
Installons d'abord les composants shadcn nécessaires :
npx shadcn@latest add card
Maintenant, créons notre page d'accueil :
// src/pages/Home.jsx
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowRight } from 'lucide-react'
function Home() {
const [latestPosts, setLatestPosts] = useState([])
// Chargement des derniers articles lors du montage du composant
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/posts')
.then(response => response.json())
.then(data => setLatestPosts(data.slice(0, 3))) // On ne garde que les 3 premiers articles
}, [])
return (
<div className="space-y-16">
{/* Section Hero avec CTA */}
<section className="text-center py-16 px-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-2xl">
<div className="max-w-3xl mx-auto">
<h1 className="text-4xl font-bold mb-6">
Bienvenue sur MonBlog
</h1>
<p className="text-lg text-gray-600 mb-8">
Découvrez nos articles sur le développement web,
le design et les nouvelles technologies.
</p>
<div className="flex justify-center gap-4">
<Button asChild size="lg">
<Link to="/blog">
Voir les Articles
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
</section>
{/* Section Derniers Articles */}
<section>
<div className="max-w-6xl mx-auto px-4">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-bold">Les Derniers Articles</h2>
<Button variant="ghost" asChild>
<Link to="/blog">
Voir tous les articles
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{latestPosts.map(post => (
<Card key={post.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="line-clamp-2">
{post.title}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 line-clamp-3">
{post.body}
</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
</div>
)
}
export default Home
Expliquons les différentes parties de cette page d'accueil :
- Section Hero avec CTA
- Un titre accrocheur et une description claire
- Un dégradé subtil en arrière-plan pour attirer l'attention
- Un bouton d'appel à l'action qui dirige vers la liste des articles
- L'utilisation de
max-w-3xlpour une largeur de lecture confortable
- Section Derniers Articles
- Affichage des 3 derniers articles dans une grille responsive
- Utilisation des composants Card de shadcn pour une présentation élégante
- Un lien "Voir tous les articles" pour accéder à la liste complète
- La propriété
line-clamppour limiter la longueur du texte
- Gestion des Données
- Utilisation de
useStateetuseEffectpour gérer les articles - Récupération des données depuis l'API JSONPlaceholder
- Limitation à 3 articles avec
slice(0, 3)
Étape 6 : Création du Composant ArticleCard
Avant de passer à la page Blog, créons un composant réutilisable pour nos articles :
// src/components/ArticleCard.jsx
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { BookOpen } from 'lucide-react'
function ArticleCard({ article }) {
return (
<Card className="flex flex-col hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="line-clamp-2">
{article.title}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 line-clamp-3">
{article.body}
</p>
</CardContent>
<CardFooter className="mt-auto">
<Button variant="ghost" className="w-full">
<BookOpen className="mr-2 h-4 w-4" />
Lire l'article
</Button>
</CardFooter>
</Card>
)
}
export default ArticleCard
Ce composant ArticleCard :
- Accepte un article en tant que prop
- Utilise les composants Card de shadcn pour une mise en forme cohérente
- Ajoute un effet de survol avec
hover:shadow-lg - Maintient une hauteur cohérente grâce à
flex-coletmt-auto
Étape 7 : Création de la Page Blog
Pour la page Blog, nous aurons besoin d'ajouter le composant Input de shadcn pour la barre de recherche :
npx shadcn@latest add input
Maintenant, créons notre page Blog avec une fonctionnalité de recherche :
// src/pages/Blog.jsx
import { useState, useEffect } from 'react'
import { Input } from "@/components/ui/input"
import { Search } from 'lucide-react'
import ArticleCard from '../components/ArticleCard'
function Blog() {
// Initialisation des états
const [articles, setArticles] = useState([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
// Chargement des articles au montage du composant
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/posts')
.then(response => response.json())
.then(data => {
setArticles(data)
setLoading(false)
})
.catch(error => {
console.error("Erreur lors du chargement des articles:", error)
setLoading(false)
})
}, [])
// Filtrage des articles en fonction du terme de recherche
const filteredArticles = articles.filter(article =>
article.title.toLowerCase().includes(searchTerm.toLowerCase())
)
return (
<div className="max-w-7xl mx-auto px-4">
{/* En-tête de la page */}
<div className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">Notre Blog</h1>
<p className="text-gray-600">
Découvrez nos derniers articles et restez à jour avec nos contenus
</p>
</div>
{/* Barre de recherche */}
<div className="relative max-w-md mx-auto mb-8">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="Rechercher un article..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Affichage des articles */}
{loading ? (
// État de chargement
<div className="text-center">
<p>Chargement des articles...</p>
</div>
) : (
// Liste des articles
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredArticles.map(article => (
<ArticleCard key={article.id} article={article} />
))}
</div>
{/* Message si aucun article trouvé */}
{filteredArticles.length === 0 && (
<div className="text-center py-8">
<p className="text-gray-600">
Aucun article ne correspond à votre recherche.
</p>
</div>
)}
</>
)}
</div>
)
}
export default Blog
Examinons les différentes parties de cette page en détail :
- Gestion de l'État
articles: stocke la liste complète des articlesloading: gère l'état de chargementsearchTerm: stocke le terme de recherche actuel Nous utilisons ces trois états pour gérer efficacement les différents aspects de notre page.
- Chargement des Données
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/posts')
.then(response => response.json())
.then(data => {
setArticles(data)
setLoading(false)
})
}, [])
- Le hook
useEffects'exécute une seule fois au montage du composant - Nous utilisons l'API JSONPlaceholder pour simuler une vraie base de données
- La gestion d'erreur est incluse pour une meilleure robustesse
- Barre de Recherche
<div className="relative max-w-md mx-auto mb-8">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="Rechercher un article..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
- L'icône de recherche est positionnée absolument dans l'input
- La recherche se fait en temps réel à chaque frappe
- L'input est stylisé avec shadcn pour une apparence cohérente
- Filtrage des Articles
const filteredArticles = articles.filter(article =>
article.title.toLowerCase().includes(searchTerm.toLowerCase())
)
- La recherche est insensible à la casse (
toLowerCase()) - Le filtrage se fait sur le titre des articles
- La méthode
includespermet une recherche partielle
- Affichage Responsive
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
- 1 colonne sur mobile
- 2 colonnes sur tablette
- 3 colonnes sur desktop
- Espacement uniforme avec
gap-6
La page Blog est maintenant fonctionnelle avec une recherche en temps réel et un affichage responsive des articles. Dans la prochaine étape, nous créerons la page Contact pour compléter notre mini-blog.
Étape 7 : Création de la Page Blog
Pour la page Blog, nous aurons besoin d'ajouter le composant Input de shadcn pour la barre de recherche :
npx shadcn@latest add input
Maintenant, créons notre page Blog avec une fonctionnalité de recherche :
// src/pages/Blog.jsx
import { useState, useEffect } from 'react'
import { Input } from "@/components/ui/input"
import { Search } from 'lucide-react'
import ArticleCard from '../components/ArticleCard'
function Blog() {
// Initialisation des états
const [articles, setArticles] = useState([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
// Chargement des articles au montage du composant
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/posts')
.then(response => response.json())
.then(data => {
setArticles(data)
setLoading(false)
})
.catch(error => {
console.error("Erreur lors du chargement des articles:", error)
setLoading(false)
})
}, [])
// Filtrage des articles en fonction du terme de recherche
const filteredArticles = articles.filter(article =>
article.title.toLowerCase().includes(searchTerm.toLowerCase())
)
return (
<div className="max-w-7xl mx-auto px-4">
{/* En-tête de la page */}
<div className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">Notre Blog</h1>
<p className="text-gray-600">
Découvrez nos derniers articles et restez à jour avec nos contenus
</p>
</div>
{/* Barre de recherche */}
<div className="relative max-w-md mx-auto mb-8">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="Rechercher un article..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Affichage des articles */}
{loading ? (
// État de chargement
<div className="text-center">
<p>Chargement des articles...</p>
</div>
) : (
// Liste des articles
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredArticles.map(article => (
<ArticleCard key={article.id} article={article} />
))}
</div>
{/* Message si aucun article trouvé */}
{filteredArticles.length === 0 && (
<div className="text-center py-8">
<p className="text-gray-600">
Aucun article ne correspond à votre recherche.
</p>
</div>
)}
</>
)}
</div>
)
}
export default Blog
Examinons les différentes parties de cette page en détail :
- Gestion de l'État
articles: stocke la liste complète des articlesloading: gère l'état de chargementsearchTerm: stocke le terme de recherche actuel Nous utilisons ces trois états pour gérer efficacement les différents aspects de notre page.
- Chargement des Données
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/posts')
.then(response => response.json())
.then(data => {
setArticles(data)
setLoading(false)
})
}, [])
- Le hook
useEffects'exécute une seule fois au montage du composant - Nous utilisons l'API JSONPlaceholder pour simuler une vraie base de données
- La gestion d'erreur est incluse pour une meilleure robustesse
- Barre de Recherche
<div className="relative max-w-md mx-auto mb-8">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="Rechercher un article..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
- L'icône de recherche est positionnée absolument dans l'input
- La recherche se fait en temps réel à chaque frappe
- L'input est stylisé avec shadcn pour une apparence cohérente
- Filtrage des Articles
const filteredArticles = articles.filter(article =>
article.title.toLowerCase().includes(searchTerm.toLowerCase())
)
- La recherche est insensible à la casse (
toLowerCase()) - Le filtrage se fait sur le titre des articles
- La méthode
includespermet une recherche partielle
- Affichage Responsive
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
- 1 colonne sur mobile
- 2 colonnes sur tablette
- 3 colonnes sur desktop
- Espacement uniforme avec
gap-6
La page Blog est maintenant fonctionnelle avec une recherche en temps réel et un affichage responsive des articles. Dans la prochaine étape, nous créerons la page Contact pour compléter notre mini-blog.
Étape 8 : Création de la Page Contact
Pour notre page de contact, nous aurons besoin de composants supplémentaires de shadcn. Commençons par les installer :
npx shadcn@latest add input
npx shadcn@latest add textarea
npx shadcn@latest add label
Maintenant, créons notre page de contact avec un formulaire élégant :
// src/pages/Contact.jsx
import { useState } from 'react'
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Send } from 'lucide-react'
function Contact() {
// État pour gérer les données du formulaire
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
})
// État pour gérer le statut de l'envoi
const [submitStatus, setSubmitStatus] = useState({
message: '',
isError: false
})
// Gestion des changements dans les champs du formulaire
const handleChange = (e) => {
const { name, value } = e.target
setFormData(prevData => ({
...prevData,
[name]: value
}))
}
// Gestion de la soumission du formulaire
const handleSubmit = (e) => {
e.preventDefault()
// Validation simple
if (!formData.name || !formData.email || !formData.message) {
setSubmitStatus({
message: 'Veuillez remplir tous les champs',
isError: true
})
return
}
// Simulation d'envoi du formulaire
setSubmitStatus({ message: 'Envoi en cours...', isError: false })
// Ici, vous ajouteriez normalement l'appel à votre API
setTimeout(() => {
setSubmitStatus({ message: 'Message envoyé avec succès !', isError: false })
setFormData({ name: '', email: '', message: '' })
// Effacer le message de succès après 3 secondes
setTimeout(() => {
setSubmitStatus({ message: '', isError: false })
}, 3000)
}, 1000)
}
return (
<div className="max-w-2xl mx-auto px-4">
{/* En-tête de la page */}
<div className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">Contactez-nous</h1>
<p className="text-gray-600">
Une question ? Une suggestion ? N'hésitez pas à nous écrire !
</p>
</div>
{/* Formulaire de contact */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Champ Nom */}
<div className="space-y-2">
<Label htmlFor="name">Nom</Label>
<Input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Votre nom"
/>
</div>
{/* Champ Email */}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="votre@email.com"
/>
</div>
{/* Champ Message */}
<div className="space-y-2">
<Label htmlFor="message">Message</Label>
<Textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Votre message..."
className="min-h-[150px]"
/>
</div>
{/* Message de statut */}
{submitStatus.message && (
<p className={`text-center ${
submitStatus.isError ? 'text-red-500' : 'text-green-500'
}`}>
{submitStatus.message}
</p>
)}
{/* Bouton d'envoi */}
<Button type="submit" className="w-full">
<Send className="mr-2 h-4 w-4" />
Envoyer le message
</Button>
</form>
</div>
)
}
export default Contact
Analysons les différentes parties de cette page :
- Gestion de l'État
formData: un objet qui contient les valeurs des champs du formulairesubmitStatus: gère les messages de succès ou d'erreur Ces états permettent de créer une expérience utilisateur fluide et informative.
- Handlers du Formulaire
const handleChange = (e) => {
const { name, value } = e.target
setFormData(prevData => ({
...prevData,
[name]: value
}))
}
- Utilisation du même handler pour tous les champs
- Mise à jour de l'état de manière immutable
- Utilisation des noms des champs comme clés dynamiques
- Validation et Soumission
const handleSubmit = (e) => {
e.preventDefault()
// Validation
if (!formData.name || !formData.email || !formData.message) {
setSubmitStatus({
message: 'Veuillez remplir tous les champs',
isError: true
})
return
}
// ...
}
- Validation simple des champs requis
- Feedback immédiat en cas d'erreur
- Simulation d'une soumission réussie
- Structure du Formulaire
- Utilisation des composants Label de shadcn pour l'accessibilité
- Espacement cohérent avec
space-y-6 - Messages de statut conditionnels
- Bouton d'envoi pleine largeur avec icône
- Retour Utilisateur
- Messages d'état clairs
- Différenciation visuelle entre succès et erreur
- Effacement automatique des messages de succès
Étape 9 : Création d'une page de lecture d'article avec récupération dynamique de l'ID
Pour ce faire nous allons d'abord créer une page pour consulter l'article que nous appelons BlogDetail :
import React from 'react';
import { useParams } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const BlogDetail = () => {
const { id } = useParams();
// Simulation d'un article de blog
const blogPost = {
id: 1,
title: "Les bases de la programmation Python",
content: "What is Lorem Ipsum? Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.Why do we use it? It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like). Where does it come from? Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of de Finibus Bonorum et Malorum (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, Lorem ipsum dolor sit amet.., comes from a line in section 1.10.32. The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from de Finibus Bonorum et Malorum by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.",
author: "Prof. Martin",
date: "2024-03-20",
category: "Programmation",
image: "https://placehold.co/600x400/000000/FFFFFF/png"
};
return (
<div className="container mx-auto px-4 py-8">
<Card className="w-full">
<CardHeader>
<CardTitle className="text-2xl font-bold">{blogPost.title}</CardTitle>
<div className="flex justify-between text-sm text-gray-500">
<span>Par {blogPost.author}</span>
<span>{new Date(blogPost.date).toLocaleDateString('fr-FR')}</span>
</div>
</CardHeader>
<CardContent>
<div className="mb-6">
<img
src={blogPost.image}
alt={blogPost.title}
className="w-full h-64 object-cover rounded-lg"
/>
</div>
<div className="space-y-4">
<span className="inline-block bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm">
{blogPost.category}
</span>
<div className="prose max-w-none">
<p className="text-gray-700 leading-relaxed">
{blogPost.content}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default BlogDetail;
La ligne de code suivante va nous permettre de récupérer l'id de l'article en question const { id } = useParams();
Ensuite, il va falloir ajouter ce component à la liste des routes :
/* ... */
return (
<div className='w-full'>
<Navbar/>
<main className='w-full'>
<Routes>
<Route path='/' element={<Home/>}/>
<Route path='/blog/:id' element={<BlogDetail/>}/>
<Route path='/blog' element={<Blog/>}/>
<Route path='/contact' element={<Contact/>}/>
</Routes>
</main>
<Footer/>
</div>
)
Comme vous pouvez le remarquer, afin de pouvoir gérer les id de manière dynamiquement il faut rajouter :id
De cette manière, dès lors que l'id sera passé dans l'url alors il pourra être récupéré depuis le component.
Maintenant, nous devons faire en sorte que lorsque l'utilisateur clique sur un article il soit rediriger vers la page de détail de l'article en question :
Rajoutons un Link sur notre component ArticleCard et passons lui l'id de l'article en question dans l'url :
import { Link } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
export default function ArticleCard(props) {
return (
<Link to={'/blog/' + props.article.id}>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="line-clamp-2">{props.article.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 line-clamp-3">{props.article.body}</p>
</CardContent>
</Card>
</Link>
);
}
✅L'utilisateur est maintenant redirigé vers son article !
Il faut maintenat modifier BlogDetail afin de récupérer l'article en question via l'API par l'id :
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const BlogDetail = () => {
const { id } = useParams();
const [blogPost, setBlogPost] = useState({ title: "", body: "" });
useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/posts/" + id)
.then((response) => response.json())
.then((data) => {
setBlogPost(data);
})
.catch((error) => {
console.error("Erreur lors de la récup", error);
});
}, []);
// Simulation d'un article de blog
// const blogPost = {
// id: 1,
// title: "Les bases de la programmation Python",
// content: "What is Lorem Ipsum? Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.Why do we use it? It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like). Where does it come from? Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of de Finibus Bonorum et Malorum (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, Lorem ipsum dolor sit amet.., comes from a line in section 1.10.32. The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from de Finibus Bonorum et Malorum by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.",
// author: "Prof. Martin",
// date: "2024-03-20",
// category: "Programmation",
// image: "https://placehold.co/600x400/000000/FFFFFF/png"
// };
return (
<div className="container mx-auto px-4 py-8">
<Card className="w-full">
<CardHeader>
<CardTitle className="text-2xl font-bold">{blogPost.title}</CardTitle>
<div className="flex justify-between text-sm text-gray-500">
<span>Par Majid Beneddine</span>
<span>{new Date().toLocaleDateString("fr-FR")}</span>
</div>
</CardHeader>
<CardContent>
<div className="mb-6">
<img
src={"https://placehold.co/600x400/000000/FFFFFF/png"}
alt={blogPost.title}
className="w-full h-64 object-cover rounded-lg"
/>
</div>
<div className="space-y-4">
<span className="inline-block bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm">
Actualité tech
</span>
<div className="prose max-w-none">
<p className="text-gray-700 leading-relaxed">{blogPost.body}</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default BlogDetail;
Le formulaire est maintenant prêt à être utilisé. Dans un environnement de production, vous devriez :
- Ajouter une validation plus robuste
- Connecter le formulaire à une vraie API
- Ajouter une protection contre le spam
- Implémenter une validation côté serveur
Notre mini-blog est maintenant complet avec ses trois pages principales. Voici quelques suggestions d'améliorations possibles :
- Ajout d'une page détaillée pour chaque article
- Implémentation d'un système d'authentification
- Ajout de catégories pour les articles
- Intégration d'un système de commentaires
- Ajout d'un back-office pour gérer les articles