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:
@@ -1,13 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Edit2, Trash2 } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import type { Feed } from '@/types';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -90,92 +87,107 @@ export default function AdminContenusPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
Contenus
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
<div className="fr-grid-row fr-grid-row--middle fr-grid-row--gutters fr-mb-4w">
|
||||
<div className="fr-col">
|
||||
<h1 className="fr-mb-1v">Contenus</h1>
|
||||
<p className="fr-text--sm fr-mb-0">
|
||||
Gérez les articles et ressources de la plateforme
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate} size="sm">
|
||||
<Plus size={16} />
|
||||
Nouvel article
|
||||
</Button>
|
||||
<div className="fr-col-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="fr-btn fr-btn--icon-left fr-icon-add-line"
|
||||
onClick={openCreate}
|
||||
>
|
||||
Nouvel article
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<div className="h-6 bg-gray-100 dark:bg-gray-800 rounded animate-pulse w-48 mb-2" />
|
||||
<div className="h-4 bg-gray-100 dark:bg-gray-800 rounded animate-pulse w-full" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<p>Chargement…</p>
|
||||
) : feeds.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<p className="text-gray-400 dark:text-gray-600 mb-4">Aucun article</p>
|
||||
<Button onClick={openCreate} size="sm">Créer le premier article</Button>
|
||||
</Card>
|
||||
<div className="fr-callout">
|
||||
<p className="fr-callout__text">Aucun article pour le moment.</p>
|
||||
<button type="button" className="fr-btn fr-btn--icon-left fr-icon-add-line" onClick={openCreate}>
|
||||
Créer le premier article
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{feeds.map((feed) => (
|
||||
<Card key={feed.id} className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
|
||||
{feed.titre}
|
||||
</h3>
|
||||
<Badge color={feed.est_publie ? '#06c656' : '#9ca3af'}>
|
||||
{feed.est_publie ? 'Publié' : 'Brouillon'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
/{feed.slug} · Ordre: {feed.ordre}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => openEdit(feed)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit2 size={16} className="text-gray-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(feed.id)}
|
||||
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={16} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<div className="fr-table fr-table--bordered">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Titre</th>
|
||||
<th scope="col">Slug</th>
|
||||
<th scope="col">Ordre</th>
|
||||
<th scope="col">Statut</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{feeds.map((feed) => (
|
||||
<tr key={feed.id}>
|
||||
<td>{feed.titre}</td>
|
||||
<td>/{feed.slug}</td>
|
||||
<td>{feed.ordre}</td>
|
||||
<td>
|
||||
<span className={`fr-badge fr-badge--sm ${feed.est_publie ? 'fr-badge--success' : 'fr-badge--info'}`}>
|
||||
{feed.est_publie ? 'Publié' : 'Brouillon'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<ul className="fr-btns-group fr-btns-group--inline fr-btns-group--sm">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(feed)}
|
||||
className="fr-btn fr-btn--sm fr-btn--tertiary fr-btn--icon-left fr-icon-edit-line"
|
||||
title="Modifier"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(feed.id)}
|
||||
className="fr-btn fr-btn--sm fr-btn--tertiary fr-btn--icon-left fr-icon-delete-line"
|
||||
title="Supprimer"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal création/édition */}
|
||||
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title={editingFeed ? 'Modifier l\'article' : 'Nouvel article'} size="lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="fr-mt-2w">
|
||||
<Input
|
||||
label="Titre"
|
||||
value={form.titre}
|
||||
onChange={(e) => setForm({ ...form, titre: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
<div className="fr-input-group">
|
||||
<label className="fr-label" htmlFor="feed-contenu">
|
||||
Contenu (HTML)
|
||||
</label>
|
||||
<textarea
|
||||
id="feed-contenu"
|
||||
className="fr-input"
|
||||
value={form.contenu}
|
||||
onChange={(e) => setForm({ ...form, contenu: e.target.value })}
|
||||
rows={10}
|
||||
required
|
||||
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 font-mono text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
@@ -183,40 +195,47 @@ export default function AdminContenusPage() {
|
||||
value={form.image_url}
|
||||
onChange={(e) => setForm({ ...form, image_url: e.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Ordre d'affichage
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.ordre}
|
||||
onChange={(e) => setForm({ ...form, ordre: parseInt(e.target.value) || 0 })}
|
||||
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 className="fr-grid-row fr-grid-row--gutters">
|
||||
<div className="fr-col-6">
|
||||
<div className="fr-input-group">
|
||||
<label className="fr-label" htmlFor="feed-ordre">
|
||||
Ordre d'affichage
|
||||
</label>
|
||||
<input
|
||||
id="feed-ordre"
|
||||
type="number"
|
||||
className="fr-input"
|
||||
value={form.ordre}
|
||||
onChange={(e) => setForm({ ...form, ordre: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<div className="fr-col-6">
|
||||
<div className="fr-checkbox-group">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="feed-publie"
|
||||
checked={form.est_publie}
|
||||
onChange={(e) => setForm({ ...form, est_publie: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 text-malachite-500 focus:ring-malachite-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="fr-label" htmlFor="feed-publie">
|
||||
Publié
|
||||
</span>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit" loading={formLoading}>
|
||||
{editingFeed ? 'Mettre à jour' : 'Créer'}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setModalOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="fr-btns-group fr-btns-group--inline fr-mt-2w">
|
||||
<li>
|
||||
<Button type="submit" loading={formLoading}>
|
||||
{editingFeed ? 'Mettre à jour' : 'Créer'}
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button variant="ghost" onClick={() => setModalOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Users, FileText, Heart } from 'lucide-react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface AdminStats {
|
||||
@@ -41,86 +39,63 @@ export default function AdminDashboardPage() {
|
||||
}, []);
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: 'Utilisateurs',
|
||||
value: stats?.total_utilisateurs || 0,
|
||||
icon: <Users size={24} />,
|
||||
color: 'text-blue-500',
|
||||
bg: 'bg-blue-50 dark:bg-blue-950/30',
|
||||
},
|
||||
{
|
||||
label: 'Articles',
|
||||
value: stats?.total_feeds || 0,
|
||||
icon: <FileText size={24} />,
|
||||
color: 'text-malachite-500',
|
||||
bg: 'bg-malachite-50 dark:bg-malachite-950/30',
|
||||
},
|
||||
{
|
||||
label: 'Émotions',
|
||||
value: stats?.total_emotions || 0,
|
||||
icon: <Heart size={24} />,
|
||||
color: 'text-candlelight-500',
|
||||
bg: 'bg-candlelight-50 dark:bg-candlelight-950/30',
|
||||
},
|
||||
{ label: 'Utilisateurs', value: stats?.total_utilisateurs || 0, icon: 'fr-icon-user-line' },
|
||||
{ label: 'Articles', value: stats?.total_feeds || 0, icon: 'fr-icon-article-line' },
|
||||
{ label: 'Émotions', value: stats?.total_emotions || 0, icon: 'fr-icon-heart-line' },
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ href: '/admin/utilisateurs', icon: 'fr-icon-user-line', label: 'Gérer les utilisateurs' },
|
||||
{ href: '/admin/contenus', icon: 'fr-icon-article-line', label: 'Gérer les contenus' },
|
||||
{ href: '/admin/emotions', icon: 'fr-icon-heart-line', label: 'Gérer les émotions' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Administration
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Vue d'ensemble de la plateforme CESIZen
|
||||
</p>
|
||||
</div>
|
||||
<h1>Administration</h1>
|
||||
<p className="fr-text--lead fr-mb-4w">
|
||||
Vue d'ensemble de la plateforme CESIZen
|
||||
</p>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-6 mb-8">
|
||||
<div className="fr-grid-row fr-grid-row--gutters fr-mb-4w">
|
||||
{statCards.map((card) => (
|
||||
<Card key={card.label}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-14 h-14 rounded-xl ${card.bg} flex items-center justify-center ${card.color}`}>
|
||||
{card.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{card.label}</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{loading ? '...' : card.value}
|
||||
</p>
|
||||
<div className="fr-col-12 fr-col-md-4" key={card.label}>
|
||||
<div className="fr-tile fr-tile--horizontal">
|
||||
<div className="fr-tile__body">
|
||||
<div className="fr-tile__content">
|
||||
<h2 className="fr-tile__title">
|
||||
<span className={`${card.icon} fr-mr-1w`} aria-hidden="true" />
|
||||
{card.label}
|
||||
</h2>
|
||||
<p className="fr-tile__detail fr-text--bold fr-display--xs">
|
||||
{loading ? '…' : card.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Actions rapides
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<Link
|
||||
href="/admin/utilisateurs"
|
||||
className="flex items-center gap-3 p-4 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<Users size={20} className="text-blue-500" />
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Gérer les utilisateurs</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/contenus"
|
||||
className="flex items-center gap-3 p-4 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<FileText size={20} className="text-malachite-500" />
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Gérer les contenus</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/emotions"
|
||||
className="flex items-center gap-3 p-4 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<Heart size={20} className="text-candlelight-500" />
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Gérer les émotions</span>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<h2>Actions rapides</h2>
|
||||
<div className="fr-grid-row fr-grid-row--gutters">
|
||||
{quickActions.map((action) => (
|
||||
<div className="fr-col-12 fr-col-md-4" key={action.href}>
|
||||
<div className="fr-tile fr-tile--horizontal fr-enlarge-link">
|
||||
<div className="fr-tile__body">
|
||||
<div className="fr-tile__content">
|
||||
<h3 className="fr-tile__title">
|
||||
<Link href={action.href}>
|
||||
<span className={`${action.icon} fr-mr-1w`} aria-hidden="true" />
|
||||
{action.label}
|
||||
</Link>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Edit2, Trash2 } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import type { Emotion } from '@/types';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -100,193 +97,268 @@ export default function AdminEmotionsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const colorSwatch = (couleur: string) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: couleur,
|
||||
verticalAlign: 'middle',
|
||||
marginRight: '0.5rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
Émotions
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Gérez le catalogue des émotions disponibles
|
||||
<div className="fr-grid-row fr-grid-row--middle fr-grid-row--gutters fr-mb-4w">
|
||||
<div className="fr-col">
|
||||
<h1 className="fr-mb-1v">Émotions</h1>
|
||||
<p className="fr-text--sm fr-mb-0">
|
||||
Gérez le catalogue des émotions disponibles (2 niveaux)
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => openCreate()} size="sm">
|
||||
<Plus size={16} />
|
||||
Nouvelle émotion
|
||||
</Button>
|
||||
<div className="fr-col-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="fr-btn fr-btn--icon-left fr-icon-add-line"
|
||||
onClick={() => openCreate()}
|
||||
>
|
||||
Nouvelle émotion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<div className="h-6 bg-gray-100 dark:bg-gray-800 rounded animate-pulse w-32" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<p>Chargement…</p>
|
||||
) : emotions.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<p className="text-gray-400 dark:text-gray-600 mb-4">Aucune émotion</p>
|
||||
<Button onClick={() => openCreate()} size="sm">Créer la première émotion</Button>
|
||||
</Card>
|
||||
<div className="fr-callout">
|
||||
<p className="fr-callout__text">Aucune émotion pour le moment.</p>
|
||||
<button type="button" className="fr-btn fr-btn--icon-left fr-icon-add-line" onClick={() => openCreate()}>
|
||||
Créer la première émotion
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{parentEmotions.map((emotion) => (
|
||||
<Card key={emotion.id}>
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl shrink-0"
|
||||
style={{ backgroundColor: `${emotion.couleur}20` }}
|
||||
>
|
||||
{emotion.icone || '🔵'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||
{emotion.nom}
|
||||
</h3>
|
||||
<Badge color={emotion.couleur}>{emotion.couleur}</Badge>
|
||||
{!emotion.est_actif && (
|
||||
<Badge color="#9ca3af">Inactif</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">Niveau 1</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => openCreate(emotion.id)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title="Ajouter sous-émotion"
|
||||
>
|
||||
<Plus size={16} className="text-malachite-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEdit(emotion)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit2 size={16} className="text-gray-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(emotion.id)}
|
||||
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={16} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sous-émotions */}
|
||||
{emotion.enfants && emotion.enfants.length > 0 && (
|
||||
<div className="ml-8 pl-4 border-l-2 border-gray-200 dark:border-gray-700 space-y-2">
|
||||
{emotion.enfants.map((child) => (
|
||||
<div key={child.id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||
<span className="text-lg">{child.icone || '🔵'}</span>
|
||||
<span className="flex-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{child.nom}
|
||||
</span>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full shrink-0"
|
||||
style={{ backgroundColor: child.couleur }}
|
||||
/>
|
||||
{!child.est_actif && <Badge color="#9ca3af">Inactif</Badge>}
|
||||
<button
|
||||
onClick={() => openEdit(child)}
|
||||
className="p-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<Edit2 size={14} className="text-gray-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(child.id)}
|
||||
className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} className="text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
<div className="fr-table fr-table--bordered">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Émotion</th>
|
||||
<th scope="col">Niveau</th>
|
||||
<th scope="col">Couleur</th>
|
||||
<th scope="col">Statut</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parentEmotions.map((emotion) => (
|
||||
<FragmentRows
|
||||
key={emotion.id}
|
||||
emotion={emotion}
|
||||
colorSwatch={colorSwatch}
|
||||
onAddChild={openCreate}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal création/édition */}
|
||||
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title={editingEmotion ? 'Modifier l\'émotion' : 'Nouvelle émotion'}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="fr-mt-2w">
|
||||
<Input
|
||||
label="Nom"
|
||||
value={form.nom}
|
||||
onChange={(e) => setForm({ ...form, nom: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Couleur
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={form.couleur}
|
||||
onChange={(e) => setForm({ ...form, couleur: e.target.value })}
|
||||
className="w-10 h-10 rounded-lg border border-gray-300 dark:border-gray-700 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.couleur}
|
||||
onChange={(e) => setForm({ ...form, couleur: e.target.value })}
|
||||
className="flex-1 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 font-mono text-sm"
|
||||
/>
|
||||
<div className="fr-grid-row fr-grid-row--gutters">
|
||||
<div className="fr-col-6">
|
||||
<div className="fr-input-group">
|
||||
<label className="fr-label" htmlFor="emotion-couleur">Couleur</label>
|
||||
<div className="fr-grid-row fr-grid-row--gutters fr-grid-row--bottom">
|
||||
<div className="fr-col-auto">
|
||||
<input
|
||||
id="emotion-couleur"
|
||||
type="color"
|
||||
value={form.couleur}
|
||||
onChange={(e) => setForm({ ...form, couleur: e.target.value })}
|
||||
style={{ width: '2.5rem', height: '2.5rem', border: 'none', padding: 0, cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="fr-col">
|
||||
<input
|
||||
type="text"
|
||||
className="fr-input"
|
||||
value={form.couleur}
|
||||
onChange={(e) => setForm({ ...form, couleur: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label="Icône (emoji)"
|
||||
value={form.icone}
|
||||
onChange={(e) => setForm({ ...form, icone: e.target.value })}
|
||||
placeholder="😊"
|
||||
/>
|
||||
<div className="fr-col-6">
|
||||
<Input
|
||||
label="Icône (emoji)"
|
||||
value={form.icone}
|
||||
onChange={(e) => setForm({ ...form, icone: e.target.value })}
|
||||
placeholder="😊"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.niveau === 2 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Émotion parente
|
||||
</label>
|
||||
<div className="fr-select-group">
|
||||
<label className="fr-label" htmlFor="emotion-parent">Émotion parente</label>
|
||||
<select
|
||||
id="emotion-parent"
|
||||
className="fr-select"
|
||||
value={form.parent_id || ''}
|
||||
onChange={(e) => setForm({ ...form, parent_id: Number(e.target.value) || null })}
|
||||
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"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option value="">Sélectionner…</option>
|
||||
{parentEmotions.map((e) => (
|
||||
<option key={e.id} value={e.id}>{e.icone} {e.nom}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<div className="fr-checkbox-group">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="emotion-actif"
|
||||
checked={form.est_actif}
|
||||
onChange={(e) => setForm({ ...form, est_actif: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 text-malachite-500 focus:ring-malachite-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="fr-label" htmlFor="emotion-actif">
|
||||
Active
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit" loading={formLoading}>
|
||||
{editingEmotion ? 'Mettre à jour' : 'Créer'}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setModalOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
<ul className="fr-btns-group fr-btns-group--inline fr-mt-2w">
|
||||
<li>
|
||||
<Button type="submit" loading={formLoading}>
|
||||
{editingEmotion ? 'Mettre à jour' : 'Créer'}
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button variant="ghost" onClick={() => setModalOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FragmentRows({
|
||||
emotion,
|
||||
colorSwatch,
|
||||
onAddChild,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
emotion: Emotion;
|
||||
colorSwatch: (couleur: string) => React.ReactNode;
|
||||
onAddChild: (parentId?: number) => void;
|
||||
onEdit: (emotion: Emotion) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>
|
||||
<span className="fr-mr-1w" aria-hidden="true">{emotion.icone || '🔵'}</span>
|
||||
<strong>{emotion.nom}</strong>
|
||||
</td>
|
||||
<td><span className="fr-badge fr-badge--sm">Niveau 1</span></td>
|
||||
<td>{colorSwatch(emotion.couleur)}<span className="fr-text--xs">{emotion.couleur}</span></td>
|
||||
<td>
|
||||
<span className={`fr-badge fr-badge--sm ${emotion.est_actif ? 'fr-badge--success' : 'fr-badge--error'}`}>
|
||||
{emotion.est_actif ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<ul className="fr-btns-group fr-btns-group--inline fr-btns-group--sm">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddChild(emotion.id)}
|
||||
className="fr-btn fr-btn--sm fr-btn--tertiary fr-btn--icon-left fr-icon-add-line"
|
||||
title="Ajouter une sous-émotion"
|
||||
>
|
||||
Sous-émotion
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(emotion)}
|
||||
className="fr-btn fr-btn--sm fr-btn--tertiary fr-btn--icon-left fr-icon-edit-line"
|
||||
title="Modifier"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(emotion.id)}
|
||||
className="fr-btn fr-btn--sm fr-btn--tertiary fr-btn--icon-left fr-icon-delete-line"
|
||||
title="Supprimer"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
{emotion.enfants?.map((child) => (
|
||||
<tr key={child.id}>
|
||||
<td>
|
||||
<span className="fr-icon-arrow-right-line fr-icon--sm fr-mr-1w" aria-hidden="true" />
|
||||
<span className="fr-mr-1w" aria-hidden="true">{child.icone || '🔵'}</span>
|
||||
{child.nom}
|
||||
</td>
|
||||
<td><span className="fr-badge fr-badge--sm fr-badge--info">Niveau 2</span></td>
|
||||
<td>{colorSwatch(child.couleur)}<span className="fr-text--xs">{child.couleur}</span></td>
|
||||
<td>
|
||||
<span className={`fr-badge fr-badge--sm ${child.est_actif ? 'fr-badge--success' : 'fr-badge--error'}`}>
|
||||
{child.est_actif ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<ul className="fr-btns-group fr-btns-group--inline fr-btns-group--sm">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(child)}
|
||||
className="fr-btn fr-btn--sm fr-btn--tertiary fr-btn--icon-left fr-icon-edit-line"
|
||||
title="Modifier"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(child.id)}
|
||||
className="fr-btn fr-btn--sm fr-btn--tertiary fr-btn--icon-left fr-icon-delete-line"
|
||||
title="Supprimer"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search, UserCheck, UserX, Trash2, Plus } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import type { Utilisateur } from '@/types';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -76,145 +73,153 @@ export default function AdminUtilisateursPage() {
|
||||
`${u.prenom} ${u.nom} ${u.email}`.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const roleColor = (nom: string) => {
|
||||
const roleBadgeClass = (nom?: string) => {
|
||||
switch (nom) {
|
||||
case 'administrateur': return '#ef4444';
|
||||
case 'membre': return '#06c656';
|
||||
default: return '#9ca3af';
|
||||
case 'administrateur': return 'fr-badge fr-badge--error';
|
||||
case 'membre': return 'fr-badge fr-badge--success';
|
||||
default: return 'fr-badge';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
Utilisateurs
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
<div className="fr-grid-row fr-grid-row--middle fr-grid-row--gutters fr-mb-4w">
|
||||
<div className="fr-col">
|
||||
<h1 className="fr-mb-1v">Utilisateurs</h1>
|
||||
<p className="fr-text--sm fr-mb-0">
|
||||
{utilisateurs.length} utilisateur{utilisateurs.length > 1 ? 's' : ''} enregistré{utilisateurs.length > 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateModalOpen(true)} size="sm">
|
||||
<Plus size={16} />
|
||||
Nouvel utilisateur
|
||||
</Button>
|
||||
<div className="fr-col-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="fr-btn fr-btn--icon-left fr-icon-add-line"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
Nouvel utilisateur
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recherche */}
|
||||
<div className="mb-6">
|
||||
<Input
|
||||
placeholder="Rechercher un utilisateur..."
|
||||
icon={<Search size={18} />}
|
||||
<div className="fr-search-bar fr-mb-4w" role="search">
|
||||
<label className="fr-label" htmlFor="recherche-utilisateur">
|
||||
Rechercher un utilisateur
|
||||
</label>
|
||||
<input
|
||||
className="fr-input"
|
||||
id="recherche-utilisateur"
|
||||
type="search"
|
||||
placeholder="Rechercher un utilisateur…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="fr-btn" title="Rechercher">
|
||||
Rechercher
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Utilisateur</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Email</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Rôle</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Statut</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Actions</th>
|
||||
<div className="fr-table fr-table--bordered">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Utilisateur</th>
|
||||
<th scope="col">Email</th>
|
||||
<th scope="col">Rôle</th>
|
||||
<th scope="col">Statut</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={5}>Chargement…</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td colSpan={5} className="px-6 py-4">
|
||||
<div className="h-4 bg-gray-100 dark:bg-gray-800 rounded animate-pulse" />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-8 text-center text-gray-400">
|
||||
Aucun utilisateur trouvé
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5}>Aucun utilisateur trouvé</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>{user.prenom} {user.nom}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
<span className={roleBadgeClass(user.role?.nom)}>
|
||||
{user.role?.nom || 'N/A'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{user.prenom} {user.nom}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{user.email}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge color={roleColor(user.role?.nom)}>
|
||||
{user.role?.nom || 'N/A'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center gap-1.5 text-sm ${user.est_actif ? 'text-malachite-600' : 'text-red-500'}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${user.est_actif ? 'bg-malachite-500' : 'bg-red-500'}`} />
|
||||
{user.est_actif ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<td>
|
||||
<span className={`fr-badge fr-badge--sm ${user.est_actif ? 'fr-badge--success' : 'fr-badge--error'}`}>
|
||||
{user.est_actif ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<ul className="fr-btns-group fr-btns-group--inline fr-btns-group--sm">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleActive(user.id)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
className={`fr-btn fr-btn--sm fr-btn--tertiary fr-btn--icon-left ${user.est_actif ? 'fr-icon-close-circle-line' : 'fr-icon-checkbox-circle-line'}`}
|
||||
title={user.est_actif ? 'Désactiver' : 'Activer'}
|
||||
>
|
||||
{user.est_actif
|
||||
? <UserX size={16} className="text-red-500" />
|
||||
: <UserCheck size={16} className="text-malachite-500" />
|
||||
}
|
||||
{user.est_actif ? 'Désactiver' : 'Activer'}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors"
|
||||
className="fr-btn fr-btn--sm fr-btn--tertiary fr-btn--icon-left fr-icon-delete-line"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={16} className="text-red-500" />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Modal création */}
|
||||
<Modal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} title="Nouvel utilisateur">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Prénom" value={newUser.prenom} onChange={(e) => setNewUser({ ...newUser, prenom: e.target.value })} required />
|
||||
<Input label="Nom" value={newUser.nom} onChange={(e) => setNewUser({ ...newUser, nom: e.target.value })} required />
|
||||
<form onSubmit={handleCreate} className="fr-mt-2w">
|
||||
<div className="fr-grid-row fr-grid-row--gutters">
|
||||
<div className="fr-col-6">
|
||||
<Input label="Prénom" value={newUser.prenom} onChange={(e) => setNewUser({ ...newUser, prenom: e.target.value })} required />
|
||||
</div>
|
||||
<div className="fr-col-6">
|
||||
<Input label="Nom" value={newUser.nom} onChange={(e) => setNewUser({ ...newUser, nom: e.target.value })} required />
|
||||
</div>
|
||||
</div>
|
||||
<Input label="Email" type="email" value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} required />
|
||||
<Input label="Mot de passe" type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} required />
|
||||
<Input label="Confirmer" type="password" value={newUser.password_confirmation} onChange={(e) => setNewUser({ ...newUser, password_confirmation: e.target.value })} required />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Rôle</label>
|
||||
<div className="fr-select-group">
|
||||
<label className="fr-label" htmlFor="role-select">Rôle</label>
|
||||
<select
|
||||
id="role-select"
|
||||
className="fr-select"
|
||||
value={newUser.role_id}
|
||||
onChange={(e) => setNewUser({ ...newUser, role_id: Number(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"
|
||||
>
|
||||
<option value={2}>Membre</option>
|
||||
<option value={3}>Administrateur</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit" loading={formLoading}>Créer</Button>
|
||||
<Button variant="ghost" onClick={() => setCreateModalOpen(false)}>Annuler</Button>
|
||||
</div>
|
||||
<ul className="fr-btns-group fr-btns-group--inline fr-mt-2w">
|
||||
<li>
|
||||
<Button type="submit" loading={formLoading}>Créer</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button variant="ghost" onClick={() => setCreateModalOpen(false)}>Annuler</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -7,16 +7,18 @@ export default function AdminLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<>
|
||||
<Header />
|
||||
<div className="flex flex-1">
|
||||
<Sidebar />
|
||||
<main className="flex-1 bg-gray-50 dark:bg-gray-950">
|
||||
<div className="p-6 lg:p-8">
|
||||
{children}
|
||||
<div className="fr-container--fluid fr-py-4w">
|
||||
<div className="fr-grid-row fr-grid-row--gutters">
|
||||
<div className="fr-col-12 fr-col-md-3 fr-col-lg-2">
|
||||
<Sidebar />
|
||||
</div>
|
||||
</main>
|
||||
<main className="fr-col-12 fr-col-md-9 fr-col-lg-10" id="contenu">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -14,10 +13,12 @@ export default function ConnexionPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
@@ -26,6 +27,7 @@ export default function ConnexionPage() {
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as import('axios').AxiosError<{ message?: string }>;
|
||||
const message = axiosError.response?.data?.message || 'Identifiants incorrects';
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -33,91 +35,73 @@ export default function ConnexionPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-candlelight-50 via-white to-malachite-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-block">
|
||||
<h1 className="font-display text-4xl font-bold">
|
||||
<span className="text-malachite-600 dark:text-malachite-400">CESI</span>
|
||||
<span className="text-candlelight-500">ZEN</span>
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2">Connectez-vous à votre compte</p>
|
||||
<>
|
||||
<h1>Connexion</h1>
|
||||
<p className="fr-text--lead">Connectez-vous à votre compte CESIZen.</p>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="fr-alert fr-alert--error fr-alert--sm fr-mb-3w" role="alert">
|
||||
<p>{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="fr-input-group">
|
||||
<label className="fr-label" htmlFor="email">
|
||||
Adresse email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="fr-input"
|
||||
placeholder="vous@exemple.fr"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-800 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Adresse email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
|
||||
placeholder="vous@exemple.fr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Mot de passe
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full pl-11 pr-12 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
|
||||
placeholder="Votre mot de passe"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-candlelight-500 hover:bg-candlelight-600 disabled:opacity-50 disabled:cursor-not-allowed text-black font-semibold py-3 rounded-xl transition-all duration-200 hover:shadow-lg"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Connexion...
|
||||
</span>
|
||||
) : (
|
||||
'Se connecter'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Pas encore de compte ?{' '}
|
||||
<Link href="/inscription" className="text-malachite-600 dark:text-malachite-400 font-medium hover:underline">
|
||||
Créer un compte
|
||||
</Link>
|
||||
<div className="fr-password" id="password-group">
|
||||
<label className="fr-label" htmlFor="password">
|
||||
Mot de passe
|
||||
</label>
|
||||
<div className="fr-input-wrap">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="fr-password__input fr-input"
|
||||
placeholder="Votre mot de passe"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="fr-password__checkbox fr-checkbox-group fr-checkbox-group--sm">
|
||||
<input
|
||||
id="password-show"
|
||||
type="checkbox"
|
||||
checked={showPassword}
|
||||
onChange={() => setShowPassword(!showPassword)}
|
||||
/>
|
||||
<label className="fr-label" htmlFor="password-show">
|
||||
Afficher le mot de passe
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={loading} className="fr-btn fr-btn--lg">
|
||||
{loading ? 'Connexion…' : 'Se connecter'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="fr-mt-4w">
|
||||
Pas encore de compte ?{' '}
|
||||
<Link href="/inscription" className="fr-link">
|
||||
Créer un compte
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { User, Mail, Lock, Eye, EyeOff } from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -50,174 +49,178 @@ export default function InscriptionPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-candlelight-50 via-white to-malachite-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-block">
|
||||
<h1 className="font-display text-4xl font-bold">
|
||||
<span className="text-malachite-600 dark:text-malachite-400">CESI</span>
|
||||
<span className="text-candlelight-500">ZEN</span>
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2">Créez votre compte gratuitement</p>
|
||||
</div>
|
||||
<>
|
||||
<h1>Créer un compte</h1>
|
||||
<p className="fr-text--lead">Créez votre compte CESIZen gratuitement.</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-800 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="prenom" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Prénom
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
id="prenom"
|
||||
name="prenom"
|
||||
type="text"
|
||||
value={form.prenom}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
|
||||
placeholder="Jean"
|
||||
/>
|
||||
</div>
|
||||
{errors.prenom && <p className="text-red-500 text-xs mt-1">{errors.prenom[0]}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="nom" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Nom
|
||||
</label>
|
||||
<input
|
||||
id="nom"
|
||||
name="nom"
|
||||
type="text"
|
||||
value={form.nom}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
|
||||
placeholder="Dupont"
|
||||
/>
|
||||
{errors.nom && <p className="text-red-500 text-xs mt-1">{errors.nom[0]}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Adresse email
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="fr-grid-row fr-grid-row--gutters">
|
||||
<div className="fr-col-12 fr-col-md-6">
|
||||
<div className={`fr-input-group${errors.prenom ? ' fr-input-group--error' : ''}`}>
|
||||
<label className="fr-label" htmlFor="prenom">
|
||||
Prénom
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
|
||||
placeholder="vous@exemple.fr"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email[0]}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Mot de passe
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full pl-11 pr-12 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
|
||||
placeholder="Minimum 8 caractères"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password[0]}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Confirmer le mot de passe
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={form.password_confirmation}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
|
||||
placeholder="Retapez votre mot de passe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 pt-2">
|
||||
<input
|
||||
id="consentement_rgpd"
|
||||
name="consentement_rgpd"
|
||||
type="checkbox"
|
||||
checked={form.consentement_rgpd}
|
||||
id="prenom"
|
||||
name="prenom"
|
||||
type="text"
|
||||
value={form.prenom}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 w-4 h-4 rounded border-gray-300 text-candlelight-500 focus:ring-candlelight-500"
|
||||
className="fr-input"
|
||||
placeholder="Jean"
|
||||
aria-describedby={errors.prenom ? 'prenom-error' : undefined}
|
||||
/>
|
||||
<label htmlFor="consentement_rgpd" className="text-sm text-gray-600 dark:text-gray-400">
|
||||
J'accepte le traitement de mes données personnelles conformément à la{' '}
|
||||
<Link href="/politique-de-confidentialite" className="text-malachite-600 dark:text-malachite-400 hover:underline">
|
||||
politique de confidentialité
|
||||
</Link>
|
||||
. Mes données sont chiffrées et je peux supprimer mon compte à tout moment.
|
||||
</label>
|
||||
</div>
|
||||
{errors.consentement_rgpd && <p className="text-red-500 text-xs">{errors.consentement_rgpd[0]}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !form.consentement_rgpd}
|
||||
className="w-full bg-malachite-500 hover:bg-malachite-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold py-3 rounded-xl transition-all duration-200 hover:shadow-lg mt-2"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Inscription...
|
||||
</span>
|
||||
) : (
|
||||
'Créer mon compte'
|
||||
{errors.prenom && (
|
||||
<p id="prenom-error" className="fr-error-text">
|
||||
{errors.prenom[0]}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Déjà un compte ?{' '}
|
||||
<Link href="/connexion" className="text-malachite-600 dark:text-malachite-400 font-medium hover:underline">
|
||||
Se connecter
|
||||
</Link>
|
||||
<div className="fr-col-12 fr-col-md-6">
|
||||
<div className={`fr-input-group${errors.nom ? ' fr-input-group--error' : ''}`}>
|
||||
<label className="fr-label" htmlFor="nom">
|
||||
Nom
|
||||
</label>
|
||||
<input
|
||||
id="nom"
|
||||
name="nom"
|
||||
type="text"
|
||||
value={form.nom}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="fr-input"
|
||||
placeholder="Dupont"
|
||||
aria-describedby={errors.nom ? 'nom-error' : undefined}
|
||||
/>
|
||||
{errors.nom && (
|
||||
<p id="nom-error" className="fr-error-text">
|
||||
{errors.nom[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`fr-input-group${errors.email ? ' fr-input-group--error' : ''}`}>
|
||||
<label className="fr-label" htmlFor="email">
|
||||
Adresse email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="fr-input"
|
||||
placeholder="vous@exemple.fr"
|
||||
autoComplete="email"
|
||||
aria-describedby={errors.email ? 'email-error' : undefined}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p id="email-error" className="fr-error-text">
|
||||
{errors.email[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`fr-password${errors.password ? ' fr-input-group--error' : ''}`}>
|
||||
<label className="fr-label" htmlFor="password">
|
||||
Mot de passe
|
||||
</label>
|
||||
<div className="fr-input-wrap">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={8}
|
||||
className="fr-password__input fr-input"
|
||||
placeholder="Minimum 8 caractères"
|
||||
autoComplete="new-password"
|
||||
aria-describedby={errors.password ? 'password-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p id="password-error" className="fr-error-text">
|
||||
{errors.password[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fr-input-group">
|
||||
<label className="fr-label" htmlFor="password_confirmation">
|
||||
Confirmer le mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={form.password_confirmation}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="fr-input"
|
||||
placeholder="Retapez votre mot de passe"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="fr-checkbox-group fr-checkbox-group--sm">
|
||||
<input
|
||||
id="password-show"
|
||||
type="checkbox"
|
||||
checked={showPassword}
|
||||
onChange={() => setShowPassword(!showPassword)}
|
||||
/>
|
||||
<label className="fr-label" htmlFor="password-show">
|
||||
Afficher les mots de passe
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={`fr-checkbox-group fr-mt-3w${errors.consentement_rgpd ? ' fr-input-group--error' : ''}`}>
|
||||
<input
|
||||
id="consentement_rgpd"
|
||||
name="consentement_rgpd"
|
||||
type="checkbox"
|
||||
checked={form.consentement_rgpd}
|
||||
onChange={handleChange}
|
||||
required
|
||||
aria-describedby={errors.consentement_rgpd ? 'consentement-error' : undefined}
|
||||
/>
|
||||
<label className="fr-label" htmlFor="consentement_rgpd">
|
||||
J'accepte le traitement de mes données personnelles conformément à la{' '}
|
||||
<Link href="/politique-de-confidentialite" className="fr-link">
|
||||
politique de confidentialité
|
||||
</Link>
|
||||
. Mes données sont chiffrées et je peux supprimer mon compte à tout moment.
|
||||
</label>
|
||||
{errors.consentement_rgpd && (
|
||||
<p id="consentement-error" className="fr-error-text">
|
||||
{errors.consentement_rgpd[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !form.consentement_rgpd}
|
||||
className="fr-btn fr-btn--lg fr-mt-3w"
|
||||
>
|
||||
{loading ? 'Inscription…' : 'Créer mon compte'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="fr-mt-4w">
|
||||
Déjà un compte ?{' '}
|
||||
<Link href="/connexion" className="fr-link">
|
||||
Se connecter
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="fr-container fr-py-8w">
|
||||
<div className="fr-grid-row fr-grid-row--center">
|
||||
<div className="fr-col-12 fr-col-md-8 fr-col-lg-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Heart, BarChart3, Plus, BookOpen } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useSaisies } from '@/hooks/useTracker';
|
||||
import { useRapport } from '@/hooks/useRapport';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import Skeleton from '@/components/ui/Skeleton';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
@@ -17,177 +13,202 @@ export default function DashboardPage() {
|
||||
const recentSaisies = saisies?.slice(0, 5) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="fr-container fr-py-6w">
|
||||
{/* En-tête */}
|
||||
<div className="mb-8">
|
||||
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Bonjour {user?.prenom} 👋
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Voici un aperçu de votre bien-être cette semaine.
|
||||
</p>
|
||||
</div>
|
||||
<h1>Bonjour {user?.prenom}</h1>
|
||||
<p className="fr-text--lead">
|
||||
Voici un aperçu de votre bien-être cette semaine.
|
||||
</p>
|
||||
|
||||
{/* Actions rapides */}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
<Link href="/journal/nouvelle-saisie">
|
||||
<Card hover className="flex items-center gap-4 bg-linear-to-r from-candlelight-50 to-candlelight-100 dark:from-candlelight-950/30 dark:to-candlelight-900/20 border-candlelight-200 dark:border-candlelight-800">
|
||||
<div className="w-12 h-12 rounded-xl bg-candlelight-200 dark:bg-candlelight-900 flex items-center justify-center">
|
||||
<Plus className="text-candlelight-700 dark:text-candlelight-400" size={24} />
|
||||
<div className="fr-grid-row fr-grid-row--gutters fr-mb-4w">
|
||||
<div className="fr-col-12 fr-col-md-4">
|
||||
<div className="fr-tile fr-tile--horizontal fr-enlarge-link">
|
||||
<div className="fr-tile__body">
|
||||
<div className="fr-tile__content">
|
||||
<h3 className="fr-tile__title">
|
||||
<Link href="/journal/nouvelle-saisie">
|
||||
<span
|
||||
className="fr-icon-add-line fr-mr-1w"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Nouvelle saisie
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="fr-tile__detail">Enregistrer une émotion</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">Nouvelle saisie</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Enregistrer une émotion</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href="/journal">
|
||||
<Card hover className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-malachite-100 dark:bg-malachite-900 flex items-center justify-center">
|
||||
<BookOpen className="text-malachite-600 dark:text-malachite-400" size={24} />
|
||||
<div className="fr-col-12 fr-col-md-4">
|
||||
<div className="fr-tile fr-tile--horizontal fr-enlarge-link">
|
||||
<div className="fr-tile__body">
|
||||
<div className="fr-tile__content">
|
||||
<h3 className="fr-tile__title">
|
||||
<Link href="/journal">
|
||||
<span
|
||||
className="fr-icon-book-2-line fr-mr-1w"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Mon journal
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="fr-tile__detail">Voir toutes mes saisies</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">Mon journal</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Voir toutes mes saisies</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href="/journal/rapports">
|
||||
<Card hover className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-100 dark:bg-blue-900 flex items-center justify-center">
|
||||
<BarChart3 className="text-blue-600 dark:text-blue-400" size={24} />
|
||||
<div className="fr-col-12 fr-col-md-4">
|
||||
<div className="fr-tile fr-tile--horizontal fr-enlarge-link">
|
||||
<div className="fr-tile__body">
|
||||
<div className="fr-tile__content">
|
||||
<h3 className="fr-tile__title">
|
||||
<Link href="/journal/rapports">
|
||||
<span
|
||||
className="fr-icon-line-chart-line fr-mr-1w"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Mes rapports
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="fr-tile__detail">Visualiser mes tendances</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">Mes rapports</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Visualiser mes tendances</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats de la semaine */}
|
||||
<div className="grid sm:grid-cols-3 gap-4 mb-8">
|
||||
<div className="fr-grid-row fr-grid-row--gutters fr-mb-4w">
|
||||
{rapportLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</Card>
|
||||
<div className="fr-col-12 fr-col-md-4" key={i}>
|
||||
<div className="fr-tile fr-tile--vertical">
|
||||
<div className="fr-tile__body">
|
||||
<div className="fr-tile__content">
|
||||
<p className="fr-tile__detail">Chargement…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Saisies cette semaine</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{rapport?.stats.total_saisies || 0}
|
||||
</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Intensité moyenne</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{rapport?.stats.intensite_moyenne?.toFixed(1) || '—'}
|
||||
</p>
|
||||
<span className="text-sm text-gray-400">/10</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Émotion dominante</p>
|
||||
{rapport?.stats.emotion_dominante ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge color={rapport.stats.emotion_dominante.couleur}>
|
||||
{rapport.stats.emotion_dominante.nom}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-400">
|
||||
({rapport.stats.emotion_dominante.count}x)
|
||||
</span>
|
||||
<div className="fr-col-12 fr-col-md-4">
|
||||
<div className="fr-tile fr-tile--vertical">
|
||||
<div className="fr-tile__body">
|
||||
<div className="fr-tile__content">
|
||||
<p className="fr-tile__detail">Saisies cette semaine</p>
|
||||
<p className="fr-h2 fr-mb-0">
|
||||
{rapport?.stats.total_saisies || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xl text-gray-300 dark:text-gray-600">—</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fr-col-12 fr-col-md-4">
|
||||
<div className="fr-tile fr-tile--vertical">
|
||||
<div className="fr-tile__body">
|
||||
<div className="fr-tile__content">
|
||||
<p className="fr-tile__detail">Intensité moyenne</p>
|
||||
<p className="fr-h2 fr-mb-0">
|
||||
{rapport?.stats.intensite_moyenne?.toFixed(1) || '—'}
|
||||
<span className="fr-text--sm"> /10</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fr-col-12 fr-col-md-4">
|
||||
<div className="fr-tile fr-tile--vertical">
|
||||
<div className="fr-tile__body">
|
||||
<div className="fr-tile__content">
|
||||
<p className="fr-tile__detail">Émotion dominante</p>
|
||||
{rapport?.stats.emotion_dominante ? (
|
||||
<p className="fr-mb-0">
|
||||
<span className="fr-badge fr-badge--info">
|
||||
{rapport.stats.emotion_dominante.nom}
|
||||
</span>
|
||||
<span className="fr-text--sm">
|
||||
{' '}
|
||||
({rapport.stats.emotion_dominante.count}x)
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="fr-h4 fr-mb-0">—</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Saisies récentes */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Saisies récentes
|
||||
</h2>
|
||||
<Link
|
||||
href="/journal"
|
||||
className="text-sm text-malachite-600 dark:text-malachite-400 hover:underline"
|
||||
>
|
||||
Voir tout →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="fr-card">
|
||||
<div className="fr-card__body">
|
||||
<div className="fr-card__content">
|
||||
<div className="fr-grid-row fr-grid-row--middle fr-mb-2w">
|
||||
<h2 className="fr-col fr-h5 fr-mb-0">Saisies récentes</h2>
|
||||
<p className="fr-col--right fr-mb-0">
|
||||
<Link href="/journal" className="fr-link">
|
||||
Voir tout
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{saisiesLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton variant="circle" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-4 w-32 mb-1" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recentSaisies.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{recentSaisies.map((saisie) => (
|
||||
<div
|
||||
key={saisie.id}
|
||||
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center text-lg shrink-0"
|
||||
style={{ backgroundColor: `${saisie.emotion?.couleur}20` }}
|
||||
{saisiesLoading ? (
|
||||
<p>Chargement…</p>
|
||||
) : recentSaisies.length > 0 ? (
|
||||
<ul className="fr-raw-list">
|
||||
{recentSaisies.map((saisie) => (
|
||||
<li
|
||||
key={saisie.id}
|
||||
className="fr-py-1w"
|
||||
style={{ borderBottom: '1px solid var(--border-default-grey)' }}
|
||||
>
|
||||
<div className="fr-grid-row fr-grid-row--middle">
|
||||
<span className="fr-mr-2w" style={{ fontSize: '1.5rem' }}>
|
||||
{saisie.emotion?.icone || '🔵'}
|
||||
</span>
|
||||
<span className="fr-col">
|
||||
<strong>{saisie.emotion?.nom}</strong>{' '}
|
||||
<span className="fr-text--sm">
|
||||
{saisie.intensite}/10
|
||||
</span>
|
||||
{saisie.note && (
|
||||
<span className="fr-text--sm fr-text-mention--grey fr-display-block">
|
||||
{saisie.note}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="fr-text--sm fr-text-mention--grey">
|
||||
{new Date(saisie.date_saisie).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="fr-callout">
|
||||
<p className="fr-callout__text">Aucune saisie pour le moment.</p>
|
||||
<Link
|
||||
className="fr-btn fr-btn--sm fr-icon-add-line fr-btn--icon-left"
|
||||
href="/journal/nouvelle-saisie"
|
||||
>
|
||||
{saisie.emotion?.icone || '🔵'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white text-sm">
|
||||
{saisie.emotion?.nom}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{saisie.intensite}/10
|
||||
</span>
|
||||
</div>
|
||||
{saisie.note && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{saisie.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
||||
{new Date(saisie.date_saisie).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
Faire ma première saisie
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 dark:text-gray-600">
|
||||
<Heart size={32} className="mx-auto mb-2 opacity-50" />
|
||||
<p>Aucune saisie pour le moment</p>
|
||||
<Link
|
||||
href="/journal/nouvelle-saisie"
|
||||
className="text-sm text-candlelight-600 hover:underline mt-2 inline-block"
|
||||
>
|
||||
Faire ma première saisie
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, ArrowRight, Check } from 'lucide-react';
|
||||
import { useCreateSaisie } from '@/hooks/useTracker';
|
||||
import EmotionPicker from '@/components/features/EmotionPicker';
|
||||
import Slider from '@/components/ui/Slider';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const STEPS = [
|
||||
@@ -49,139 +46,144 @@ export default function NouvelleSaisiePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const nextStep = STEPS[step]?.title;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Retour
|
||||
</button>
|
||||
<div className="fr-container fr-py-6w">
|
||||
<div className="fr-grid-row fr-grid-row--center">
|
||||
<div className="fr-col-12 fr-col-md-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-icon-arrow-left-line fr-btn--icon-left fr-mb-2w"
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
|
||||
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Nouvelle saisie
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-8">
|
||||
Enregistrez votre émotion en quelques étapes simples.
|
||||
</p>
|
||||
<h1>Nouvelle saisie</h1>
|
||||
<p className="fr-text--lead">
|
||||
Enregistrez votre émotion en quelques étapes simples.
|
||||
</p>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
{STEPS.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-2 flex-1">
|
||||
{/* Stepper DSFR */}
|
||||
<div className="fr-stepper">
|
||||
<h2 className="fr-stepper__title">
|
||||
{STEPS[step - 1].title}
|
||||
<span className="fr-stepper__state">
|
||||
Étape {step} sur {STEPS.length}
|
||||
</span>
|
||||
</h2>
|
||||
<div
|
||||
className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold shrink-0
|
||||
${step > s.id
|
||||
? 'bg-malachite-500 text-white'
|
||||
: step === s.id
|
||||
? 'bg-candlelight-500 text-black'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{step > s.id ? <Check size={16} /> : s.id}
|
||||
</div>
|
||||
{s.id < STEPS.length && (
|
||||
<div
|
||||
className={`flex-1 h-0.5 ${
|
||||
step > s.id ? 'bg-malachite-500' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
className="fr-stepper__steps"
|
||||
data-fr-current-step={step}
|
||||
data-fr-steps={STEPS.length}
|
||||
/>
|
||||
{nextStep && (
|
||||
<p className="fr-stepper__details">
|
||||
<span className="fr-text--bold">Étape suivante :</span> {nextStep}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Étape actuelle */}
|
||||
<Card className="mb-6">
|
||||
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{STEPS[step - 1].title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{STEPS[step - 1].description}
|
||||
</p>
|
||||
{/* Étape actuelle */}
|
||||
<div className="fr-card fr-p-3w fr-mb-3w">
|
||||
<p className="fr-text--sm fr-text-mention--grey fr-mb-3w">
|
||||
{STEPS[step - 1].description}
|
||||
</p>
|
||||
|
||||
{step === 1 && (
|
||||
<EmotionPicker value={emotionId} onChange={setEmotionId} />
|
||||
)}
|
||||
{step === 1 && (
|
||||
<EmotionPicker value={emotionId} onChange={setEmotionId} />
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<Slider
|
||||
value={intensite}
|
||||
onChange={setIntensite}
|
||||
min={1}
|
||||
max={10}
|
||||
label="Intensité de l'émotion"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Faible</span>
|
||||
<span>Modérée</span>
|
||||
<span>Forte</span>
|
||||
</div>
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<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-1w">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<div className="fr-input-group">
|
||||
<label className="fr-label" htmlFor="saisie-date">
|
||||
Date de la saisie
|
||||
</label>
|
||||
<input
|
||||
id="saisie-date"
|
||||
className="fr-input"
|
||||
type="date"
|
||||
value={dateSaisie}
|
||||
onChange={(e) => setDateSaisie(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="fr-input-group">
|
||||
<label className="fr-label" htmlFor="saisie-note">
|
||||
Note (optionnel)
|
||||
</label>
|
||||
<textarea
|
||||
id="saisie-note"
|
||||
className="fr-input"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Décrivez ce que vous ressentez, ce qui a déclenché cette émotion..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
<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, ce qui a déclenché cette émotion..."
|
||||
rows={4}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setStep(step - 1)}
|
||||
disabled={step === 1}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Précédent
|
||||
</Button>
|
||||
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={!canNext()}
|
||||
>
|
||||
Suivant
|
||||
<ArrowRight size={16} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={createSaisie.isPending}
|
||||
disabled={!emotionId}
|
||||
>
|
||||
<Check size={16} />
|
||||
Enregistrer
|
||||
</Button>
|
||||
)}
|
||||
{/* Navigation */}
|
||||
<ul className="fr-btns-group fr-btns-group--inline-md">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="fr-btn fr-btn--secondary fr-icon-arrow-left-line fr-btn--icon-left"
|
||||
onClick={() => setStep(step - 1)}
|
||||
disabled={step === 1}
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
</li>
|
||||
{step < 3 ? (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="fr-btn fr-icon-arrow-right-line fr-btn--icon-right"
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={!canNext()}
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</li>
|
||||
) : (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="fr-btn fr-icon-check-line fr-btn--icon-left"
|
||||
onClick={handleSubmit}
|
||||
disabled={!emotionId || createSaisie.isPending}
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Filter, Download } from 'lucide-react';
|
||||
import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker';
|
||||
import { useEmotions } from '@/hooks/useEmotions';
|
||||
import TrackerTimeline from '@/components/features/TrackerTimeline';
|
||||
import EditSaisieModal from '@/components/features/EditSaisieModal';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Skeleton from '@/components/ui/Skeleton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { SaisieTracker } from '@/types';
|
||||
@@ -71,107 +67,126 @@ export default function JournalPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
Mon journal
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
<div className="fr-container fr-py-6w">
|
||||
<div className="fr-grid-row fr-grid-row--middle fr-mb-4w">
|
||||
<div className="fr-col-12 fr-col-md">
|
||||
<h1 className="fr-mb-1v">Mon journal</h1>
|
||||
<p className="fr-text--lead fr-mb-0">
|
||||
Historique de vos émotions au quotidien
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter size={16} />
|
||||
Filtrer
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleExport} title="Exporter mes saisies (RGPD)">
|
||||
<Download size={16} />
|
||||
Export CSV
|
||||
</Button>
|
||||
<Link href="/journal/nouvelle-saisie">
|
||||
<Button size="sm">
|
||||
<Plus size={16} />
|
||||
Nouvelle saisie
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="fr-col-12 fr-col-md--right">
|
||||
<ul className="fr-btns-group fr-btns-group--inline-md fr-btns-group--right">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="fr-btn fr-btn--tertiary fr-btn--sm fr-icon-filter-line fr-btn--icon-left"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
Filtrer
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="fr-btn fr-btn--tertiary fr-btn--sm fr-icon-download-line fr-btn--icon-left"
|
||||
onClick={handleExport}
|
||||
title="Exporter mes saisies (RGPD)"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/journal/nouvelle-saisie"
|
||||
className="fr-btn fr-btn--sm fr-icon-add-line fr-btn--icon-left"
|
||||
>
|
||||
Nouvelle saisie
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
{showFilters && (
|
||||
<Card className="mb-6">
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Date début
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.date_debut || ''}
|
||||
onChange={(e) => setFilters({ ...filters, date_debut: e.target.value || undefined })}
|
||||
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 className="fr-card fr-p-3w fr-mb-4w">
|
||||
<div className="fr-grid-row fr-grid-row--gutters">
|
||||
<div className="fr-col-12 fr-col-md-4">
|
||||
<div className="fr-input-group">
|
||||
<label className="fr-label" htmlFor="filtre-date-debut">
|
||||
Date début
|
||||
</label>
|
||||
<input
|
||||
id="filtre-date-debut"
|
||||
className="fr-input"
|
||||
type="date"
|
||||
value={filters.date_debut || ''}
|
||||
onChange={(e) =>
|
||||
setFilters({ ...filters, date_debut: e.target.value || undefined })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Date fin
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.date_fin || ''}
|
||||
onChange={(e) => setFilters({ ...filters, date_fin: e.target.value || undefined })}
|
||||
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 className="fr-col-12 fr-col-md-4">
|
||||
<div className="fr-input-group">
|
||||
<label className="fr-label" htmlFor="filtre-date-fin">
|
||||
Date fin
|
||||
</label>
|
||||
<input
|
||||
id="filtre-date-fin"
|
||||
className="fr-input"
|
||||
type="date"
|
||||
value={filters.date_fin || ''}
|
||||
onChange={(e) =>
|
||||
setFilters({ ...filters, date_fin: e.target.value || undefined })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Émotion
|
||||
</label>
|
||||
<select
|
||||
value={filters.emotion_id || ''}
|
||||
onChange={(e) => setFilters({ ...filters, emotion_id: e.target.value ? Number(e.target.value) : undefined })}
|
||||
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 className="fr-col-12 fr-col-md-4">
|
||||
<div className="fr-select-group">
|
||||
<label className="fr-label" htmlFor="filtre-emotion">
|
||||
Émotion
|
||||
</label>
|
||||
<select
|
||||
id="filtre-emotion"
|
||||
className="fr-select"
|
||||
value={filters.emotion_id || ''}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
emotion_id: e.target.value ? Number(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Toutes</option>
|
||||
{emotions?.map((emotion) => (
|
||||
<option key={emotion.id} value={emotion.id}>
|
||||
{emotion.icone} {emotion.nom}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="fr-btns-group fr-btns-group--inline-md fr-mt-2w">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="fr-btn fr-btn--tertiary fr-btn--sm"
|
||||
onClick={() => setFilters({})}
|
||||
>
|
||||
<option value="">Toutes</option>
|
||||
{emotions?.map((emotion) => (
|
||||
<option key={emotion.id} value={emotion.id}>
|
||||
{emotion.icone} {emotion.nom}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFilters({})}
|
||||
>
|
||||
Réinitialiser
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 p-4 bg-white dark:bg-gray-900 rounded-xl">
|
||||
<Skeleton variant="circle" className="w-12 h-12" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-4 w-32 mb-2" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p>Chargement…</p>
|
||||
) : (
|
||||
<TrackerTimeline
|
||||
saisies={saisies || []}
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useRapport } from '@/hooks/useRapport';
|
||||
import EmotionChart from '@/components/features/EmotionChart';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Skeleton from '@/components/ui/Skeleton';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import { BarChart3, TrendingUp, PieChart } from 'lucide-react';
|
||||
|
||||
type Period = 'week' | 'month' | 'quarter' | 'year';
|
||||
|
||||
@@ -22,169 +18,192 @@ export default function RapportsPage() {
|
||||
const { data: rapport, isLoading } = useRapport(period);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
Mes rapports
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
<div className="fr-container fr-py-6w">
|
||||
<div className="fr-grid-row fr-grid-row--middle fr-mb-4w">
|
||||
<div className="fr-col-12 fr-col-md">
|
||||
<h1 className="fr-mb-1v">Mes rapports</h1>
|
||||
<p className="fr-text--lead fr-mb-0">
|
||||
Analysez vos tendances émotionnelles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sélecteur de période */}
|
||||
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-xl p-1">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => setPeriod(p.value)}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200
|
||||
${period === p.value
|
||||
? 'bg-white dark:bg-gray-700 shadow-sm text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="fr-col-12 fr-col-md--right">
|
||||
<fieldset className="fr-segmented fr-segmented--sm">
|
||||
<legend className="fr-segmented__legend">Période</legend>
|
||||
<div className="fr-segmented__elements">
|
||||
{PERIODS.map((p) => (
|
||||
<div className="fr-segmented__element" key={p.value}>
|
||||
<input
|
||||
type="radio"
|
||||
id={`period-${p.value}`}
|
||||
name="period"
|
||||
checked={period === p.value}
|
||||
onChange={() => setPeriod(p.value)}
|
||||
/>
|
||||
<label className="fr-label" htmlFor={`period-${p.value}`}>
|
||||
{p.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<Skeleton variant="rect" className="h-80" />
|
||||
</Card>
|
||||
</div>
|
||||
<p>Chargement…</p>
|
||||
) : rapport ? (
|
||||
<div className="space-y-6">
|
||||
<>
|
||||
{/* Stats résumées */}
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<BarChart3 size={18} className="text-candlelight-500" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Total saisies</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{rapport.stats.total_saisies}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Du {new Date(rapport.date_debut).toLocaleDateString('fr-FR')} au {new Date(rapport.date_fin).toLocaleDateString('fr-FR')}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<TrendingUp size={18} className="text-malachite-500" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Intensité moyenne</p>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}
|
||||
</p>
|
||||
<span className="text-sm text-gray-400">/10</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<PieChart size={18} className="text-blue-500" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Émotion dominante</p>
|
||||
</div>
|
||||
{rapport.stats.emotion_dominante ? (
|
||||
<div>
|
||||
<Badge color={rapport.stats.emotion_dominante.couleur}>
|
||||
{rapport.stats.emotion_dominante.nom}
|
||||
</Badge>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{rapport.stats.emotion_dominante.count} fois
|
||||
</p>
|
||||
<div className="fr-grid-row fr-grid-row--gutters fr-mb-3w">
|
||||
<div className="fr-col-12 fr-col-md-4">
|
||||
<div className="fr-tile fr-tile--vertical">
|
||||
<div className="fr-tile__body">
|
||||
<div className="fr-tile__content">
|
||||
<h3 className="fr-tile__title">
|
||||
<span
|
||||
className="fr-icon-bar-chart-box-line fr-mr-1w"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Total saisies
|
||||
</h3>
|
||||
<p className="fr-h3 fr-mb-1v">{rapport.stats.total_saisies}</p>
|
||||
<p className="fr-tile__detail">
|
||||
Du {new Date(rapport.date_debut).toLocaleDateString('fr-FR')} au{' '}
|
||||
{new Date(rapport.date_fin).toLocaleDateString('fr-FR')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xl text-gray-300 dark:text-gray-600">—</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fr-col-12 fr-col-md-4">
|
||||
<div className="fr-tile fr-tile--vertical">
|
||||
<div className="fr-tile__body">
|
||||
<div className="fr-tile__content">
|
||||
<h3 className="fr-tile__title">
|
||||
<span
|
||||
className="fr-icon-line-chart-line fr-mr-1w"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Intensité moyenne
|
||||
</h3>
|
||||
<p className="fr-h3 fr-mb-0">
|
||||
{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}
|
||||
<span className="fr-text--sm"> /10</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fr-col-12 fr-col-md-4">
|
||||
<div className="fr-tile fr-tile--vertical">
|
||||
<div className="fr-tile__body">
|
||||
<div className="fr-tile__content">
|
||||
<h3 className="fr-tile__title">
|
||||
<span
|
||||
className="fr-icon-pie-chart-2-line fr-mr-1w"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Émotion dominante
|
||||
</h3>
|
||||
{rapport.stats.emotion_dominante ? (
|
||||
<p className="fr-mb-0">
|
||||
<span className="fr-badge fr-badge--info">
|
||||
{rapport.stats.emotion_dominante.nom}
|
||||
</span>
|
||||
<span className="fr-text--sm fr-display-block fr-mt-1v">
|
||||
{rapport.stats.emotion_dominante.count} fois
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="fr-h4 fr-mb-0">—</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Répartition (Camembert) */}
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Répartition des émotions
|
||||
</h2>
|
||||
<div className="fr-card fr-p-3w fr-mb-3w">
|
||||
<h2 className="fr-h5">Répartition des émotions</h2>
|
||||
<EmotionChart data={rapport} type="repartition" />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Évolution (Lignes) */}
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Évolution dans le temps
|
||||
</h2>
|
||||
<div className="fr-card fr-p-3w fr-mb-3w">
|
||||
<h2 className="fr-h5">Évolution dans le temps</h2>
|
||||
<EmotionChart data={rapport} type="evolution" />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Intensité moyenne (Barres) */}
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Intensité moyenne par période
|
||||
</h2>
|
||||
<div className="fr-card fr-p-3w fr-mb-3w">
|
||||
<h2 className="fr-h5">Intensité moyenne par période</h2>
|
||||
<EmotionChart data={rapport} type="intensite" />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Détail répartition */}
|
||||
{rapport.repartition.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Détail par émotion
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="fr-card fr-p-3w">
|
||||
<h2 className="fr-h5">Détail par émotion</h2>
|
||||
<ul className="fr-raw-list">
|
||||
{rapport.repartition.map((item) => (
|
||||
<div key={item.nom} className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: item.couleur }}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 w-32">
|
||||
{item.nom}
|
||||
</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
<li key={item.nom} className="fr-py-1w">
|
||||
<div className="fr-grid-row fr-grid-row--middle">
|
||||
<span
|
||||
className="fr-mr-2w"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '0.75rem',
|
||||
height: '0.75rem',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: item.couleur,
|
||||
}}
|
||||
/>
|
||||
<span className="fr-col">
|
||||
<strong>{item.nom}</strong>
|
||||
</span>
|
||||
<span className="fr-text--sm fr-mr-3w">
|
||||
{item.pourcentage}%
|
||||
</span>
|
||||
<span className="fr-text--sm fr-text-mention--grey">
|
||||
Moy : {item.intensite_moyenne.toFixed(1)}/10
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="fr-mt-1v"
|
||||
style={{
|
||||
height: '0.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
backgroundColor: 'var(--background-contrast-grey)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${item.pourcentage}%`,
|
||||
backgroundColor: item.couleur,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 w-16 text-right">
|
||||
{item.pourcentage}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 w-20 text-right">
|
||||
Moy: {item.intensite_moyenne.toFixed(1)}/10
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card className="text-center py-16">
|
||||
<BarChart3 size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-lg text-gray-500 dark:text-gray-400">
|
||||
Pas de données pour cette période
|
||||
<div className="fr-callout">
|
||||
<p className="fr-callout__title">Pas de données pour cette période</p>
|
||||
<p className="fr-callout__text">
|
||||
Enregistrez des saisies pour visualiser vos tendances.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { User, Mail, Lock, Shield, Trash2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '@/lib/api';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
@@ -76,157 +73,229 @@ export default function ProfilPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-8">
|
||||
Mon profil
|
||||
</h1>
|
||||
<div className="fr-container fr-py-6w">
|
||||
<div className="fr-grid-row fr-grid-row--center">
|
||||
<div className="fr-col-12 fr-col-md-8">
|
||||
<h1>Mon profil</h1>
|
||||
|
||||
{/* Infos du profil */}
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-16 h-16 rounded-full bg-malachite-100 dark:bg-malachite-900 flex items-center justify-center">
|
||||
<User size={28} className="text-malachite-600 dark:text-malachite-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{/* Infos du profil */}
|
||||
<div className="fr-card fr-p-3w fr-mb-3w">
|
||||
<h2 className="fr-h4 fr-mb-1v">
|
||||
{user?.prenom} {user?.nom}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{user?.email}</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Membre depuis le {user?.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) : '—'}
|
||||
<p className="fr-text--sm fr-mb-1v">{user?.email}</p>
|
||||
<p className="fr-text--xs fr-text-mention--grey fr-mb-3w">
|
||||
Membre depuis le{' '}
|
||||
{user?.created_at
|
||||
? new Date(user.created_at).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
: '—'}
|
||||
</p>
|
||||
|
||||
{editing ? (
|
||||
<form onSubmit={handleUpdateProfil}>
|
||||
<div className="fr-grid-row fr-grid-row--gutters">
|
||||
<div className="fr-col-12 fr-col-md-6">
|
||||
<div className="fr-input-group">
|
||||
<label className="fr-label" htmlFor="profil-prenom">
|
||||
Prénom
|
||||
</label>
|
||||
<input
|
||||
id="profil-prenom"
|
||||
className="fr-input"
|
||||
value={form.prenom}
|
||||
onChange={(e) => setForm({ ...form, prenom: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fr-col-12 fr-col-md-6">
|
||||
<div className="fr-input-group">
|
||||
<label className="fr-label" htmlFor="profil-nom">
|
||||
Nom
|
||||
</label>
|
||||
<input
|
||||
id="profil-nom"
|
||||
className="fr-input"
|
||||
value={form.nom}
|
||||
onChange={(e) => setForm({ ...form, nom: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fr-input-group">
|
||||
<label className="fr-label" htmlFor="profil-email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="profil-email"
|
||||
className="fr-input"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<ul className="fr-btns-group fr-btns-group--inline-md">
|
||||
<li>
|
||||
<Button type="submit" loading={loading}>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button variant="secondary" type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setForm({
|
||||
nom: user?.nom || '',
|
||||
prenom: user?.prenom || '',
|
||||
email: user?.email || '',
|
||||
});
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
Modifier mes informations
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<form onSubmit={handleUpdateProfil} className="space-y-4">
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Prénom"
|
||||
value={form.prenom}
|
||||
onChange={(e) => setForm({ ...form, prenom: e.target.value })}
|
||||
icon={<User size={18} />}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Nom"
|
||||
value={form.nom}
|
||||
onChange={(e) => setForm({ ...form, nom: e.target.value })}
|
||||
icon={<User size={18} />}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
icon={<Mail size={18} />}
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" loading={loading}>
|
||||
Enregistrer
|
||||
{/* Changer mot de passe */}
|
||||
<div className="fr-card fr-p-3w fr-mb-3w">
|
||||
<h2 className="fr-h5">
|
||||
<span className="fr-icon-lock-line fr-mr-1w" aria-hidden="true" />
|
||||
Mot de passe
|
||||
</h2>
|
||||
|
||||
{changingPassword ? (
|
||||
<form onSubmit={handleChangePassword}>
|
||||
<div className="fr-password fr-input-group">
|
||||
<label className="fr-label" htmlFor="pwd-actuel">
|
||||
Mot de passe actuel
|
||||
</label>
|
||||
<input
|
||||
id="pwd-actuel"
|
||||
className="fr-input"
|
||||
type="password"
|
||||
value={passwordForm.ancien_mot_de_passe}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({ ...passwordForm, ancien_mot_de_passe: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="fr-password fr-input-group">
|
||||
<label className="fr-label" htmlFor="pwd-nouveau">
|
||||
Nouveau mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="pwd-nouveau"
|
||||
className="fr-input"
|
||||
type="password"
|
||||
value={passwordForm.password}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({ ...passwordForm, password: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="fr-password fr-input-group">
|
||||
<label className="fr-label" htmlFor="pwd-confirm">
|
||||
Confirmer le nouveau mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="pwd-confirm"
|
||||
className="fr-input"
|
||||
type="password"
|
||||
value={passwordForm.password_confirmation}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({
|
||||
...passwordForm,
|
||||
password_confirmation: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<ul className="fr-btns-group fr-btns-group--inline-md">
|
||||
<li>
|
||||
<Button type="submit" loading={loading}>
|
||||
Changer le mot de passe
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => setChangingPassword(false)}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
) : (
|
||||
<Button variant="secondary" onClick={() => setChangingPassword(true)}>
|
||||
Changer mon mot de passe
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => {
|
||||
setForm({ nom: user?.nom || '', prenom: user?.prenom || '', email: user?.email || '' });
|
||||
setEditing(true);
|
||||
}}>
|
||||
Modifier mes informations
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Changer mot de passe */}
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Lock size={20} className="text-gray-400" />
|
||||
<h3 className="font-display font-semibold text-gray-900 dark:text-white">
|
||||
Mot de passe
|
||||
</h3>
|
||||
{/* Zone dangereuse — RGPD */}
|
||||
<div className="fr-card fr-p-3w">
|
||||
<h2 className="fr-h5">
|
||||
<span className="fr-icon-warning-line fr-mr-1w" aria-hidden="true" />
|
||||
Zone dangereuse
|
||||
</h2>
|
||||
<p className="fr-text--sm">
|
||||
La suppression de votre compte est irréversible. Toutes vos données
|
||||
seront effacées conformément au RGPD.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="fr-icon-delete-line fr-btn--icon-left"
|
||||
onClick={() => setDeleteModalOpen(true)}
|
||||
>
|
||||
Supprimer mon compte
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Modal confirmation suppression */}
|
||||
<Modal
|
||||
isOpen={deleteModalOpen}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
title="Confirmer la suppression"
|
||||
size="sm"
|
||||
>
|
||||
<p>
|
||||
Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est
|
||||
irréversible.
|
||||
</p>
|
||||
<ul className="fr-btns-group fr-btns-group--inline-md">
|
||||
<li>
|
||||
<Button variant="secondary" onClick={handleDeleteAccount} loading={loading}>
|
||||
Oui, supprimer
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button variant="secondary" onClick={() => setDeleteModalOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
{changingPassword ? (
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<Input
|
||||
label="Mot de passe actuel"
|
||||
type="password"
|
||||
value={passwordForm.ancien_mot_de_passe}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, ancien_mot_de_passe: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Nouveau mot de passe"
|
||||
type="password"
|
||||
value={passwordForm.password}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, password: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Confirmer le nouveau mot de passe"
|
||||
type="password"
|
||||
value={passwordForm.password_confirmation}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, password_confirmation: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" loading={loading}>
|
||||
Changer le mot de passe
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setChangingPassword(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setChangingPassword(true)}>
|
||||
Changer mon mot de passe
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Zone dangereuse */}
|
||||
<Card className="border-red-200 dark:border-red-900">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Shield size={20} className="text-red-500" />
|
||||
<h3 className="font-display font-semibold text-red-600 dark:text-red-400">
|
||||
Zone dangereuse
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
La suppression de votre compte est irréversible. Toutes vos données seront effacées conformément au RGPD.
|
||||
</p>
|
||||
<Button variant="danger" onClick={() => setDeleteModalOpen(true)}>
|
||||
<Trash2 size={16} />
|
||||
Supprimer mon compte
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Modal confirmation suppression */}
|
||||
<Modal
|
||||
isOpen={deleteModalOpen}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
title="Confirmer la suppression"
|
||||
size="sm"
|
||||
>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="danger" onClick={handleDeleteAccount} loading={loading}>
|
||||
Oui, supprimer
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setDeleteModalOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import { Phone } from 'lucide-react';
|
||||
|
||||
export default function ContactsUrgencePage() {
|
||||
const contacts = [
|
||||
{
|
||||
@@ -19,13 +16,13 @@ export default function ContactsUrgencePage() {
|
||||
name: 'Fil Santé Jeunes',
|
||||
number: '0 800 235 236',
|
||||
description:
|
||||
'Ligne d\u2019écoute gratuite et anonyme pour les jeunes de 12 à 25 ans. Du lundi au dimanche de 9h à 23h.',
|
||||
'Ligne d’écoute gratuite et anonyme pour les jeunes de 12 à 25 ans. Du lundi au dimanche de 9h à 23h.',
|
||||
},
|
||||
{
|
||||
name: 'SAMU',
|
||||
number: '15',
|
||||
description:
|
||||
'Service d\u2019aide médicale urgente. En cas d\u2019urgence vitale, appelez immédiatement le 15 ou le 112.',
|
||||
'Service d’aide médicale urgente. En cas d’urgence vitale, appelez immédiatement le 15 ou le 112.',
|
||||
},
|
||||
{
|
||||
name: 'SOS Médecins',
|
||||
@@ -36,48 +33,51 @@ export default function ContactsUrgencePage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-12">
|
||||
<h1 className="font-display text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Contacts d'urgence
|
||||
</h1>
|
||||
<p className="text-lg text-gray-500 dark:text-gray-400 mb-8">
|
||||
Si vous ou un proche traversez une période difficile, n'hésitez pas à
|
||||
contacter l'un de ces services. Vous n'êtes pas seul(e).
|
||||
<div className="fr-container fr-py-6w">
|
||||
<h1>Contacts d'urgence</h1>
|
||||
<p className="fr-text--lead">
|
||||
Si vous ou un proche traversez une période difficile, n'hésitez pas
|
||||
à contacter l'un de ces services. Vous n'êtes pas seul(e).
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{contacts.map((contact) => (
|
||||
<Card key={contact.number}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-50 dark:bg-red-950/50 flex items-center justify-center">
|
||||
<Phone size={20} className="text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{contact.name}
|
||||
</h2>
|
||||
<a
|
||||
href={`tel:${contact.number.replace(/\s/g, '')}`}
|
||||
className="inline-block text-xl font-bold text-malachite-600 dark:text-malachite-400 mb-2 hover:underline"
|
||||
>
|
||||
{contact.number}
|
||||
</a>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{contact.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<div className="fr-callout fr-callout--brown-caramel fr-icon-information-line fr-mb-6w">
|
||||
<p className="fr-callout__title">En cas d'urgence ou de détresse</p>
|
||||
<p className="fr-callout__text">
|
||||
Appelez le <strong>3114</strong> (numéro national de prévention du
|
||||
suicide, gratuit, 24h/24) ou le <strong>15</strong> (SAMU). En cas
|
||||
d'urgence vitale, composez le <strong>112</strong> (numéro
|
||||
d'urgence européen).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="mt-8 bg-red-50 dark:bg-red-950/30 border-red-100 dark:border-red-900">
|
||||
<p className="text-sm text-red-700 dark:text-red-400 text-center leading-relaxed">
|
||||
<strong>En cas d'urgence vitale</strong>, appelez immédiatement le{' '}
|
||||
<strong>15</strong> (SAMU) ou le <strong>112</strong> (numéro d'urgence
|
||||
européen).
|
||||
</p>
|
||||
</Card>
|
||||
<div className="fr-grid-row fr-grid-row--gutters">
|
||||
{contacts.map((contact) => (
|
||||
<div className="fr-col-12 fr-col-md-6" key={contact.number}>
|
||||
<div className="fr-card">
|
||||
<div className="fr-card__body">
|
||||
<div className="fr-card__content">
|
||||
<h2 className="fr-card__title">{contact.name}</h2>
|
||||
<p className="fr-card__desc">{contact.description}</p>
|
||||
<div className="fr-card__end">
|
||||
<p className="fr-card__detail">
|
||||
<a
|
||||
className="fr-link"
|
||||
href={`tel:${contact.number.replace(/\s/g, '')}`}
|
||||
>
|
||||
<span
|
||||
className="fr-icon-phone-line fr-mr-1w"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{contact.number}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { ArrowLeft, Calendar, User } from 'lucide-react';
|
||||
import { useFeed } from '@/hooks/useFeeds';
|
||||
import Skeleton from '@/components/ui/Skeleton';
|
||||
|
||||
export default function ArticlePage() {
|
||||
const params = useParams();
|
||||
@@ -14,79 +11,93 @@ export default function ArticlePage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-12">
|
||||
<Skeleton className="h-8 w-48 mb-8" />
|
||||
<Skeleton variant="rect" className="h-64 mb-6" />
|
||||
<Skeleton className="h-10 w-3/4 mb-4" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<div className="fr-container fr-py-6w">
|
||||
<p className="fr-text--sm">Chargement de l'article…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !feed) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-12 text-center">
|
||||
<p className="text-lg text-gray-500 dark:text-gray-400 mb-4">
|
||||
Article introuvable
|
||||
</p>
|
||||
<Link
|
||||
href="/informations"
|
||||
className="text-malachite-600 dark:text-malachite-400 hover:underline"
|
||||
>
|
||||
← Retour aux informations
|
||||
<div className="fr-container fr-py-6w">
|
||||
<div className="fr-alert fr-alert--error">
|
||||
<h3 className="fr-alert__title">Article introuvable</h3>
|
||||
<p>
|
||||
L'article demandé n'existe pas ou n'est plus
|
||||
disponible.
|
||||
</p>
|
||||
</div>
|
||||
<Link className="fr-link fr-mt-2w" href="/informations">
|
||||
Retour aux informations
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-12">
|
||||
<Link
|
||||
href="/informations"
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-8 transition-colors"
|
||||
<div className="fr-container fr-py-6w">
|
||||
<nav
|
||||
role="navigation"
|
||||
className="fr-breadcrumb"
|
||||
aria-label="vous êtes ici :"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Retour aux informations
|
||||
</Link>
|
||||
|
||||
{feed.image_url && (
|
||||
<div className="relative w-full h-64 sm:h-80 rounded-2xl overflow-hidden bg-gray-100 dark:bg-gray-800 mb-8">
|
||||
<Image
|
||||
src={feed.image_url}
|
||||
alt={feed.titre}
|
||||
width={800}
|
||||
height={400}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="fr-breadcrumb__button"
|
||||
aria-expanded="false"
|
||||
aria-controls="breadcrumb-article"
|
||||
>
|
||||
Voir le fil d'Ariane
|
||||
</button>
|
||||
<div className="fr-collapse" id="breadcrumb-article">
|
||||
<ol className="fr-breadcrumb__list">
|
||||
<li>
|
||||
<Link className="fr-breadcrumb__link" href="/">
|
||||
Accueil
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link className="fr-breadcrumb__link" href="/informations">
|
||||
Informations
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a className="fr-breadcrumb__link" aria-current="page">
|
||||
{feed.titre}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<h1 className="font-display text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{feed.titre}
|
||||
</h1>
|
||||
<h1>{feed.titre}</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400 dark:text-gray-500 mb-8 pb-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<p className="fr-text--sm">
|
||||
{feed.auteur && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<User size={14} />
|
||||
<>
|
||||
<span
|
||||
className="fr-icon-user-line fr-mr-1w"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{feed.auteur.prenom} {feed.auteur.nom}
|
||||
</div>
|
||||
{' — '}
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar size={14} />
|
||||
{new Date(feed.created_at).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="fr-icon-calendar-line fr-mr-1w"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{new Date(feed.created_at).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<article
|
||||
className="prose prose-lg dark:prose-invert max-w-none prose-headings:font-display prose-a:text-malachite-600 dark:prose-a:text-malachite-400"
|
||||
<hr className="fr-mt-2w fr-mb-4w" />
|
||||
|
||||
<div
|
||||
className="fr-col-12 fr-col-md-10"
|
||||
dangerouslySetInnerHTML={{ __html: feed.contenu }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,86 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { BookOpen, Calendar } from 'lucide-react';
|
||||
import { useFeeds } from '@/hooks/useFeeds';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Skeleton from '@/components/ui/Skeleton';
|
||||
|
||||
export default function InformationsPage() {
|
||||
const { data: feeds, isLoading } = useFeeds();
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="font-display text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Informations & Ressources
|
||||
</h1>
|
||||
<p className="text-lg text-gray-500 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Des contenus validés sur la santé mentale, la gestion du stress
|
||||
et le bien-être émotionnel.
|
||||
</p>
|
||||
</div>
|
||||
<div className="fr-container fr-py-6w">
|
||||
<h1>Informations & Ressources</h1>
|
||||
<p className="fr-text--lead">
|
||||
Des contenus validés sur la santé mentale, la gestion du stress et le
|
||||
bien-être émotionnel.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<Skeleton variant="rect" className="h-48 mb-4" />
|
||||
<Skeleton className="h-6 w-3/4 mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-1" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<p className="fr-text--sm">Chargement des articles…</p>
|
||||
) : feeds && feeds.length > 0 ? (
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="fr-grid-row fr-grid-row--gutters">
|
||||
{feeds.map((feed) => (
|
||||
<Link key={feed.id} href={`/informations/${feed.slug}`}>
|
||||
<Card hover className="h-full">
|
||||
{feed.image_url && (
|
||||
<div className="relative w-full h-48 mb-4 rounded-xl overflow-hidden bg-gray-100 dark:bg-gray-800">
|
||||
<Image
|
||||
src={feed.image_url}
|
||||
alt={feed.titre}
|
||||
width={800}
|
||||
height={400}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="fr-col-12 fr-col-md-6" key={feed.id}>
|
||||
<div className="fr-card fr-enlarge-link">
|
||||
<div className="fr-card__body">
|
||||
<div className="fr-card__content">
|
||||
<h2 className="fr-card__title">
|
||||
<Link href={`/informations/${feed.slug}`}>
|
||||
{feed.titre}
|
||||
</Link>
|
||||
</h2>
|
||||
<p className="fr-card__desc">
|
||||
{(feed.contenu ?? '')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.substring(0, 200) + '…'}
|
||||
</p>
|
||||
<div className="fr-card__end">
|
||||
<p className="fr-card__detail">
|
||||
<span
|
||||
className="fr-icon-calendar-line fr-mr-1w"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{new Date(feed.created_at).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen size={16} className="text-malachite-500" />
|
||||
<span className="text-xs font-medium text-malachite-600 dark:text-malachite-400 uppercase tracking-wide">
|
||||
Article
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-2 line-clamp-2">
|
||||
{feed.titre}
|
||||
</h2>
|
||||
<div
|
||||
className="text-gray-500 dark:text-gray-400 text-sm line-clamp-3 mb-4"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: (feed.contenu ?? '').replace(/<[^>]*>/g, '').substring(0, 200) + '...',
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
<Calendar size={14} />
|
||||
{new Date(feed.created_at).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-gray-400 dark:text-gray-600">
|
||||
<BookOpen size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg">Aucun article disponible pour le moment</p>
|
||||
<div className="fr-callout">
|
||||
<p className="fr-callout__text">
|
||||
Aucun article disponible pour le moment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,73 +1,47 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
export default function MentionsLegalesPage() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-12">
|
||||
<h1 className="font-display text-4xl font-bold text-gray-900 dark:text-white mb-8">
|
||||
Mentions légales
|
||||
</h1>
|
||||
<div className="fr-container fr-py-6w">
|
||||
<h1>Mentions légales</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Éditeur de l'application
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
CESIZen est une application développée dans le cadre d'un projet
|
||||
pédagogique au sein de{' '}
|
||||
<strong>CESI École d'Ingénieurs</strong>. Cette application est
|
||||
réalisée à but éducatif et n'a pas de vocation commerciale.
|
||||
</p>
|
||||
</Card>
|
||||
<h2>Éditeur de l'application</h2>
|
||||
<p>
|
||||
CESIZen est une application développée dans le cadre d'un projet
|
||||
pédagogique au sein de <strong>CESI École d'Ingénieurs</strong>.
|
||||
Cette application est réalisée à but éducatif et n'a pas de vocation
|
||||
commerciale.
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Hébergement
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
L'application est hébergée dans le cadre de l'infrastructure
|
||||
pédagogique de CESI. Les données sont stockées sur des serveurs
|
||||
sécurisés situés en France.
|
||||
</p>
|
||||
</Card>
|
||||
<h2>Hébergement</h2>
|
||||
<p>
|
||||
L'application est hébergée dans le cadre de l'infrastructure
|
||||
pédagogique de CESI. Les données sont stockées sur des serveurs
|
||||
sécurisés situés en France.
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Propriété intellectuelle
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
L'ensemble des contenus présents sur CESIZen (textes, images,
|
||||
graphismes, logo, icônes) sont la propriété exclusive de leurs auteurs
|
||||
respectifs et sont protégés par les lois françaises et internationales
|
||||
relatives à la propriété intellectuelle.
|
||||
</p>
|
||||
</Card>
|
||||
<h2>Propriété intellectuelle</h2>
|
||||
<p>
|
||||
L'ensemble des contenus présents sur CESIZen (textes, images,
|
||||
graphismes, logo, icônes) sont la propriété exclusive de leurs auteurs
|
||||
respectifs et sont protégés par les lois françaises et internationales
|
||||
relatives à la propriété intellectuelle.
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Responsabilité
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
CESIZen est un outil de suivi du bien-être et ne se substitue en aucun
|
||||
cas à un avis médical professionnel. En cas de détresse psychologique,
|
||||
veuillez contacter les services d'urgence ou un professionnel de
|
||||
santé.
|
||||
</p>
|
||||
</Card>
|
||||
<h2>Responsabilité</h2>
|
||||
<p>
|
||||
CESIZen est un outil de suivi du bien-être et ne se substitue en aucun
|
||||
cas à un avis médical professionnel. En cas de détresse psychologique,
|
||||
veuillez contacter les services d'urgence ou un professionnel de
|
||||
santé.
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Contact
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
Pour toute question relative aux mentions légales, vous pouvez nous
|
||||
contacter via l'adresse suivante :{' '}
|
||||
<span className="text-malachite-600 dark:text-malachite-400">
|
||||
contact@cesizen.fr
|
||||
</span>
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Pour toute question relative aux mentions légales, vous pouvez nous
|
||||
contacter via l'adresse suivante :{' '}
|
||||
<a className="fr-link" href="mailto:contact@cesizen.fr">
|
||||
contact@cesizen.fr
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,105 +1,73 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
export default function PolitiqueConfidentialitePage() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-12">
|
||||
<h1 className="font-display text-4xl font-bold text-gray-900 dark:text-white mb-8">
|
||||
Politique de confidentialité
|
||||
</h1>
|
||||
<div className="fr-container fr-py-6w">
|
||||
<h1>Politique de confidentialité</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Conformité RGPD
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
CESIZen s'engage à respecter le Règlement Général sur la Protection
|
||||
des Données (RGPD) et la loi Informatique et Libertés. La protection
|
||||
de vos données personnelles est une priorité.
|
||||
</p>
|
||||
</Card>
|
||||
<h2>Conformité RGPD</h2>
|
||||
<p>
|
||||
CESIZen s'engage à respecter le Règlement Général sur la Protection
|
||||
des Données (RGPD) et la loi Informatique et Libertés. La protection de
|
||||
vos données personnelles est une priorité.
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Données collectées
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mb-3">
|
||||
Dans le cadre de l'utilisation de CESIZen, les données suivantes
|
||||
sont collectées :
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<li>Adresse e-mail</li>
|
||||
<li>Nom et prénom</li>
|
||||
<li>Données relatives aux émotions enregistrées</li>
|
||||
</ul>
|
||||
</Card>
|
||||
<h2>Données collectées</h2>
|
||||
<p>
|
||||
Dans le cadre de l'utilisation de CESIZen, les données suivantes
|
||||
sont collectées :
|
||||
</p>
|
||||
<ul>
|
||||
<li>Adresse e-mail</li>
|
||||
<li>Nom et prénom</li>
|
||||
<li>Données relatives aux émotions enregistrées</li>
|
||||
</ul>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Finalité du traitement
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
Ces données sont collectées uniquement dans le but de fournir un suivi
|
||||
personnalisé du bien-être émotionnel. Elles ne sont ni vendues, ni
|
||||
partagées avec des tiers à des fins commerciales.
|
||||
</p>
|
||||
</Card>
|
||||
<h2>Finalité du traitement</h2>
|
||||
<p>
|
||||
Ces données sont collectées uniquement dans le but de fournir un suivi
|
||||
personnalisé du bien-être émotionnel. Elles ne sont ni vendues, ni
|
||||
partagées avec des tiers à des fins commerciales.
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Durée de conservation
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
Les données personnelles sont conservées pendant toute la durée
|
||||
d'utilisation du compte. En cas de suppression du compte, les
|
||||
données sont effacées dans un délai de 30 jours.
|
||||
</p>
|
||||
</Card>
|
||||
<h2>Durée de conservation</h2>
|
||||
<p>
|
||||
Les données personnelles sont conservées pendant toute la durée
|
||||
d'utilisation du compte. En cas de suppression du compte, les
|
||||
données sont effacées dans un délai de 30 jours.
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Sécurité des données
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
Les données sont chiffrées en transit (HTTPS) et au repos. Les mots de
|
||||
passe sont hachés avec des algorithmes sécurisés. L'accès aux
|
||||
données est strictement limité aux personnes habilitées.
|
||||
</p>
|
||||
</Card>
|
||||
<h2>Sécurité des données</h2>
|
||||
<p>
|
||||
Les données sont chiffrées en transit (HTTPS) et au repos. Les mots de
|
||||
passe sont hachés avec des algorithmes sécurisés. L'accès aux
|
||||
données est strictement limité aux personnes habilitées.
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Vos droits
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mb-3">
|
||||
Conformément au RGPD, vous disposez des droits suivants :
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<li>
|
||||
<strong>Droit d'accès :</strong> obtenir une copie de vos
|
||||
données personnelles
|
||||
</li>
|
||||
<li>
|
||||
<strong>Droit de rectification :</strong> corriger des données
|
||||
inexactes ou incomplètes
|
||||
</li>
|
||||
<li>
|
||||
<strong>Droit de suppression :</strong> demander l'effacement
|
||||
de vos données personnelles
|
||||
</li>
|
||||
<li>
|
||||
<strong>Droit à la portabilité :</strong> récupérer vos données dans
|
||||
un format structuré
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mt-3">
|
||||
Pour exercer ces droits, contactez-nous à l'adresse :{' '}
|
||||
<span className="text-malachite-600 dark:text-malachite-400">
|
||||
contact@cesizen.fr
|
||||
</span>
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
<h2>Vos droits</h2>
|
||||
<p>Conformément au RGPD, vous disposez des droits suivants :</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Droit d'accès :</strong> obtenir une copie de vos données
|
||||
personnelles
|
||||
</li>
|
||||
<li>
|
||||
<strong>Droit de rectification :</strong> corriger des données
|
||||
inexactes ou incomplètes
|
||||
</li>
|
||||
<li>
|
||||
<strong>Droit de suppression :</strong> demander l'effacement de
|
||||
vos données personnelles
|
||||
</li>
|
||||
<li>
|
||||
<strong>Droit à la portabilité :</strong> récupérer vos données dans
|
||||
un format structuré
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Pour exercer ces droits, contactez-nous à l'adresse :{' '}
|
||||
<a className="fr-link" href="mailto:contact@cesizen.fr">
|
||||
contact@cesizen.fr
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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