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:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user