feat(frontend): convertit l'ensemble des pages et composants au DSFR

- composants UI (Input, Card, Badge, Modal, ConfirmDialog, Slider, Skeleton,
  ThemeToggle) + Sidebar
- pages auth (connexion, inscription)
- pages publiques (informations, détail, contacts urgence, mentions, RGPD)
- espace membre (dashboard, journal, wizard saisie via fr-stepper, rapports, profil)
  + composants tracker (EditSaisieModal, EmotionPicker, TrackerTimeline, EmotionChart)
- back-office admin (dashboard, utilisateurs, contenus, émotions)

Suppression de Tailwind candlelight/malachite + lucide-react. Build OK (20 routes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Azmog
2026-06-18 23:34:01 +02:00
parent 9b5be855fc
commit f6e150ddae
31 changed files with 2205 additions and 2069 deletions
@@ -57,63 +57,69 @@ export default function EditSaisieModal({ saisie, isOpen, onClose }: EditSaisieM
return (
<Modal isOpen={isOpen} onClose={onClose} title="Modifier la saisie" size="lg">
<div className="space-y-6">
{/* Émotion */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Émotion
</label>
<EmotionPicker value={emotionId} onChange={setEmotionId} />
</div>
{/* Émotion */}
<div className="fr-input-group">
<p className="fr-label">Émotion</p>
<EmotionPicker value={emotionId} onChange={setEmotionId} />
</div>
{/* Intensité */}
<div>
<Slider
value={intensite}
onChange={setIntensite}
min={1}
max={10}
label="Intensité de l'émotion"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>Faible</span>
<span>Modérée</span>
<span>Forte</span>
</div>
{/* Intensité */}
<div className="fr-input-group">
<Slider
value={intensite}
onChange={setIntensite}
min={1}
max={10}
label="Intensité de l'émotion"
/>
<div className="fr-grid-row fr-grid-row--middle fr-mt-1v">
<span className="fr-col fr-text--sm">Faible</span>
<span className="fr-col fr-text--sm" style={{ textAlign: 'center' }}>
Modérée
</span>
<span className="fr-col fr-text--sm" style={{ textAlign: 'right' }}>
Forte
</span>
</div>
</div>
{/* Date */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Date de la saisie
</label>
<input
type="date"
value={dateSaisie}
onChange={(e) => setDateSaisie(e.target.value)}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500"
/>
</div>
{/* Date */}
<div className="fr-input-group">
<label className="fr-label" htmlFor="edit-saisie-date">
Date de la saisie
</label>
<input
id="edit-saisie-date"
className="fr-input"
type="date"
value={dateSaisie}
onChange={(e) => setDateSaisie(e.target.value)}
/>
</div>
{/* Note */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Note (optionnel)
</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Décrivez ce que vous ressentez..."
rows={3}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-candlelight-500 placeholder:text-gray-400 resize-none"
/>
</div>
{/* Note */}
<div className="fr-input-group">
<label className="fr-label" htmlFor="edit-saisie-note">
Note (optionnel)
</label>
<textarea
id="edit-saisie-note"
className="fr-input"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Décrivez ce que vous ressentez..."
rows={3}
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<Button variant="ghost" onClick={onClose}>
{/* Actions */}
<ul className="fr-btns-group fr-btns-group--inline-md fr-btns-group--right">
<li>
<Button variant="secondary" onClick={onClose}>
Annuler
</Button>
</li>
<li>
<Button
onClick={handleSubmit}
loading={updateSaisie.isPending}
@@ -121,8 +127,8 @@ export default function EditSaisieModal({ saisie, isOpen, onClose }: EditSaisieM
>
Enregistrer
</Button>
</div>
</div>
</li>
</ul>
</Modal>
);
}
@@ -38,7 +38,7 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
}
return (
<div className="w-full h-80">
<div style={{ width: '100%', height: '20rem' }}>
<ResponsiveContainer width="100%" height={320}>
<PieChart>
<Pie
@@ -88,10 +88,10 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
}
return (
<div className="w-full h-80">
<div style={{ width: '100%', height: '20rem' }}>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={lineData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<CartesianGrid strokeDasharray="3 3" style={{ opacity: 0.3 }} />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
@@ -131,10 +131,10 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
}
return (
<div className="w-full h-80">
<div style={{ width: '100%', height: '20rem' }}>
<ResponsiveContainer width="100%" height={320}>
<BarChart data={barData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<CartesianGrid strokeDasharray="3 3" style={{ opacity: 0.3 }} />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis domain={[0, 10]} tick={{ fontSize: 12 }} />
<Tooltip
@@ -146,7 +146,7 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
}}
formatter={(value: ValueType | undefined) => [`${Number(value).toFixed(1)}`, 'Intensité moyenne']}
/>
<Bar dataKey="intensite" fill="#fce117" radius={[6, 6, 0, 0]} />
<Bar dataKey="intensite" fill="#000091" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
@@ -158,8 +158,17 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
function EmptyChart({ message }: { message: string }) {
return (
<div className="w-full h-80 flex items-center justify-center text-gray-400 dark:text-gray-600">
<p className="text-lg">{message}</p>
<div
className="fr-text-mention--grey"
style={{
width: '100%',
height: '20rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<p className="fr-text--lead fr-mb-0">{message}</p>
</div>
);
}
@@ -14,13 +14,7 @@ export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
const [selectedParent, setSelectedParent] = useState<Emotion | null>(null);
if (isLoading) {
return (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="h-20 rounded-xl bg-gray-100 dark:bg-gray-800 animate-pulse" />
))}
</div>
);
return <p className="fr-text--sm fr-text-mention--grey">Chargement des émotions…</p>;
}
// Si une émotion parent est sélectionnée et qu'elle a des enfants, afficher les sous-émotions
@@ -30,76 +24,75 @@ export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
<button
type="button"
onClick={() => setSelectedParent(null)}
className="mb-4 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 flex items-center gap-1"
className="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-icon-arrow-left-line fr-btn--icon-left fr-mb-2w"
>
← Retour aux émotions
Retour aux émotions
</button>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Précisez votre émotion de <span className="font-medium" style={{ color: selectedParent.couleur }}>{selectedParent.nom}</span> :
<p className="fr-text--sm fr-mb-2w">
Précisez votre émotion de{' '}
<span className="fr-text--bold" style={{ color: selectedParent.couleur }}>
{selectedParent.nom}
</span>{' '}
:
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{selectedParent.enfants.map((child) => (
<button
key={child.id}
type="button"
onClick={() => onChange(child.id)}
className={`
flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-200
${value === child.id
? 'border-current shadow-lg scale-105'
: 'border-gray-200 dark:border-gray-700 hover:border-current hover:shadow-md'
}
`}
style={{
color: child.couleur,
borderColor: value === child.id ? child.couleur : undefined,
}}
>
<span className="text-2xl">{child.icone || '🔵'}</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{child.nom}</span>
</button>
))}
</div>
<ul className="fr-tags-group">
{selectedParent.enfants.map((child) => {
const isSelected = value === child.id;
return (
<li key={child.id}>
<button
type="button"
aria-pressed={isSelected}
onClick={() => onChange(child.id)}
className={`fr-tag${isSelected ? ' fr-tag--green-emeraude' : ''}`}
>
<span aria-hidden="true" className="fr-mr-1v">
{child.icone || '🔵'}
</span>
{child.nom}
</button>
</li>
);
})}
</ul>
</div>
);
}
return (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
<ul className="fr-tags-group">
{emotions?.map((emotion) => {
const hasChildren = emotion.enfants && emotion.enfants.length > 0;
const isSelected = value === emotion.id || emotion.enfants?.some(e => e.id === value);
const isSelected =
value === emotion.id || emotion.enfants?.some((e) => e.id === value);
return (
<button
key={emotion.id}
type="button"
onClick={() => {
if (hasChildren) {
setSelectedParent(emotion);
} else {
onChange(emotion.id);
}
}}
className={`
flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-200
${isSelected
? 'shadow-lg scale-105'
: 'border-gray-200 dark:border-gray-700 hover:shadow-md'
}
`}
style={{
borderColor: isSelected ? emotion.couleur : undefined,
}}
>
<span className="text-3xl">{emotion.icone || '🔵'}</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{emotion.nom}</span>
{hasChildren && (
<span className="text-xs text-gray-400">▼</span>
)}
</button>
<li key={emotion.id}>
<button
type="button"
aria-pressed={!!isSelected}
onClick={() => {
if (hasChildren) {
setSelectedParent(emotion);
} else {
onChange(emotion.id);
}
}}
className={`fr-tag fr-tag--md${isSelected ? ' fr-tag--green-emeraude' : ''}`}
>
<span aria-hidden="true" className="fr-mr-1v">
{emotion.icone || '🔵'}
</span>
{emotion.nom}
{hasChildren && (
<span aria-hidden="true" className="fr-ml-1v">
▾
</span>
)}
</button>
</li>
);
})}
</div>
</ul>
);
}
@@ -2,8 +2,6 @@
import React from 'react';
import type { SaisieTracker } from '@/types';
import Badge from '@/components/ui/Badge';
import { Trash2, Edit2 } from 'lucide-react';
interface TrackerTimelineProps {
saisies: SaisieTracker[];
@@ -14,9 +12,11 @@ interface TrackerTimelineProps {
export default function TrackerTimeline({ saisies, onEdit, onDelete }: TrackerTimelineProps) {
if (saisies.length === 0) {
return (
<div className="text-center py-12 text-gray-400 dark:text-gray-600">
<p className="text-lg mb-2">Aucune saisie enregistrée</p>
<p className="text-sm">Commencez par enregistrer votre première émotion</p>
<div className="fr-callout">
<p className="fr-callout__title">Aucune saisie enregistrée</p>
<p className="fr-callout__text">
Commencez par enregistrer votre première émotion.
</p>
</div>
);
}
@@ -35,78 +35,105 @@ export default function TrackerTimeline({ saisies, onEdit, onDelete }: TrackerTi
}, {});
return (
<div className="space-y-8">
<div>
{Object.entries(grouped).map(([date, items]) => (
<div key={date}>
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-4">
<section key={date} className="fr-mb-4w">
<h3 className="fr-h6 fr-text-mention--grey fr-mb-2w" style={{ textTransform: 'uppercase' }}>
{date}
</h3>
<div className="space-y-3">
<div className="fr-grid-row fr-grid-row--gutters">
{items.map((saisie) => (
<div
key={saisie.id}
className="flex items-start gap-4 p-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 hover:shadow-md transition-all duration-200"
>
{/* Icône émotion */}
<div
className="shrink-0 w-12 h-12 rounded-xl flex items-center justify-center text-xl"
style={{ backgroundColor: `${saisie.emotion?.couleur}20` }}
>
{saisie.emotion?.icone || '🔵'}
</div>
<div key={saisie.id} className="fr-col-12">
<div className="fr-card fr-p-2w">
<div className="fr-grid-row fr-grid-row--gutters fr-grid-row--top">
{/* Icône émotion */}
<div className="fr-col-auto">
<span
aria-hidden="true"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '3rem',
height: '3rem',
borderRadius: '0.5rem',
fontSize: '1.5rem',
backgroundColor: `${saisie.emotion?.couleur}20`,
}}
>
{saisie.emotion?.icone || '🔵'}
</span>
</div>
{/* Contenu */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge color={saisie.emotion?.couleur}>
{saisie.emotion?.nom}
</Badge>
<span className="text-sm text-gray-400 dark:text-gray-500">
Intensité : {saisie.intensite}/10
</span>
</div>
{/* Barre d'intensité */}
<div className="w-full h-1.5 bg-gray-100 dark:bg-gray-800 rounded-full mb-2">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${saisie.intensite * 10}%`,
backgroundColor: saisie.emotion?.couleur || '#fce117',
}}
/>
</div>
{saisie.note && (
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{saisie.note}
</p>
)}
</div>
{/* Contenu */}
<div className="fr-col">
<p className="fr-mb-1v">
<span className="fr-badge fr-badge--info fr-mr-2v">
{saisie.emotion?.nom}
</span>
<span className="fr-text--sm fr-text-mention--grey">
Intensité : {saisie.intensite}/10
</span>
</p>
{/* Barre d'intensité */}
<div
className="fr-mb-1w"
style={{
width: '100%',
height: '0.375rem',
borderRadius: '0.25rem',
backgroundColor: 'var(--background-contrast-grey)',
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
width: `${saisie.intensite * 10}%`,
backgroundColor: saisie.emotion?.couleur || '#000091',
}}
/>
</div>
{saisie.note && (
<p className="fr-text--sm fr-mb-0">{saisie.note}</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
{onEdit && (
<button
onClick={() => onEdit(saisie)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Modifier"
>
<Edit2 size={16} />
</button>
)}
{onDelete && (
<button
onClick={() => onDelete(saisie.id)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 text-gray-400 hover:text-red-500 transition-colors"
aria-label="Supprimer"
>
<Trash2 size={16} />
</button>
)}
{/* Actions */}
<div className="fr-col-auto">
<ul className="fr-btns-group fr-btns-group--inline fr-btns-group--sm">
{onEdit && (
<li>
<button
type="button"
onClick={() => onEdit(saisie)}
className="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-icon-edit-line"
title="Modifier"
>
Modifier
</button>
</li>
)}
{onDelete && (
<li>
<button
type="button"
onClick={() => onDelete(saisie.id)}
className="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-icon-delete-line"
title="Supprimer"
>
Supprimer
</button>
</li>
)}
</ul>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</section>
))}
</div>
);
+46 -88
View File
@@ -3,41 +3,33 @@
import React, { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Users,
FileText,
Heart,
Menu,
X,
} from "lucide-react";
interface SidebarLink {
href: string;
label: string;
icon: React.ReactNode;
icon: string;
}
const sidebarLinks: SidebarLink[] = [
{
href: "/admin",
label: "Dashboard",
icon: <LayoutDashboard size={20} />,
icon: "fr-icon-dashboard-3-line",
},
{
href: "/admin/utilisateurs",
label: "Utilisateurs",
icon: <Users size={20} />,
icon: "fr-icon-user-line",
},
{
href: "/admin/contenus",
label: "Contenus",
icon: <FileText size={20} />,
icon: "fr-icon-file-text-line",
},
{
href: "/admin/emotions",
label: "Émotions",
icon: <Heart size={20} />,
icon: "fr-icon-heart-line",
},
];
@@ -51,83 +43,49 @@ export default function Sidebar() {
};
return (
<>
{/* Mobile toggle */}
<button
onClick={() => setCollapsed(!collapsed)}
className="lg:hidden fixed top-20 left-4 z-40 p-2 bg-white dark:bg-gray-900 rounded-xl shadow-lg border border-gray-200 dark:border-gray-800 text-gray-600 dark:text-gray-400"
aria-label="Toggle sidebar"
>
{collapsed ? <X size={20} /> : <Menu size={20} />}
</button>
{/* Overlay mobile */}
{collapsed && (
<nav
className="fr-sidemenu"
aria-labelledby="fr-sidemenu-title"
role="navigation"
>
<div className="fr-sidemenu__inner">
<button
type="button"
className="fr-sidemenu__btn"
aria-controls="fr-sidemenu-wrapper"
aria-expanded={collapsed}
onClick={() => setCollapsed(!collapsed)}
>
Menu d&apos;administration
</button>
<div
className="lg:hidden fixed inset-0 z-30 bg-black/50 backdrop-blur-sm"
onClick={() => setCollapsed(false)}
/>
)}
{/* Sidebar */}
<aside
className={`
fixed lg:sticky top-0 left-0 z-40 lg:z-auto
h-screen w-64
bg-white dark:bg-gray-950
border-r border-gray-200 dark:border-gray-800
flex flex-col
transition-transform duration-300 ease-in-out
${collapsed ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
`}
>
{/* Logo */}
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200 dark:border-gray-800">
<Link href="/admin" className="flex items-center gap-0.5">
<span className="text-xl font-bold font-display text-malachite-500">
CESI
</span>
<span className="text-xl font-bold font-display text-candlelight-500">
ZEN
</span>
</Link>
<button
onClick={() => setCollapsed(false)}
className="lg:hidden p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500"
>
<X size={18} />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{sidebarLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setCollapsed(false)}
className={`
flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200
${
isActive(link.href)
? "bg-candlelight-100 dark:bg-candlelight-950 text-candlelight-700 dark:text-candlelight-400 shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-200"
}
`}
>
{link.icon}
{link.label}
className={`fr-collapse${collapsed ? " fr-collapse--expanded" : ""}`}
id="fr-sidemenu-wrapper"
>
<div className="fr-sidemenu__title" id="fr-sidemenu-title">
<Link href="/admin" className="fr-sidemenu__link">
Administration CESIZen
</Link>
))}
</nav>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-800">
<p className="text-xs text-gray-400 dark:text-gray-600">
Administration CESIZen
</p>
</div>
<ul className="fr-sidemenu__list">
{sidebarLinks.map((link) => {
const active = isActive(link.href);
return (
<li className="fr-sidemenu__item" key={link.href}>
<Link
className={`fr-sidemenu__link ${link.icon} fr-link--icon-left`}
href={link.href}
aria-current={active ? "page" : undefined}
onClick={() => setCollapsed(false)}
>
{link.label}
</Link>
</li>
);
})}
</ul>
</div>
</aside>
</>
</div>
</nav>
);
}
+32 -18
View File
@@ -8,27 +8,41 @@ interface BadgeProps {
className?: string;
}
// Mappe une couleur (hex) vers une variante de statut DSFR `fr-badge--*`.
// Conserve la prop `color` pour garder la même signature, mais le rendu
// utilise désormais le système de couleurs du DSFR (thème clair/sombre géré).
function badgeVariant(color: string): string {
const c = color.toLowerCase();
switch (c) {
case "#22c55e":
case "#16a34a":
case "green":
return "fr-badge--success";
case "#ef4444":
case "#dc2626":
case "red":
return "fr-badge--error";
case "#f59e0b":
case "#fce117":
case "orange":
return "fr-badge--warning";
case "#3b82f6":
case "#0ea5e9":
case "blue":
return "fr-badge--info";
default:
return "fr-badge--new";
}
}
export default function Badge({
children,
color = "#fce117",
className = "",
}: BadgeProps) {
return (
<span
className={`
inline-flex items-center
px-2.5 py-0.5
rounded-full
text-xs font-medium
transition-all duration-200
${className}
`}
style={{
backgroundColor: `${color}20`,
color: color,
}}
>
{children}
</span>
);
const classes = ["fr-badge", "fr-badge--sm", badgeVariant(color), className]
.filter(Boolean)
.join(" ");
return <span className={classes}>{children}</span>;
}
+12 -15
View File
@@ -8,20 +8,17 @@ interface CardProps {
hover?: boolean;
}
// Conteneur neutre au style DSFR (fr-card sans la structure imposée d'une carte
// éditoriale DSFR, afin de garder un conteneur générique réutilisable).
export default function Card({ children, className = "", hover = false }: CardProps) {
return (
<div
className={`
bg-white dark:bg-gray-900
rounded-2xl shadow-sm
border border-gray-100 dark:border-gray-800
p-6
transition-all duration-200
${hover ? "hover:shadow-lg hover:-translate-y-0.5" : ""}
${className}
`}
>
{children}
</div>
);
const classes = [
"fr-card",
"fr-p-3w",
hover ? "fr-enlarge-link" : "",
className,
]
.filter(Boolean)
.join(" ");
return <div className={classes}>{children}</div>;
}
+19 -36
View File
@@ -1,6 +1,5 @@
"use client";
import { AlertTriangle } from "lucide-react";
import Modal from "./Modal";
import Button from "./Button";
@@ -27,42 +26,26 @@ export default function ConfirmDialog({
}: ConfirmDialogProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<div className="flex items-start gap-3 mb-6">
<div
className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${
destructive
? "bg-red-50 dark:bg-red-950/40"
: "bg-malachite-50 dark:bg-malachite-950/40"
}`}
>
<AlertTriangle
size={20}
className={
destructive
? "text-red-600 dark:text-red-400"
: "text-malachite-600 dark:text-malachite-400"
}
/>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed pt-1">
{message}
</p>
</div>
<p className="fr-mb-3w">{message}</p>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
{cancelLabel}
</Button>
<Button
variant={destructive ? "danger" : "primary"}
onClick={() => {
onConfirm();
onClose();
}}
>
{confirmLabel}
</Button>
</div>
<ul className="fr-btns-group fr-btns-group--right fr-btns-group--inline-reverse fr-btns-group--inline-lg">
<li>
<Button
variant={destructive ? "danger" : "primary"}
onClick={() => {
onConfirm();
onClose();
}}
>
{confirmLabel}
</Button>
</li>
<li>
<Button variant="secondary" onClick={onClose}>
{cancelLabel}
</Button>
</li>
</ul>
</Modal>
);
}
+8 -24
View File
@@ -17,42 +17,26 @@ export default function Input({
...inputProps
}: InputProps) {
const inputId = id || label?.toLowerCase().replace(/\s+/g, "-");
const errorId = error ? `${inputId}-error` : undefined;
return (
<div className="w-full">
<div className={`fr-input-group${error ? " fr-input-group--error" : ""}`}>
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
>
<label className="fr-label" htmlFor={inputId}>
{label}
</label>
)}
<div className="relative">
{icon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 dark:text-gray-500">
{icon}
</div>
)}
<div className={icon ? "fr-input-wrap" : undefined}>
{icon && <span aria-hidden="true">{icon}</span>}
<input
id={inputId}
className={`
w-full rounded-xl border border-gray-300 dark:border-gray-700
bg-white dark:bg-gray-900
text-gray-900 dark:text-gray-100
placeholder:text-gray-400 dark:placeholder:text-gray-500
focus:outline-none focus:ring-2 focus:ring-candlelight-500 focus:border-candlelight-500
transition-all duration-200
px-4 py-2.5 text-base
${icon ? "pl-10" : ""}
${error ? "border-red-500 focus:ring-red-500 focus:border-red-500" : ""}
${className}
`}
className={`fr-input${error ? " fr-input--error" : ""} ${className}`.trim()}
aria-describedby={errorId}
{...inputProps}
/>
</div>
{error && (
<p className="mt-1.5 text-sm text-red-500 dark:text-red-400">
<p id={errorId} className="fr-error-text">
{error}
</p>
)}
+46 -41
View File
@@ -1,7 +1,6 @@
"use client";
import React, { useEffect, useCallback } from "react";
import { X } from "lucide-react";
type ModalSize = "sm" | "md" | "lg";
@@ -13,10 +12,11 @@ interface ModalProps {
size?: ModalSize;
}
const sizeClasses: Record<ModalSize, string> = {
sm: "max-w-sm",
md: "max-w-lg",
lg: "max-w-2xl",
// Largeur de la colonne DSFR selon la taille demandée.
const sizeColClasses: Record<ModalSize, string> = {
sm: "fr-col-12 fr-col-md-4",
md: "fr-col-12 fr-col-md-6",
lg: "fr-col-12 fr-col-md-8",
};
export default function Modal({
@@ -46,44 +46,49 @@ export default function Modal({
if (!isOpen) return null;
// L'ouverture/fermeture est pilotée par React (isOpen/onClose), on applique
// donc la structure et les classes visuelles DSFR sans le JS d'ouverture DSFR.
return (
<div className="fixed inset-0 z-100 flex items-center justify-center p-4">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200"
onClick={onClose}
/>
{/* Modal */}
<div
className={`
relative w-full ${sizeClasses[size]}
bg-white dark:bg-gray-900
rounded-2xl shadow-2xl
border border-gray-100 dark:border-gray-800
p-6
animate-in zoom-in-95 fade-in duration-200
`}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
{title && (
<h2 className="text-xl font-bold font-display text-gray-900 dark:text-gray-100">
{title}
</h2>
)}
<button
onClick={onClose}
className="ml-auto p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-500 dark:text-gray-400"
aria-label="Fermer"
>
<X size={20} />
</button>
<dialog
className="fr-modal fr-modal--opened"
aria-modal="true"
role="dialog"
open
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
display: "block",
background: "transparent",
border: "none",
maxWidth: "none",
maxHeight: "none",
width: "100%",
height: "100%",
}}
>
<div className="fr-container fr-container--fluid fr-container-md">
<div className="fr-grid-row fr-grid-row--center">
<div className={sizeColClasses[size]}>
<div className="fr-modal__body">
<div className="fr-modal__header">
<button
type="button"
className="fr-btn--close fr-btn"
aria-label="Fermer"
onClick={onClose}
>
Fermer
</button>
</div>
<div className="fr-modal__content">
{title && <h1 className="fr-modal__title">{title}</h1>}
{children}
</div>
</div>
</div>
</div>
{/* Content */}
<div>{children}</div>
</div>
</div>
</dialog>
);
}
+14 -10
View File
@@ -9,10 +9,12 @@ interface SkeletonProps {
variant?: SkeletonVariant;
}
const variantClasses: Record<SkeletonVariant, string> = {
text: "h-4 w-full rounded",
circle: "h-10 w-10 rounded-full",
rect: "h-24 w-full rounded-xl",
// Dimensions/forme selon la variante. Couleur neutre gérée en ligne pour
// rester cohérent avec le thème DSFR (sans couleur custom Tailwind).
const variantStyle: Record<SkeletonVariant, React.CSSProperties> = {
text: { height: "1rem", width: "100%", borderRadius: "0.25rem" },
circle: { height: "2.5rem", width: "2.5rem", borderRadius: "9999px" },
rect: { height: "6rem", width: "100%", borderRadius: "0.25rem" },
};
export default function Skeleton({
@@ -21,12 +23,14 @@ export default function Skeleton({
}: SkeletonProps) {
return (
<div
className={`
animate-pulse
bg-gray-200 dark:bg-gray-700
${variantClasses[variant]}
${className}
`}
className={className}
aria-hidden="true"
style={{
...variantStyle[variant],
backgroundColor: "var(--background-contrast-grey)",
opacity: 0.6,
animation: "fr-skeleton-pulse 1.5s ease-in-out infinite",
}}
/>
);
}
+34 -22
View File
@@ -19,33 +19,45 @@ export default function Slider({
label,
showValue = true,
}: SliderProps) {
const rangeId = label
? `range-${label.toLowerCase().replace(/\s+/g, "-")}`
: "range";
return (
<div className="w-full">
<div className="fr-range-group">
{(label || showValue) && (
<div className="flex items-center justify-between mb-2">
{label && (
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
)}
<label className="fr-label" htmlFor={rangeId}>
{label}
{showValue && (
<span className="text-sm font-bold text-candlelight-600 dark:text-candlelight-400 bg-candlelight-50 dark:bg-candlelight-950 px-2 py-0.5 rounded-lg">
{value}
</span>
<span className="fr-hint-text">Valeur sélectionnée : {value}</span>
)}
</div>
</label>
)}
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-2 rounded-full appearance-none cursor-pointer bg-gray-200 dark:bg-gray-700 accent-candlelight-500"
/>
<div className="flex justify-between mt-1">
<span className="text-xs text-gray-400 dark:text-gray-500">{min}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{max}</span>
<div
className="fr-range fr-range--sm"
data-fr-js-range="true"
data-fr-min={min}
data-fr-max={max}
>
<span
className="fr-range__output"
aria-hidden="true"
>{`${value}`}</span>
<input
id={rangeId}
type="range"
min={min}
max={max}
value={value}
step={1}
onChange={(e) => onChange(Number(e.target.value))}
/>
<div className="fr-range__min" aria-hidden="true">
{min}
</div>
<div className="fr-range__max" aria-hidden="true">
{max}
</div>
</div>
</div>
);
+25 -9
View File
@@ -1,35 +1,51 @@
"use client";
import React from "react";
import { useTheme } from "next-themes";
import { Sun, Moon } from "lucide-react";
type Scheme = "light" | "dark";
export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
const [scheme, setScheme] = React.useState<Scheme>("light");
React.useEffect(() => {
setMounted(true);
const current = document.documentElement.getAttribute("data-fr-theme");
setScheme(current === "dark" ? "dark" : "light");
}, []);
const toggle = () => {
const next: Scheme = scheme === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-fr-theme", next);
document.documentElement.setAttribute("data-fr-scheme", next);
setScheme(next);
};
if (!mounted) {
return (
<button
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
type="button"
className="fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-theme-fill"
aria-label="Changer le thème"
>
<div className="h-5 w-5" />
Thème
</button>
);
}
return (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 transition-all duration-200 text-gray-600 dark:text-gray-400"
aria-label={theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"}
type="button"
onClick={toggle}
className={`fr-btn fr-btn--tertiary fr-btn--icon-left ${
scheme === "dark" ? "fr-icon-sun-fill" : "fr-icon-moon-fill"
}`}
aria-label={
scheme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"
}
title={scheme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"}
>
{theme === "dark" ? <Sun size={20} /> : <Moon size={20} />}
{scheme === "dark" ? "Mode clair" : "Mode sombre"}
</button>
);
}