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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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