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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Azmog
2026-06-18 23:34:01 +02:00
parent 9b5be855fc
commit f6e150ddae
31 changed files with 2205 additions and 2069 deletions
@@ -1,13 +1,10 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Plus, Edit2, Trash2 } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Feed } from '@/types'; import type { Feed } from '@/types';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -90,92 +87,107 @@ export default function AdminContenusPage() {
return ( return (
<div> <div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="fr-grid-row fr-grid-row--middle fr-grid-row--gutters fr-mb-4w">
<div> <div className="fr-col">
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1"> <h1 className="fr-mb-1v">Contenus</h1>
Contenus <p className="fr-text--sm fr-mb-0">
</h1>
<p className="text-gray-500 dark:text-gray-400">
Gérez les articles et ressources de la plateforme Gérez les articles et ressources de la plateforme
</p> </p>
</div> </div>
<Button onClick={openCreate} size="sm"> <div className="fr-col-auto">
<Plus size={16} /> <button
type="button"
className="fr-btn fr-btn--icon-left fr-icon-add-line"
onClick={openCreate}
>
Nouvel article Nouvel article
</Button> </button>
</div>
</div> </div>
{loading ? ( {loading ? (
<div className="space-y-4"> <p>Chargement…</p>
{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>
) : feeds.length === 0 ? ( ) : feeds.length === 0 ? (
<Card className="text-center py-12"> <div className="fr-callout">
<p className="text-gray-400 dark:text-gray-600 mb-4">Aucun article</p> <p className="fr-callout__text">Aucun article pour le moment.</p>
<Button onClick={openCreate} size="sm">Créer le premier article</Button> <button type="button" className="fr-btn fr-btn--icon-left fr-icon-add-line" onClick={openCreate}>
</Card> Créer le premier article
</button>
</div>
) : ( ) : (
<div className="space-y-4"> <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) => ( {feeds.map((feed) => (
<Card key={feed.id} className="flex flex-col sm:flex-row sm:items-center gap-4"> <tr key={feed.id}>
<div className="flex-1 min-w-0"> <td>{feed.titre}</td>
<div className="flex items-center gap-2 mb-1"> <td>/{feed.slug}</td>
<h3 className="font-semibold text-gray-900 dark:text-white truncate"> <td>{feed.ordre}</td>
{feed.titre} <td>
</h3> <span className={`fr-badge fr-badge--sm ${feed.est_publie ? 'fr-badge--success' : 'fr-badge--info'}`}>
<Badge color={feed.est_publie ? '#06c656' : '#9ca3af'}>
{feed.est_publie ? 'Publié' : 'Brouillon'} {feed.est_publie ? 'Publié' : 'Brouillon'}
</Badge> </span>
</div> </td>
<p className="text-sm text-gray-500 dark:text-gray-400"> <td>
/{feed.slug} · Ordre: {feed.ordre} <ul className="fr-btns-group fr-btns-group--inline fr-btns-group--sm">
</p> <li>
</div>
<div className="flex items-center gap-2 shrink-0">
<button <button
type="button"
onClick={() => openEdit(feed)} onClick={() => openEdit(feed)}
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 fr-icon-edit-line"
title="Modifier" title="Modifier"
> >
<Edit2 size={16} className="text-gray-500" /> Modifier
</button> </button>
</li>
<li>
<button <button
type="button"
onClick={() => handleDelete(feed.id)} onClick={() => handleDelete(feed.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" title="Supprimer"
> >
<Trash2 size={16} className="text-red-500" /> Supprimer
</button> </button>
</div> </li>
</Card> </ul>
</td>
</tr>
))} ))}
</tbody>
</table>
</div> </div>
)} )}
{/* Modal création/édition */} {/* Modal création/édition */}
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title={editingFeed ? 'Modifier l\'article' : 'Nouvel article'} size="lg"> <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 <Input
label="Titre" label="Titre"
value={form.titre} value={form.titre}
onChange={(e) => setForm({ ...form, titre: e.target.value })} onChange={(e) => setForm({ ...form, titre: e.target.value })}
required required
/> />
<div> <div className="fr-input-group">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <label className="fr-label" htmlFor="feed-contenu">
Contenu (HTML) Contenu (HTML)
</label> </label>
<textarea <textarea
id="feed-contenu"
className="fr-input"
value={form.contenu} value={form.contenu}
onChange={(e) => setForm({ ...form, contenu: e.target.value })} onChange={(e) => setForm({ ...form, contenu: e.target.value })}
rows={10} rows={10}
required 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> </div>
<Input <Input
@@ -183,40 +195,47 @@ export default function AdminContenusPage() {
value={form.image_url} value={form.image_url}
onChange={(e) => setForm({ ...form, image_url: e.target.value })} onChange={(e) => setForm({ ...form, image_url: e.target.value })}
/> />
<div className="grid grid-cols-2 gap-4"> <div className="fr-grid-row fr-grid-row--gutters">
<div> <div className="fr-col-6">
<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-ordre">
Ordre d&apos;affichage Ordre d&apos;affichage
</label> </label>
<input <input
id="feed-ordre"
type="number" type="number"
className="fr-input"
value={form.ordre} value={form.ordre}
onChange={(e) => setForm({ ...form, ordre: parseInt(e.target.value) || 0 })} 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> </div>
<div className="flex items-end pb-1"> </div>
<label className="flex items-center gap-2 cursor-pointer"> <div className="fr-col-6">
<div className="fr-checkbox-group">
<input <input
type="checkbox" type="checkbox"
id="feed-publie"
checked={form.est_publie} checked={form.est_publie}
onChange={(e) => setForm({ ...form, est_publie: e.target.checked })} 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é Publié
</span>
</label> </label>
</div> </div>
</div> </div>
<div className="flex gap-3 pt-2"> </div>
<ul className="fr-btns-group fr-btns-group--inline fr-mt-2w">
<li>
<Button type="submit" loading={formLoading}> <Button type="submit" loading={formLoading}>
{editingFeed ? 'Mettre à jour' : 'Créer'} {editingFeed ? 'Mettre à jour' : 'Créer'}
</Button> </Button>
</li>
<li>
<Button variant="ghost" onClick={() => setModalOpen(false)}> <Button variant="ghost" onClick={() => setModalOpen(false)}>
Annuler Annuler
</Button> </Button>
</div> </li>
</ul>
</form> </form>
</Modal> </Modal>
</div> </div>
@@ -2,8 +2,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Users, FileText, Heart } from 'lucide-react';
import Card from '@/components/ui/Card';
import api from '@/lib/api'; import api from '@/lib/api';
interface AdminStats { interface AdminStats {
@@ -41,86 +39,63 @@ export default function AdminDashboardPage() {
}, []); }, []);
const statCards = [ const statCards = [
{ { label: 'Utilisateurs', value: stats?.total_utilisateurs || 0, icon: 'fr-icon-user-line' },
label: 'Utilisateurs', { label: 'Articles', value: stats?.total_feeds || 0, icon: 'fr-icon-article-line' },
value: stats?.total_utilisateurs || 0, { label: 'Émotions', value: stats?.total_emotions || 0, icon: 'fr-icon-heart-line' },
icon: <Users size={24} />, ];
color: 'text-blue-500',
bg: 'bg-blue-50 dark:bg-blue-950/30', 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' },
label: 'Articles', { href: '/admin/emotions', icon: 'fr-icon-heart-line', label: 'Gérer les émotions' },
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',
},
]; ];
return ( return (
<div> <div>
<div className="mb-8"> <h1>Administration</h1>
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-2"> <p className="fr-text--lead fr-mb-4w">
Administration
</h1>
<p className="text-gray-500 dark:text-gray-400">
Vue d&apos;ensemble de la plateforme CESIZen Vue d&apos;ensemble de la plateforme CESIZen
</p> </p>
</div>
<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) => ( {statCards.map((card) => (
<Card key={card.label}> <div className="fr-col-12 fr-col-md-4" key={card.label}>
<div className="flex items-center gap-4"> <div className="fr-tile fr-tile--horizontal">
<div className={`w-14 h-14 rounded-xl ${card.bg} flex items-center justify-center ${card.color}`}> <div className="fr-tile__body">
{card.icon} <div className="fr-tile__content">
</div> <h2 className="fr-tile__title">
<div> <span className={`${card.icon} fr-mr-1w`} aria-hidden="true" />
<p className="text-sm text-gray-500 dark:text-gray-400">{card.label}</p> {card.label}
<p className="text-3xl font-bold text-gray-900 dark:text-white"> </h2>
{loading ? '...' : card.value} <p className="fr-tile__detail fr-text--bold fr-display--xs">
{loading ? '…' : card.value}
</p> </p>
</div> </div>
</div> </div>
</Card> </div>
</div>
))} ))}
</div> </div>
<Card> <h2>Actions rapides</h2>
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4"> <div className="fr-grid-row fr-grid-row--gutters">
Actions rapides {quickActions.map((action) => (
</h2> <div className="fr-col-12 fr-col-md-4" key={action.href}>
<div className="grid sm:grid-cols-3 gap-4"> <div className="fr-tile fr-tile--horizontal fr-enlarge-link">
<Link <div className="fr-tile__body">
href="/admin/utilisateurs" <div className="fr-tile__content">
className="flex items-center gap-3 p-4 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" <h3 className="fr-tile__title">
> <Link href={action.href}>
<Users size={20} className="text-blue-500" /> <span className={`${action.icon} fr-mr-1w`} aria-hidden="true" />
<span className="font-medium text-gray-700 dark:text-gray-300">Gérer les utilisateurs</span> {action.label}
</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> </Link>
</h3>
</div>
</div>
</div>
</div>
))}
</div> </div>
</Card>
</div> </div>
); );
} }
+193 -121
View File
@@ -1,13 +1,10 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Plus, Edit2, Trash2 } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Emotion } from '@/types'; import type { Emotion } from '@/types';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -100,148 +97,113 @@ 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 ( return (
<div> <div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="fr-grid-row fr-grid-row--middle fr-grid-row--gutters fr-mb-4w">
<div> <div className="fr-col">
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1"> <h1 className="fr-mb-1v">Émotions</h1>
Émotions <p className="fr-text--sm fr-mb-0">
</h1> Gérez le catalogue des émotions disponibles (2 niveaux)
<p className="text-gray-500 dark:text-gray-400">
Gérez le catalogue des émotions disponibles
</p> </p>
</div> </div>
<Button onClick={() => openCreate()} size="sm"> <div className="fr-col-auto">
<Plus size={16} /> <button
type="button"
className="fr-btn fr-btn--icon-left fr-icon-add-line"
onClick={() => openCreate()}
>
Nouvelle émotion Nouvelle émotion
</Button> </button>
</div>
</div> </div>
{loading ? ( {loading ? (
<div className="space-y-4"> <p>Chargement…</p>
{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>
) : emotions.length === 0 ? ( ) : emotions.length === 0 ? (
<Card className="text-center py-12"> <div className="fr-callout">
<p className="text-gray-400 dark:text-gray-600 mb-4">Aucune émotion</p> <p className="fr-callout__text">Aucune émotion pour le moment.</p>
<Button onClick={() => openCreate()} size="sm">Créer la première émotion</Button> <button type="button" className="fr-btn fr-btn--icon-left fr-icon-add-line" onClick={() => openCreate()}>
</Card> Créer la première émotion
</button>
</div>
) : ( ) : (
<div className="space-y-4"> <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) => ( {parentEmotions.map((emotion) => (
<Card key={emotion.id}> <FragmentRows
<div className="flex items-center gap-4 mb-3"> key={emotion.id}
<div emotion={emotion}
className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl shrink-0" colorSwatch={colorSwatch}
style={{ backgroundColor: `${emotion.couleur}20` }} onAddChild={openCreate}
> onEdit={openEdit}
{emotion.icone || '🔵'} onDelete={handleDelete}
</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>
))} ))}
</tbody>
</table>
</div> </div>
)} )}
{/* Modal création/édition */} {/* Modal création/édition */}
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title={editingEmotion ? 'Modifier l\'émotion' : 'Nouvelle émotion'}> <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 <Input
label="Nom" label="Nom"
value={form.nom} value={form.nom}
onChange={(e) => setForm({ ...form, nom: e.target.value })} onChange={(e) => setForm({ ...form, nom: e.target.value })}
required required
/> />
<div className="grid grid-cols-2 gap-4"> <div className="fr-grid-row fr-grid-row--gutters">
<div> <div className="fr-col-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <div className="fr-input-group">
Couleur <label className="fr-label" htmlFor="emotion-couleur">Couleur</label>
</label> <div className="fr-grid-row fr-grid-row--gutters fr-grid-row--bottom">
<div className="flex items-center gap-3"> <div className="fr-col-auto">
<input <input
id="emotion-couleur"
type="color" type="color"
value={form.couleur} value={form.couleur}
onChange={(e) => setForm({ ...form, couleur: e.target.value })} 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" style={{ width: '2.5rem', height: '2.5rem', border: 'none', padding: 0, cursor: 'pointer' }}
/> />
</div>
<div className="fr-col">
<input <input
type="text" type="text"
className="fr-input"
value={form.couleur} value={form.couleur}
onChange={(e) => setForm({ ...form, couleur: e.target.value })} 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> </div>
</div> </div>
</div>
</div>
<div className="fr-col-6">
<Input <Input
label="Icône (emoji)" label="Icône (emoji)"
value={form.icone} value={form.icone}
@@ -249,44 +211,154 @@ export default function AdminEmotionsPage() {
placeholder="😊" placeholder="😊"
/> />
</div> </div>
</div>
{form.niveau === 2 && ( {form.niveau === 2 && (
<div> <div className="fr-select-group">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <label className="fr-label" htmlFor="emotion-parent">Émotion parente</label>
Émotion parente
</label>
<select <select
id="emotion-parent"
className="fr-select"
value={form.parent_id || ''} value={form.parent_id || ''}
onChange={(e) => setForm({ ...form, parent_id: Number(e.target.value) || null })} 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) => ( {parentEmotions.map((e) => (
<option key={e.id} value={e.id}>{e.icone} {e.nom}</option> <option key={e.id} value={e.id}>{e.icone} {e.nom}</option>
))} ))}
</select> </select>
</div> </div>
)} )}
<label className="flex items-center gap-2 cursor-pointer"> <div className="fr-checkbox-group">
<input <input
type="checkbox" type="checkbox"
id="emotion-actif"
checked={form.est_actif} checked={form.est_actif}
onChange={(e) => setForm({ ...form, est_actif: e.target.checked })} 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 Active
</span>
</label> </label>
<div className="flex gap-3 pt-2"> </div>
<ul className="fr-btns-group fr-btns-group--inline fr-mt-2w">
<li>
<Button type="submit" loading={formLoading}> <Button type="submit" loading={formLoading}>
{editingEmotion ? 'Mettre à jour' : 'Créer'} {editingEmotion ? 'Mettre à jour' : 'Créer'}
</Button> </Button>
</li>
<li>
<Button variant="ghost" onClick={() => setModalOpen(false)}> <Button variant="ghost" onClick={() => setModalOpen(false)}>
Annuler Annuler
</Button> </Button>
</div> </li>
</ul>
</form> </form>
</Modal> </Modal>
</div> </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'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Search, UserCheck, UserX, Trash2, Plus } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Utilisateur } from '@/types'; import type { Utilisateur } from '@/types';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -76,111 +73,111 @@ export default function AdminUtilisateursPage() {
`${u.prenom} ${u.nom} ${u.email}`.toLowerCase().includes(search.toLowerCase()) `${u.prenom} ${u.nom} ${u.email}`.toLowerCase().includes(search.toLowerCase())
); );
const roleColor = (nom: string) => { const roleBadgeClass = (nom?: string) => {
switch (nom) { switch (nom) {
case 'administrateur': return '#ef4444'; case 'administrateur': return 'fr-badge fr-badge--error';
case 'membre': return '#06c656'; case 'membre': return 'fr-badge fr-badge--success';
default: return '#9ca3af'; default: return 'fr-badge';
} }
}; };
return ( return (
<div> <div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="fr-grid-row fr-grid-row--middle fr-grid-row--gutters fr-mb-4w">
<div> <div className="fr-col">
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1"> <h1 className="fr-mb-1v">Utilisateurs</h1>
Utilisateurs <p className="fr-text--sm fr-mb-0">
</h1>
<p className="text-gray-500 dark:text-gray-400">
{utilisateurs.length} utilisateur{utilisateurs.length > 1 ? 's' : ''} enregistré{utilisateurs.length > 1 ? 's' : ''} {utilisateurs.length} utilisateur{utilisateurs.length > 1 ? 's' : ''} enregistré{utilisateurs.length > 1 ? 's' : ''}
</p> </p>
</div> </div>
<Button onClick={() => setCreateModalOpen(true)} size="sm"> <div className="fr-col-auto">
<Plus size={16} /> <button
type="button"
className="fr-btn fr-btn--icon-left fr-icon-add-line"
onClick={() => setCreateModalOpen(true)}
>
Nouvel utilisateur Nouvel utilisateur
</Button> </button>
</div>
</div> </div>
{/* Recherche */} {/* Recherche */}
<div className="mb-6"> <div className="fr-search-bar fr-mb-4w" role="search">
<Input <label className="fr-label" htmlFor="recherche-utilisateur">
placeholder="Rechercher un utilisateur..." Rechercher un utilisateur
icon={<Search size={18} />} </label>
<input
className="fr-input"
id="recherche-utilisateur"
type="search"
placeholder="Rechercher un utilisateur…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<button type="button" className="fr-btn" title="Rechercher">
Rechercher
</button>
</div> </div>
{/* Table */} {/* Table */}
<Card className="overflow-hidden p-0"> <div className="fr-table fr-table--bordered">
<div className="overflow-x-auto"> <table>
<table className="w-full">
<thead> <thead>
<tr className="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700"> <tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Utilisateur</th> <th scope="col">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 scope="col">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 scope="col">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 scope="col">Statut</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Actions</th> <th scope="col">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800"> <tbody>
{loading ? ( {loading ? (
Array.from({ length: 5 }).map((_, i) => ( <tr>
<tr key={i}> <td colSpan={5}>Chargement…</td>
<td colSpan={5} className="px-6 py-4">
<div className="h-4 bg-gray-100 dark:bg-gray-800 rounded animate-pulse" />
</td>
</tr> </tr>
))
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="px-6 py-8 text-center text-gray-400"> <td colSpan={5}>Aucun utilisateur trouvé</td>
Aucun utilisateur trouvé
</td>
</tr> </tr>
) : ( ) : (
filtered.map((user) => ( filtered.map((user) => (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"> <tr key={user.id}>
<td className="px-6 py-4"> <td>{user.prenom} {user.nom}</td>
<span className="font-medium text-gray-900 dark:text-white"> <td>{user.email}</td>
{user.prenom} {user.nom} <td>
<span className={roleBadgeClass(user.role?.nom)}>
{user.role?.nom || 'N/A'}
</span> </span>
</td> </td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400"> <td>
{user.email} <span className={`fr-badge fr-badge--sm ${user.est_actif ? 'fr-badge--success' : 'fr-badge--error'}`}>
</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'} {user.est_actif ? 'Actif' : 'Inactif'}
</span> </span>
</td> </td>
<td className="px-6 py-4"> <td>
<div className="flex items-center justify-end gap-1"> <ul className="fr-btns-group fr-btns-group--inline fr-btns-group--sm">
<li>
<button <button
type="button"
onClick={() => handleToggleActive(user.id)} 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'} title={user.est_actif ? 'Désactiver' : 'Activer'}
> >
{user.est_actif {user.est_actif ? 'Désactiver' : 'Activer'}
? <UserX size={16} className="text-red-500" />
: <UserCheck size={16} className="text-malachite-500" />
}
</button> </button>
</li>
<li>
<button <button
type="button"
onClick={() => handleDelete(user.id)} 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" title="Supprimer"
> >
<Trash2 size={16} className="text-red-500" /> Supprimer
</button> </button>
</div> </li>
</ul>
</td> </td>
</tr> </tr>
)) ))
@@ -188,33 +185,41 @@ export default function AdminUtilisateursPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</Card>
{/* Modal création */} {/* Modal création */}
<Modal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} title="Nouvel utilisateur"> <Modal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} title="Nouvel utilisateur">
<form onSubmit={handleCreate} className="space-y-4"> <form onSubmit={handleCreate} className="fr-mt-2w">
<div className="grid grid-cols-2 gap-4"> <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 /> <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 /> <Input label="Nom" value={newUser.nom} onChange={(e) => setNewUser({ ...newUser, nom: e.target.value })} required />
</div> </div>
</div>
<Input label="Email" type="email" value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} required /> <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="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 /> <Input label="Confirmer" type="password" value={newUser.password_confirmation} onChange={(e) => setNewUser({ ...newUser, password_confirmation: e.target.value })} required />
<div> <div className="fr-select-group">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Rôle</label> <label className="fr-label" htmlFor="role-select">Rôle</label>
<select <select
id="role-select"
className="fr-select"
value={newUser.role_id} value={newUser.role_id}
onChange={(e) => setNewUser({ ...newUser, role_id: Number(e.target.value) })} 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={2}>Membre</option>
<option value={3}>Administrateur</option> <option value={3}>Administrateur</option>
</select> </select>
</div> </div>
<div className="flex gap-3 pt-2"> <ul className="fr-btns-group fr-btns-group--inline fr-mt-2w">
<li>
<Button type="submit" loading={formLoading}>Créer</Button> <Button type="submit" loading={formLoading}>Créer</Button>
</li>
<li>
<Button variant="ghost" onClick={() => setCreateModalOpen(false)}>Annuler</Button> <Button variant="ghost" onClick={() => setCreateModalOpen(false)}>Annuler</Button>
</div> </li>
</ul>
</form> </form>
</Modal> </Modal>
</div> </div>
+7 -5
View File
@@ -7,16 +7,18 @@ export default function AdminLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="min-h-screen flex flex-col"> <>
<Header /> <Header />
<div className="flex flex-1"> <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 /> <Sidebar />
<main className="flex-1 bg-gray-50 dark:bg-gray-950">
<div className="p-6 lg:p-8">
{children}
</div> </div>
<main className="fr-col-12 fr-col-md-9 fr-col-lg-10" id="contenu">
{children}
</main> </main>
</div> </div>
</div> </div>
</>
); );
} }
+39 -55
View File
@@ -3,7 +3,6 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -14,10 +13,12 @@ export default function ConnexionPage() {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setErrorMessage('');
try { try {
await login(email, password); await login(email, password);
@@ -26,6 +27,7 @@ export default function ConnexionPage() {
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as import('axios').AxiosError<{ message?: string }>; const axiosError = error as import('axios').AxiosError<{ message?: string }>;
const message = axiosError.response?.data?.message || 'Identifiants incorrects'; const message = axiosError.response?.data?.message || 'Identifiants incorrects';
setErrorMessage(message);
toast.error(message); toast.error(message);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -33,91 +35,73 @@ export default function ConnexionPage() {
}; };
return ( 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"> <h1>Connexion</h1>
<div className="text-center mb-8"> <p className="fr-text--lead">Connectez-vous à votre compte CESIZen.</p>
<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>
</div>
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-800 p-8"> {errorMessage && (
<form onSubmit={handleSubmit} className="space-y-5"> <div className="fr-alert fr-alert--error fr-alert--sm fr-mb-3w" role="alert">
<div> <p>{errorMessage}</p>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> </div>
)}
<form onSubmit={handleSubmit}>
<div className="fr-input-group">
<label className="fr-label" htmlFor="email">
Adresse email Adresse email
</label> </label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input <input
id="email" id="email"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required 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" className="fr-input"
placeholder="vous@exemple.fr" placeholder="vous@exemple.fr"
autoComplete="email"
/> />
</div> </div>
</div>
<div> <div className="fr-password" id="password-group">
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <label className="fr-label" htmlFor="password">
Mot de passe Mot de passe
</label> </label>
<div className="relative"> <div className="fr-input-wrap">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input <input
id="password" id="password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required 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" className="fr-password__input fr-input"
placeholder="Votre mot de passe" placeholder="Votre mot de passe"
autoComplete="current-password"
/> />
<button </div>
type="button" <div className="fr-password__checkbox fr-checkbox-group fr-checkbox-group--sm">
onClick={() => setShowPassword(!showPassword)} <input
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" id="password-show"
aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'} type="checkbox"
> checked={showPassword}
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />} onChange={() => setShowPassword(!showPassword)}
</button> />
<label className="fr-label" htmlFor="password-show">
Afficher le mot de passe
</label>
</div> </div>
</div> </div>
<button <button type="submit" disabled={loading} className="fr-btn fr-btn--lg">
type="submit" {loading ? 'Connexion…' : 'Se connecter'}
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> </button>
</form> </form>
<div className="mt-6 text-center text-sm text-gray-500 dark:text-gray-400"> <p className="fr-mt-4w">
Pas encore de compte ?{' '} Pas encore de compte ?{' '}
<Link href="/inscription" className="text-malachite-600 dark:text-malachite-400 font-medium hover:underline"> <Link href="/inscription" className="fr-link">
Créer un compte Créer un compte
</Link> </Link>
</div> </p>
</div> </>
</div>
</div>
); );
} }
+77 -74
View File
@@ -3,7 +3,6 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { User, Mail, Lock, Eye, EyeOff } from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -50,27 +49,17 @@ export default function InscriptionPage() {
}; };
return ( 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"> <h1>Créer un compte</h1>
<div className="text-center mb-8"> <p className="fr-text--lead">Créez votre compte CESIZen gratuitement.</p>
<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>
<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}>
<form onSubmit={handleSubmit} className="space-y-4"> <div className="fr-grid-row fr-grid-row--gutters">
<div className="grid grid-cols-2 gap-4"> <div className="fr-col-12 fr-col-md-6">
<div> <div className={`fr-input-group${errors.prenom ? ' fr-input-group--error' : ''}`}>
<label htmlFor="prenom" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <label className="fr-label" htmlFor="prenom">
Prénom Prénom
</label> </label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input <input
id="prenom" id="prenom"
name="prenom" name="prenom"
@@ -78,15 +67,21 @@ export default function InscriptionPage() {
value={form.prenom} value={form.prenom}
onChange={handleChange} onChange={handleChange}
required 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" className="fr-input"
placeholder="Jean" placeholder="Jean"
aria-describedby={errors.prenom ? 'prenom-error' : undefined}
/> />
{errors.prenom && (
<p id="prenom-error" className="fr-error-text">
{errors.prenom[0]}
</p>
)}
</div> </div>
{errors.prenom && <p className="text-red-500 text-xs mt-1">{errors.prenom[0]}</p>}
</div> </div>
<div> <div className="fr-col-12 fr-col-md-6">
<label htmlFor="nom" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <div className={`fr-input-group${errors.nom ? ' fr-input-group--error' : ''}`}>
<label className="fr-label" htmlFor="nom">
Nom Nom
</label> </label>
<input <input
@@ -96,19 +91,23 @@ export default function InscriptionPage() {
value={form.nom} value={form.nom}
onChange={handleChange} onChange={handleChange}
required 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" className="fr-input"
placeholder="Dupont" placeholder="Dupont"
aria-describedby={errors.nom ? 'nom-error' : undefined}
/> />
{errors.nom && <p className="text-red-500 text-xs mt-1">{errors.nom[0]}</p>} {errors.nom && (
<p id="nom-error" className="fr-error-text">
{errors.nom[0]}
</p>
)}
</div>
</div> </div>
</div> </div>
<div> <div className={`fr-input-group${errors.email ? ' fr-input-group--error' : ''}`}>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <label className="fr-label" htmlFor="email">
Adresse email Adresse email
</label> </label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input <input
id="email" id="email"
name="email" name="email"
@@ -116,19 +115,23 @@ export default function InscriptionPage() {
value={form.email} value={form.email}
onChange={handleChange} onChange={handleChange}
required 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" className="fr-input"
placeholder="vous@exemple.fr" placeholder="vous@exemple.fr"
autoComplete="email"
aria-describedby={errors.email ? 'email-error' : undefined}
/> />
</div> {errors.email && (
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email[0]}</p>} <p id="email-error" className="fr-error-text">
{errors.email[0]}
</p>
)}
</div> </div>
<div> <div className={`fr-password${errors.password ? ' fr-input-group--error' : ''}`}>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <label className="fr-label" htmlFor="password">
Mot de passe Mot de passe
</label> </label>
<div className="relative"> <div className="fr-input-wrap">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input <input
id="password" id="password"
name="password" name="password"
@@ -137,27 +140,23 @@ export default function InscriptionPage() {
onChange={handleChange} onChange={handleChange}
required required
minLength={8} 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" className="fr-password__input fr-input"
placeholder="Minimum 8 caractères" placeholder="Minimum 8 caractères"
autoComplete="new-password"
aria-describedby={errors.password ? 'password-error' : undefined}
/> />
<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>
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password[0]}</p>} {errors.password && (
<p id="password-error" className="fr-error-text">
{errors.password[0]}
</p>
)}
</div> </div>
<div> <div className="fr-input-group">
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <label className="fr-label" htmlFor="password_confirmation">
Confirmer le mot de passe Confirmer le mot de passe
</label> </label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input <input
id="password_confirmation" id="password_confirmation"
name="password_confirmation" name="password_confirmation"
@@ -165,13 +164,25 @@ export default function InscriptionPage() {
value={form.password_confirmation} value={form.password_confirmation}
onChange={handleChange} onChange={handleChange}
required 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" className="fr-input"
placeholder="Retapez votre mot de passe" placeholder="Retapez votre mot de passe"
autoComplete="new-password"
/> />
</div> </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>
<div className="flex items-start gap-3 pt-2"> <div className={`fr-checkbox-group fr-mt-3w${errors.consentement_rgpd ? ' fr-input-group--error' : ''}`}>
<input <input
id="consentement_rgpd" id="consentement_rgpd"
name="consentement_rgpd" name="consentement_rgpd"
@@ -179,45 +190,37 @@ export default function InscriptionPage() {
checked={form.consentement_rgpd} checked={form.consentement_rgpd}
onChange={handleChange} onChange={handleChange}
required required
className="mt-1 w-4 h-4 rounded border-gray-300 text-candlelight-500 focus:ring-candlelight-500" aria-describedby={errors.consentement_rgpd ? 'consentement-error' : undefined}
/> />
<label htmlFor="consentement_rgpd" className="text-sm text-gray-600 dark:text-gray-400"> <label className="fr-label" htmlFor="consentement_rgpd">
J&apos;accepte le traitement de mes données personnelles conformément à la{' '} J&apos;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"> <Link href="/politique-de-confidentialite" className="fr-link">
politique de confidentialité politique de confidentialité
</Link> </Link>
. Mes données sont chiffrées et je peux supprimer mon compte à tout moment. . Mes données sont chiffrées et je peux supprimer mon compte à tout moment.
</label> </label>
{errors.consentement_rgpd && (
<p id="consentement-error" className="fr-error-text">
{errors.consentement_rgpd[0]}
</p>
)}
</div> </div>
{errors.consentement_rgpd && <p className="text-red-500 text-xs">{errors.consentement_rgpd[0]}</p>}
<button <button
type="submit" type="submit"
disabled={loading || !form.consentement_rgpd} 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" className="fr-btn fr-btn--lg fr-mt-3w"
> >
{loading ? ( {loading ? 'Inscription…' : 'Créer mon compte'}
<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'
)}
</button> </button>
</form> </form>
<div className="mt-6 text-center text-sm text-gray-500 dark:text-gray-400"> <p className="fr-mt-4w">
Déjà un compte ?{' '} Déjà un compte ?{' '}
<Link href="/connexion" className="text-malachite-600 dark:text-malachite-400 font-medium hover:underline"> <Link href="/connexion" className="fr-link">
Se connecter Se connecter
</Link> </Link>
</div> </p>
</div> </>
</div>
</div>
); );
} }
+13
View File
@@ -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>
);
}
+131 -110
View File
@@ -1,13 +1,9 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { Heart, BarChart3, Plus, BookOpen } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useSaisies } from '@/hooks/useTracker'; import { useSaisies } from '@/hooks/useTracker';
import { useRapport } from '@/hooks/useRapport'; 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() { export default function DashboardPage() {
const { user } = useAuth(); const { user } = useAuth();
@@ -17,177 +13,202 @@ export default function DashboardPage() {
const recentSaisies = saisies?.slice(0, 5) || []; const recentSaisies = saisies?.slice(0, 5) || [];
return ( return (
<div> <div className="fr-container fr-py-6w">
{/* En-tête */} {/* En-tête */}
<div className="mb-8"> <h1>Bonjour {user?.prenom}</h1>
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-2"> <p className="fr-text--lead">
Bonjour {user?.prenom} 👋
</h1>
<p className="text-gray-500 dark:text-gray-400">
Voici un aperçu de votre bien-être cette semaine. Voici un aperçu de votre bien-être cette semaine.
</p> </p>
</div>
{/* Actions rapides */} {/* Actions rapides */}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8"> <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"> <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"> <span
<div className="w-12 h-12 rounded-xl bg-candlelight-200 dark:bg-candlelight-900 flex items-center justify-center"> className="fr-icon-add-line fr-mr-1w"
<Plus className="text-candlelight-700 dark:text-candlelight-400" size={24} /> aria-hidden="true"
</div> />
<div> Nouvelle saisie
<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> </Link>
</h3>
<p className="fr-tile__detail">Enregistrer une émotion</p>
</div>
</div>
</div>
</div>
<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"> <Link href="/journal">
<Card hover className="flex items-center gap-4"> <span
<div className="w-12 h-12 rounded-xl bg-malachite-100 dark:bg-malachite-900 flex items-center justify-center"> className="fr-icon-book-2-line fr-mr-1w"
<BookOpen className="text-malachite-600 dark:text-malachite-400" size={24} /> aria-hidden="true"
</div> />
<div> Mon journal
<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> </Link>
</h3>
<p className="fr-tile__detail">Voir toutes mes saisies</p>
</div>
</div>
</div>
</div>
<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"> <Link href="/journal/rapports">
<Card hover className="flex items-center gap-4"> <span
<div className="w-12 h-12 rounded-xl bg-blue-100 dark:bg-blue-900 flex items-center justify-center"> className="fr-icon-line-chart-line fr-mr-1w"
<BarChart3 className="text-blue-600 dark:text-blue-400" size={24} /> aria-hidden="true"
</div> />
<div> Mes rapports
<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> </Link>
</h3>
<p className="fr-tile__detail">Visualiser mes tendances</p>
</div>
</div>
</div>
</div>
</div> </div>
{/* Stats de la semaine */} {/* 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 ? ( {rapportLoading ? (
Array.from({ length: 3 }).map((_, i) => ( Array.from({ length: 3 }).map((_, i) => (
<Card key={i}> <div className="fr-col-12 fr-col-md-4" key={i}>
<Skeleton className="h-4 w-24 mb-2" /> <div className="fr-tile fr-tile--vertical">
<Skeleton className="h-8 w-16" /> <div className="fr-tile__body">
</Card> <div className="fr-tile__content">
<p className="fr-tile__detail">Chargement…</p>
</div>
</div>
</div>
</div>
)) ))
) : ( ) : (
<> <>
<Card> <div className="fr-col-12 fr-col-md-4">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Saisies cette semaine</p> <div className="fr-tile fr-tile--vertical">
<p className="text-3xl font-bold text-gray-900 dark:text-white"> <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} {rapport?.stats.total_saisies || 0}
</p> </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> </div>
</Card> </div>
<Card> </div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Émotion dominante</p> </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 ? ( {rapport?.stats.emotion_dominante ? (
<div className="flex items-center gap-2"> <p className="fr-mb-0">
<Badge color={rapport.stats.emotion_dominante.couleur}> <span className="fr-badge fr-badge--info">
{rapport.stats.emotion_dominante.nom} {rapport.stats.emotion_dominante.nom}
</Badge> </span>
<span className="text-sm text-gray-400"> <span className="fr-text--sm">
{' '}
({rapport.stats.emotion_dominante.count}x) ({rapport.stats.emotion_dominante.count}x)
</span> </span>
</div> </p>
) : ( ) : (
<p className="text-xl text-gray-300 dark:text-gray-600">—</p> <p className="fr-h4 fr-mb-0">—</p>
)} )}
</Card> </div>
</div>
</div>
</div>
</> </>
)} )}
</div> </div>
{/* Saisies récentes */} {/* Saisies récentes */}
<Card> <div className="fr-card">
<div className="flex items-center justify-between mb-4"> <div className="fr-card__body">
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white"> <div className="fr-card__content">
Saisies récentes <div className="fr-grid-row fr-grid-row--middle fr-mb-2w">
</h2> <h2 className="fr-col fr-h5 fr-mb-0">Saisies récentes</h2>
<Link <p className="fr-col--right fr-mb-0">
href="/journal" <Link href="/journal" className="fr-link">
className="text-sm text-malachite-600 dark:text-malachite-400 hover:underline" Voir tout
>
Voir tout →
</Link> </Link>
</p>
</div> </div>
{saisiesLoading ? ( {saisiesLoading ? (
<div className="space-y-3"> <p>Chargement…</p>
{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 ? ( ) : recentSaisies.length > 0 ? (
<div className="space-y-3"> <ul className="fr-raw-list">
{recentSaisies.map((saisie) => ( {recentSaisies.map((saisie) => (
<div <li
key={saisie.id} 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" className="fr-py-1w"
> style={{ borderBottom: '1px solid var(--border-default-grey)' }}
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-lg shrink-0"
style={{ backgroundColor: `${saisie.emotion?.couleur}20` }}
> >
<div className="fr-grid-row fr-grid-row--middle">
<span className="fr-mr-2w" style={{ fontSize: '1.5rem' }}>
{saisie.emotion?.icone || '🔵'} {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>
<span className="text-xs text-gray-400"> <span className="fr-col">
<strong>{saisie.emotion?.nom}</strong>{' '}
<span className="fr-text--sm">
{saisie.intensite}/10 {saisie.intensite}/10
</span> </span>
</div>
{saisie.note && ( {saisie.note && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate"> <span className="fr-text--sm fr-text-mention--grey fr-display-block">
{saisie.note} {saisie.note}
</p> </span>
)} )}
</div> </span>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0"> <span className="fr-text--sm fr-text-mention--grey">
{new Date(saisie.date_saisie).toLocaleDateString('fr-FR', { {new Date(saisie.date_saisie).toLocaleDateString('fr-FR', {
day: 'numeric', day: 'numeric',
month: 'short', month: 'short',
})} })}
</span> </span>
</div> </div>
</li>
))} ))}
</div> </ul>
) : ( ) : (
<div className="text-center py-8 text-gray-400 dark:text-gray-600"> <div className="fr-callout">
<Heart size={32} className="mx-auto mb-2 opacity-50" /> <p className="fr-callout__text">Aucune saisie pour le moment.</p>
<p>Aucune saisie pour le moment</p>
<Link <Link
className="fr-btn fr-btn--sm fr-icon-add-line fr-btn--icon-left"
href="/journal/nouvelle-saisie" href="/journal/nouvelle-saisie"
className="text-sm text-candlelight-600 hover:underline mt-2 inline-block"
> >
Faire ma première saisie Faire ma première saisie
</Link> </Link>
</div> </div>
)} )}
</Card> </div>
</div>
</div>
</div> </div>
); );
} }
@@ -2,12 +2,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ArrowLeft, ArrowRight, Check } from 'lucide-react';
import { useCreateSaisie } from '@/hooks/useTracker'; import { useCreateSaisie } from '@/hooks/useTracker';
import EmotionPicker from '@/components/features/EmotionPicker'; import EmotionPicker from '@/components/features/EmotionPicker';
import Slider from '@/components/ui/Slider'; import Slider from '@/components/ui/Slider';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
const STEPS = [ const STEPS = [
@@ -49,57 +46,48 @@ export default function NouvelleSaisiePage() {
} }
}; };
const nextStep = STEPS[step]?.title;
return ( return (
<div className="max-w-2xl mx-auto"> <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 <button
type="button"
onClick={() => router.back()} 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" className="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-icon-arrow-left-line fr-btn--icon-left fr-mb-2w"
> >
<ArrowLeft size={16} />
Retour Retour
</button> </button>
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h1>Nouvelle saisie</h1>
Nouvelle saisie <p className="fr-text--lead">
</h1>
<p className="text-gray-500 dark:text-gray-400 mb-8">
Enregistrez votre émotion en quelques étapes simples. Enregistrez votre émotion en quelques étapes simples.
</p> </p>
{/* Stepper */} {/* Stepper DSFR */}
<div className="flex items-center gap-2 mb-8"> <div className="fr-stepper">
{STEPS.map((s) => ( <h2 className="fr-stepper__title">
<div key={s.id} className="flex items-center gap-2 flex-1"> {STEPS[step - 1].title}
<span className="fr-stepper__state">
Étape {step} sur {STEPS.length}
</span>
</h2>
<div <div
className={` className="fr-stepper__steps"
w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold shrink-0 data-fr-current-step={step}
${step > s.id data-fr-steps={STEPS.length}
? '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'
}`}
/> />
{nextStep && (
<p className="fr-stepper__details">
<span className="fr-text--bold">Étape suivante :</span> {nextStep}
</p>
)} )}
</div> </div>
))}
</div>
{/* Étape actuelle */} {/* Étape actuelle */}
<Card className="mb-6"> <div className="fr-card fr-p-3w fr-mb-3w">
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-1"> <p className="fr-text--sm fr-text-mention--grey fr-mb-3w">
{STEPS[step - 1].title}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{STEPS[step - 1].description} {STEPS[step - 1].description}
</p> </p>
@@ -108,7 +96,7 @@ export default function NouvelleSaisiePage() {
)} )}
{step === 2 && ( {step === 2 && (
<div className="space-y-6"> <div>
<Slider <Slider
value={intensite} value={intensite}
onChange={setIntensite} onChange={setIntensite}
@@ -116,72 +104,86 @@ export default function NouvelleSaisiePage() {
max={10} max={10}
label="Intensité de l'émotion" label="Intensité de l'émotion"
/> />
<div className="flex justify-between text-xs text-gray-400"> <div className="fr-grid-row fr-grid-row--middle fr-mt-1w">
<span>Faible</span> <span className="fr-col fr-text--sm">Faible</span>
<span>Modérée</span> <span className="fr-col fr-text--sm" style={{ textAlign: 'center' }}>
<span>Forte</span> Modérée
</span>
<span className="fr-col fr-text--sm" style={{ textAlign: 'right' }}>
Forte
</span>
</div> </div>
</div> </div>
)} )}
{step === 3 && ( {step === 3 && (
<div className="space-y-4">
<div> <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="saisie-date">
Date de la saisie Date de la saisie
</label> </label>
<input <input
id="saisie-date"
className="fr-input"
type="date" type="date"
value={dateSaisie} value={dateSaisie}
onChange={(e) => setDateSaisie(e.target.value)} 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>
<div> <div className="fr-input-group">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <label className="fr-label" htmlFor="saisie-note">
Note (optionnel) Note (optionnel)
</label> </label>
<textarea <textarea
id="saisie-note"
className="fr-input"
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
placeholder="Décrivez ce que vous ressentez, ce qui a déclenché cette émotion..." placeholder="Décrivez ce que vous ressentez, ce qui a déclenché cette émotion..."
rows={4} 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>
</div> </div>
)} )}
</Card> </div>
{/* Navigation */} {/* Navigation */}
<div className="flex justify-between"> <ul className="fr-btns-group fr-btns-group--inline-md">
<Button <li>
variant="ghost" <button
type="button"
className="fr-btn fr-btn--secondary fr-icon-arrow-left-line fr-btn--icon-left"
onClick={() => setStep(step - 1)} onClick={() => setStep(step - 1)}
disabled={step === 1} disabled={step === 1}
> >
<ArrowLeft size={16} />
Précédent Précédent
</Button> </button>
</li>
{step < 3 ? ( {step < 3 ? (
<Button <li>
<button
type="button"
className="fr-btn fr-icon-arrow-right-line fr-btn--icon-right"
onClick={() => setStep(step + 1)} onClick={() => setStep(step + 1)}
disabled={!canNext()} disabled={!canNext()}
> >
Suivant Suivant
<ArrowRight size={16} /> </button>
</Button> </li>
) : ( ) : (
<Button <li>
<button
type="button"
className="fr-btn fr-icon-check-line fr-btn--icon-left"
onClick={handleSubmit} onClick={handleSubmit}
loading={createSaisie.isPending} disabled={!emotionId || createSaisie.isPending}
disabled={!emotionId}
> >
<Check size={16} />
Enregistrer Enregistrer
</Button> </button>
</li>
)} )}
</ul>
</div>
</div> </div>
</div> </div>
); );
+70 -55
View File
@@ -2,14 +2,10 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Plus, Filter, Download } from 'lucide-react';
import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker'; import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker';
import { useEmotions } from '@/hooks/useEmotions'; import { useEmotions } from '@/hooks/useEmotions';
import TrackerTimeline from '@/components/features/TrackerTimeline'; import TrackerTimeline from '@/components/features/TrackerTimeline';
import EditSaisieModal from '@/components/features/EditSaisieModal'; 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 ConfirmDialog from '@/components/ui/ConfirmDialog';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import type { SaisieTracker } from '@/types'; import type { SaisieTracker } from '@/types';
@@ -71,72 +67,98 @@ export default function JournalPage() {
}; };
return ( return (
<div> <div className="fr-container fr-py-6w">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8"> <div className="fr-grid-row fr-grid-row--middle fr-mb-4w">
<div> <div className="fr-col-12 fr-col-md">
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1"> <h1 className="fr-mb-1v">Mon journal</h1>
Mon journal <p className="fr-text--lead fr-mb-0">
</h1>
<p className="text-gray-500 dark:text-gray-400">
Historique de vos émotions au quotidien Historique de vos émotions au quotidien
</p> </p>
</div> </div>
<div className="flex gap-3"> <div className="fr-col-12 fr-col-md--right">
<Button <ul className="fr-btns-group fr-btns-group--inline-md fr-btns-group--right">
variant="ghost" <li>
size="sm" <button
type="button"
className="fr-btn fr-btn--tertiary fr-btn--sm fr-icon-filter-line fr-btn--icon-left"
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
> >
<Filter size={16} />
Filtrer Filtrer
</Button> </button>
<Button variant="ghost" size="sm" onClick={handleExport} title="Exporter mes saisies (RGPD)"> </li>
<Download size={16} /> <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 Export CSV
</Button> </button>
<Link href="/journal/nouvelle-saisie"> </li>
<Button size="sm"> <li>
<Plus size={16} /> <Link
href="/journal/nouvelle-saisie"
className="fr-btn fr-btn--sm fr-icon-add-line fr-btn--icon-left"
>
Nouvelle saisie Nouvelle saisie
</Button>
</Link> </Link>
</li>
</ul>
</div> </div>
</div> </div>
{/* Filtres */} {/* Filtres */}
{showFilters && ( {showFilters && (
<Card className="mb-6"> <div className="fr-card fr-p-3w fr-mb-4w">
<div className="grid sm:grid-cols-3 gap-4"> <div className="fr-grid-row fr-grid-row--gutters">
<div> <div className="fr-col-12 fr-col-md-4">
<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="filtre-date-debut">
Date début Date début
</label> </label>
<input <input
id="filtre-date-debut"
className="fr-input"
type="date" type="date"
value={filters.date_debut || ''} value={filters.date_debut || ''}
onChange={(e) => setFilters({ ...filters, date_debut: e.target.value || undefined })} onChange={(e) =>
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" setFilters({ ...filters, date_debut: e.target.value || undefined })
}
/> />
</div> </div>
<div> </div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <div className="fr-col-12 fr-col-md-4">
<div className="fr-input-group">
<label className="fr-label" htmlFor="filtre-date-fin">
Date fin Date fin
</label> </label>
<input <input
id="filtre-date-fin"
className="fr-input"
type="date" type="date"
value={filters.date_fin || ''} value={filters.date_fin || ''}
onChange={(e) => setFilters({ ...filters, date_fin: e.target.value || undefined })} onChange={(e) =>
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" setFilters({ ...filters, date_fin: e.target.value || undefined })
}
/> />
</div> </div>
<div> </div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <div className="fr-col-12 fr-col-md-4">
<div className="fr-select-group">
<label className="fr-label" htmlFor="filtre-emotion">
Émotion Émotion
</label> </label>
<select <select
id="filtre-emotion"
className="fr-select"
value={filters.emotion_id || ''} value={filters.emotion_id || ''}
onChange={(e) => setFilters({ ...filters, emotion_id: e.target.value ? Number(e.target.value) : undefined })} onChange={(e) =>
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" setFilters({
...filters,
emotion_id: e.target.value ? Number(e.target.value) : undefined,
})
}
> >
<option value="">Toutes</option> <option value="">Toutes</option>
{emotions?.map((emotion) => ( {emotions?.map((emotion) => (
@@ -147,31 +169,24 @@ export default function JournalPage() {
</select> </select>
</div> </div>
</div> </div>
<div className="mt-4 flex gap-2"> </div>
<Button <ul className="fr-btns-group fr-btns-group--inline-md fr-mt-2w">
variant="ghost" <li>
size="sm" <button
type="button"
className="fr-btn fr-btn--tertiary fr-btn--sm"
onClick={() => setFilters({})} onClick={() => setFilters({})}
> >
Réinitialiser Réinitialiser
</Button> </button>
</li>
</ul>
</div> </div>
</Card>
)} )}
{/* Timeline */} {/* Timeline */}
{isLoading ? ( {isLoading ? (
<div className="space-y-4"> <p>Chargement…</p>
{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>
) : ( ) : (
<TrackerTimeline <TrackerTimeline
saisies={saisies || []} saisies={saisies || []}
@@ -3,10 +3,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRapport } from '@/hooks/useRapport'; import { useRapport } from '@/hooks/useRapport';
import EmotionChart from '@/components/features/EmotionChart'; 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'; type Period = 'week' | 'month' | 'quarter' | 'year';
@@ -22,169 +18,192 @@ export default function RapportsPage() {
const { data: rapport, isLoading } = useRapport(period); const { data: rapport, isLoading } = useRapport(period);
return ( return (
<div> <div className="fr-container fr-py-6w">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8"> <div className="fr-grid-row fr-grid-row--middle fr-mb-4w">
<div> <div className="fr-col-12 fr-col-md">
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1"> <h1 className="fr-mb-1v">Mes rapports</h1>
Mes rapports <p className="fr-text--lead fr-mb-0">
</h1>
<p className="text-gray-500 dark:text-gray-400">
Analysez vos tendances émotionnelles Analysez vos tendances émotionnelles
</p> </p>
</div> </div>
{/* Sélecteur de période */} {/* Sélecteur de période */}
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-xl p-1"> <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) => ( {PERIODS.map((p) => (
<button <div className="fr-segmented__element" key={p.value}>
key={p.value} <input
onClick={() => setPeriod(p.value)} type="radio"
className={` id={`period-${p.value}`}
px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 name="period"
${period === p.value checked={period === p.value}
? 'bg-white dark:bg-gray-700 shadow-sm text-gray-900 dark:text-white' onChange={() => setPeriod(p.value)}
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200' />
} <label className="fr-label" htmlFor={`period-${p.value}`}>
`}
>
{p.label} {p.label}
</button> </label>
</div>
))} ))}
</div> </div>
</fieldset>
</div>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="space-y-6"> <p>Chargement…</p>
<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>
) : rapport ? ( ) : rapport ? (
<div className="space-y-6"> <>
{/* Stats résumées */} {/* Stats résumées */}
<div className="grid sm:grid-cols-3 gap-4"> <div className="fr-grid-row fr-grid-row--gutters fr-mb-3w">
<Card> <div className="fr-col-12 fr-col-md-4">
<div className="flex items-center gap-3 mb-2"> <div className="fr-tile fr-tile--vertical">
<BarChart3 size={18} className="text-candlelight-500" /> <div className="fr-tile__body">
<p className="text-sm text-gray-500 dark:text-gray-400">Total saisies</p> <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>
</div>
</div> </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="fr-col-12 fr-col-md-4">
<div className="flex items-center gap-3 mb-2"> <div className="fr-tile fr-tile--vertical">
<TrendingUp size={18} className="text-malachite-500" /> <div className="fr-tile__body">
<p className="text-sm text-gray-500 dark:text-gray-400">Intensité moyenne</p> <div className="fr-tile__content">
</div> <h3 className="fr-tile__title">
<div className="flex items-baseline gap-2"> <span
<p className="text-3xl font-bold text-gray-900 dark:text-white"> 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) || '—'} {rapport.stats.intensite_moyenne?.toFixed(1) || '—'}
<span className="fr-text--sm"> /10</span>
</p> </p>
<span className="text-sm text-gray-400">/10</span>
</div> </div>
</Card> </div>
</div>
</div>
<Card> <div className="fr-col-12 fr-col-md-4">
<div className="flex items-center gap-3 mb-2"> <div className="fr-tile fr-tile--vertical">
<PieChart size={18} className="text-blue-500" /> <div className="fr-tile__body">
<p className="text-sm text-gray-500 dark:text-gray-400">Émotion dominante</p> <div className="fr-tile__content">
</div> <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 ? ( {rapport.stats.emotion_dominante ? (
<div> <p className="fr-mb-0">
<Badge color={rapport.stats.emotion_dominante.couleur}> <span className="fr-badge fr-badge--info">
{rapport.stats.emotion_dominante.nom} {rapport.stats.emotion_dominante.nom}
</Badge> </span>
<p className="text-xs text-gray-400 mt-1"> <span className="fr-text--sm fr-display-block fr-mt-1v">
{rapport.stats.emotion_dominante.count} fois {rapport.stats.emotion_dominante.count} fois
</span>
</p> </p>
</div>
) : ( ) : (
<p className="text-xl text-gray-300 dark:text-gray-600">—</p> <p className="fr-h4 fr-mb-0">—</p>
)} )}
</Card> </div>
</div>
</div>
</div>
</div> </div>
{/* Répartition (Camembert) */} {/* Répartition (Camembert) */}
<Card> <div className="fr-card fr-p-3w fr-mb-3w">
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4"> <h2 className="fr-h5">Répartition des émotions</h2>
Répartition des émotions
</h2>
<EmotionChart data={rapport} type="repartition" /> <EmotionChart data={rapport} type="repartition" />
</Card> </div>
{/* Évolution (Lignes) */} {/* Évolution (Lignes) */}
<Card> <div className="fr-card fr-p-3w fr-mb-3w">
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4"> <h2 className="fr-h5">Évolution dans le temps</h2>
Évolution dans le temps
</h2>
<EmotionChart data={rapport} type="evolution" /> <EmotionChart data={rapport} type="evolution" />
</Card> </div>
{/* Intensité moyenne (Barres) */} {/* Intensité moyenne (Barres) */}
<Card> <div className="fr-card fr-p-3w fr-mb-3w">
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4"> <h2 className="fr-h5">Intensité moyenne par période</h2>
Intensité moyenne par période
</h2>
<EmotionChart data={rapport} type="intensite" /> <EmotionChart data={rapport} type="intensite" />
</Card> </div>
{/* Détail répartition */} {/* Détail répartition */}
{rapport.repartition.length > 0 && ( {rapport.repartition.length > 0 && (
<Card> <div className="fr-card fr-p-3w">
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4"> <h2 className="fr-h5">Détail par émotion</h2>
Détail par émotion <ul className="fr-raw-list">
</h2>
<div className="space-y-3">
{rapport.repartition.map((item) => ( {rapport.repartition.map((item) => (
<div key={item.nom} className="flex items-center gap-4"> <li key={item.nom} className="fr-py-1w">
<div <div className="fr-grid-row fr-grid-row--middle">
className="w-3 h-3 rounded-full shrink-0" <span
style={{ backgroundColor: item.couleur }} className="fr-mr-2w"
/> aria-hidden="true"
<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"
style={{ 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}%`, width: `${item.pourcentage}%`,
backgroundColor: item.couleur, backgroundColor: item.couleur,
}} }}
/> />
</div> </div>
<span className="text-sm text-gray-500 dark:text-gray-400 w-16 text-right"> </li>
{item.pourcentage}%
</span>
<span className="text-xs text-gray-400 w-20 text-right">
Moy: {item.intensite_moyenne.toFixed(1)}/10
</span>
</div>
))} ))}
</ul>
</div> </div>
</Card>
)} )}
</div> </>
) : ( ) : (
<Card className="text-center py-16"> <div className="fr-callout">
<BarChart3 size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" /> <p className="fr-callout__title">Pas de données pour cette période</p>
<p className="text-lg text-gray-500 dark:text-gray-400"> <p className="fr-callout__text">
Pas de données pour cette période Enregistrez des saisies pour visualiser vos tendances.
</p> </p>
</Card> </div>
)} )}
</div> </div>
); );
+152 -83
View File
@@ -2,10 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; 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 toast from 'react-hot-toast';
import api from '@/lib/api'; import api from '@/lib/api';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
@@ -76,137 +73,202 @@ export default function ProfilPage() {
}; };
return ( return (
<div className="max-w-2xl mx-auto"> <div className="fr-container fr-py-6w">
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-8"> <div className="fr-grid-row fr-grid-row--center">
Mon profil <div className="fr-col-12 fr-col-md-8">
</h1> <h1>Mon profil</h1>
{/* Infos du profil */} {/* Infos du profil */}
<Card className="mb-6"> <div className="fr-card fr-p-3w fr-mb-3w">
<div className="flex items-center gap-4 mb-6"> <h2 className="fr-h4 fr-mb-1v">
<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">
{user?.prenom} {user?.nom} {user?.prenom} {user?.nom}
</h2> </h2>
<p className="text-sm text-gray-500 dark:text-gray-400">{user?.email}</p> <p className="fr-text--sm fr-mb-1v">{user?.email}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1"> <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' }) : '—'} Membre depuis le{' '}
{user?.created_at
? new Date(user.created_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
: '—'}
</p> </p>
</div>
</div>
{editing ? ( {editing ? (
<form onSubmit={handleUpdateProfil} className="space-y-4"> <form onSubmit={handleUpdateProfil}>
<div className="grid sm:grid-cols-2 gap-4"> <div className="fr-grid-row fr-grid-row--gutters">
<Input <div className="fr-col-12 fr-col-md-6">
label="Prénom" <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} value={form.prenom}
onChange={(e) => setForm({ ...form, prenom: e.target.value })} 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 required
/> />
</div> </div>
<Input </div>
label="Email" <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" type="email"
value={form.email} value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })} onChange={(e) => setForm({ ...form, email: e.target.value })}
icon={<Mail size={18} />}
required required
/> />
<div className="flex gap-3"> </div>
<ul className="fr-btns-group fr-btns-group--inline-md">
<li>
<Button type="submit" loading={loading}> <Button type="submit" loading={loading}>
Enregistrer Enregistrer
</Button> </Button>
<Button variant="ghost" onClick={() => setEditing(false)}> </li>
<li>
<Button variant="secondary" type="button" onClick={() => setEditing(false)}>
Annuler Annuler
</Button> </Button>
</div> </li>
</ul>
</form> </form>
) : ( ) : (
<Button variant="outline" onClick={() => { <Button
setForm({ nom: user?.nom || '', prenom: user?.prenom || '', email: user?.email || '' }); variant="secondary"
onClick={() => {
setForm({
nom: user?.nom || '',
prenom: user?.prenom || '',
email: user?.email || '',
});
setEditing(true); setEditing(true);
}}> }}
>
Modifier mes informations Modifier mes informations
</Button> </Button>
)} )}
</Card>
{/* 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>
</div> </div>
{/* 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 ? ( {changingPassword ? (
<form onSubmit={handleChangePassword} className="space-y-4"> <form onSubmit={handleChangePassword}>
<Input <div className="fr-password fr-input-group">
label="Mot de passe actuel" <label className="fr-label" htmlFor="pwd-actuel">
Mot de passe actuel
</label>
<input
id="pwd-actuel"
className="fr-input"
type="password" type="password"
value={passwordForm.ancien_mot_de_passe} value={passwordForm.ancien_mot_de_passe}
onChange={(e) => setPasswordForm({ ...passwordForm, ancien_mot_de_passe: e.target.value })} onChange={(e) =>
setPasswordForm({ ...passwordForm, ancien_mot_de_passe: e.target.value })
}
required required
/> />
<Input </div>
label="Nouveau mot de passe" <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" type="password"
value={passwordForm.password} value={passwordForm.password}
onChange={(e) => setPasswordForm({ ...passwordForm, password: e.target.value })} onChange={(e) =>
setPasswordForm({ ...passwordForm, password: e.target.value })
}
required required
/> />
<Input </div>
label="Confirmer le nouveau mot de passe" <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" type="password"
value={passwordForm.password_confirmation} value={passwordForm.password_confirmation}
onChange={(e) => setPasswordForm({ ...passwordForm, password_confirmation: e.target.value })} onChange={(e) =>
setPasswordForm({
...passwordForm,
password_confirmation: e.target.value,
})
}
required required
/> />
<div className="flex gap-3"> </div>
<ul className="fr-btns-group fr-btns-group--inline-md">
<li>
<Button type="submit" loading={loading}> <Button type="submit" loading={loading}>
Changer le mot de passe Changer le mot de passe
</Button> </Button>
<Button variant="ghost" onClick={() => setChangingPassword(false)}> </li>
<li>
<Button
variant="secondary"
type="button"
onClick={() => setChangingPassword(false)}
>
Annuler Annuler
</Button> </Button>
</div> </li>
</ul>
</form> </form>
) : ( ) : (
<Button variant="outline" onClick={() => setChangingPassword(true)}> <Button variant="secondary" onClick={() => setChangingPassword(true)}>
Changer mon mot de passe Changer mon mot de passe
</Button> </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> </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. {/* 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> </p>
<Button variant="danger" onClick={() => setDeleteModalOpen(true)}> <Button
<Trash2 size={16} /> variant="secondary"
className="fr-icon-delete-line fr-btn--icon-left"
onClick={() => setDeleteModalOpen(true)}
>
Supprimer mon compte Supprimer mon compte
</Button> </Button>
</Card> </div>
{/* Modal confirmation suppression */} {/* Modal confirmation suppression */}
<Modal <Modal
@@ -215,18 +277,25 @@ export default function ProfilPage() {
title="Confirmer la suppression" title="Confirmer la suppression"
size="sm" size="sm"
> >
<p className="text-gray-600 dark:text-gray-400 mb-6"> <p>
Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible. Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est
irréversible.
</p> </p>
<div className="flex gap-3"> <ul className="fr-btns-group fr-btns-group--inline-md">
<Button variant="danger" onClick={handleDeleteAccount} loading={loading}> <li>
<Button variant="secondary" onClick={handleDeleteAccount} loading={loading}>
Oui, supprimer Oui, supprimer
</Button> </Button>
<Button variant="ghost" onClick={() => setDeleteModalOpen(false)}> </li>
<li>
<Button variant="secondary" onClick={() => setDeleteModalOpen(false)}>
Annuler Annuler
</Button> </Button>
</div> </li>
</ul>
</Modal> </Modal>
</div> </div>
</div>
</div>
); );
} }
@@ -1,6 +1,3 @@
import Card from '@/components/ui/Card';
import { Phone } from 'lucide-react';
export default function ContactsUrgencePage() { export default function ContactsUrgencePage() {
const contacts = [ const contacts = [
{ {
@@ -19,13 +16,13 @@ export default function ContactsUrgencePage() {
name: 'Fil Santé Jeunes', name: 'Fil Santé Jeunes',
number: '0 800 235 236', number: '0 800 235 236',
description: 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', name: 'SAMU',
number: '15', number: '15',
description: 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', name: 'SOS Médecins',
@@ -36,48 +33,51 @@ export default function ContactsUrgencePage() {
]; ];
return ( return (
<div className="max-w-3xl mx-auto px-4 py-12"> <div className="fr-container fr-py-6w">
<h1 className="font-display text-4xl font-bold text-gray-900 dark:text-white mb-4"> <h1>Contacts d&apos;urgence</h1>
Contacts d&apos;urgence <p className="fr-text--lead">
</h1> Si vous ou un proche traversez une période difficile, n&apos;hésitez pas
<p className="text-lg text-gray-500 dark:text-gray-400 mb-8"> à contacter l&apos;un de ces services. Vous n&apos;êtes pas seul(e).
Si vous ou un proche traversez une période difficile, n&apos;hésitez pas à
contacter l&apos;un de ces services. Vous n&apos;êtes pas seul(e).
</p> </p>
<div className="space-y-4"> <div className="fr-callout fr-callout--brown-caramel fr-icon-information-line fr-mb-6w">
{contacts.map((contact) => ( <p className="fr-callout__title">En cas d&apos;urgence ou de détresse</p>
<Card key={contact.number}> <p className="fr-callout__text">
<div className="flex items-start gap-4"> Appelez le <strong>3114</strong> (numéro national de prévention du
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-50 dark:bg-red-950/50 flex items-center justify-center"> suicide, gratuit, 24h/24) ou le <strong>15</strong> (SAMU). En cas
<Phone size={20} className="text-red-600 dark:text-red-400" /> d&apos;urgence vitale, composez le <strong>112</strong> (numéro
d&apos;urgence européen).
</p>
</div> </div>
<div className="flex-1 min-w-0">
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-1"> <div className="fr-grid-row fr-grid-row--gutters">
{contact.name} {contacts.map((contact) => (
</h2> <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 <a
className="fr-link"
href={`tel:${contact.number.replace(/\s/g, '')}`} 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"
> >
<span
className="fr-icon-phone-line fr-mr-1w"
aria-hidden="true"
/>
{contact.number} {contact.number}
</a> </a>
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
{contact.description}
</p> </p>
</div> </div>
</div> </div>
</Card> </div>
</div>
</div>
))} ))}
</div> </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&apos;urgence vitale</strong>, appelez immédiatement le{' '}
<strong>15</strong> (SAMU) ou le <strong>112</strong> (numéro d&apos;urgence
européen).
</p>
</Card>
</div> </div>
); );
} }
@@ -2,10 +2,7 @@
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { ArrowLeft, Calendar, User } from 'lucide-react';
import { useFeed } from '@/hooks/useFeeds'; import { useFeed } from '@/hooks/useFeeds';
import Skeleton from '@/components/ui/Skeleton';
export default function ArticlePage() { export default function ArticlePage() {
const params = useParams(); const params = useParams();
@@ -14,79 +11,93 @@ export default function ArticlePage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-3xl mx-auto px-4 py-12"> <div className="fr-container fr-py-6w">
<Skeleton className="h-8 w-48 mb-8" /> <p className="fr-text--sm">Chargement de l&apos;article…</p>
<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> </div>
); );
} }
if (error || !feed) { if (error || !feed) {
return ( return (
<div className="max-w-3xl mx-auto px-4 py-12 text-center"> <div className="fr-container fr-py-6w">
<p className="text-lg text-gray-500 dark:text-gray-400 mb-4"> <div className="fr-alert fr-alert--error">
Article introuvable <h3 className="fr-alert__title">Article introuvable</h3>
<p>
L&apos;article demandé n&apos;existe pas ou n&apos;est plus
disponible.
</p> </p>
<Link </div>
href="/informations" <Link className="fr-link fr-mt-2w" href="/informations">
className="text-malachite-600 dark:text-malachite-400 hover:underline" Retour aux informations
>
← Retour aux informations
</Link> </Link>
</div> </div>
); );
} }
return ( return (
<div className="max-w-3xl mx-auto px-4 py-12"> <div className="fr-container fr-py-6w">
<Link <nav
href="/informations" role="navigation"
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" className="fr-breadcrumb"
aria-label="vous êtes ici :"
> >
<ArrowLeft size={16} /> <button
Retour aux informations type="button"
className="fr-breadcrumb__button"
aria-expanded="false"
aria-controls="breadcrumb-article"
>
Voir le fil d&apos;Ariane
</button>
<div className="fr-collapse" id="breadcrumb-article">
<ol className="fr-breadcrumb__list">
<li>
<Link className="fr-breadcrumb__link" href="/">
Accueil
</Link> </Link>
</li>
{feed.image_url && ( <li>
<div className="relative w-full h-64 sm:h-80 rounded-2xl overflow-hidden bg-gray-100 dark:bg-gray-800 mb-8"> <Link className="fr-breadcrumb__link" href="/informations">
<Image Informations
src={feed.image_url} </Link>
alt={feed.titre} </li>
width={800} <li>
height={400} <a className="fr-breadcrumb__link" aria-current="page">
className="w-full h-full object-cover"
unoptimized
/>
</div>
)}
<h1 className="font-display text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{feed.titre} {feed.titre}
</h1> </a>
</li>
<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"> </ol>
{feed.auteur && (
<div className="flex items-center gap-1.5">
<User size={14} />
{feed.auteur.prenom} {feed.auteur.nom}
</div> </div>
</nav>
<h1>{feed.titre}</h1>
<p className="fr-text--sm">
{feed.auteur && (
<>
<span
className="fr-icon-user-line fr-mr-1w"
aria-hidden="true"
/>
{feed.auteur.prenom} {feed.auteur.nom}
{' — '}
</>
)} )}
<div className="flex items-center gap-1.5"> <span
<Calendar size={14} /> className="fr-icon-calendar-line fr-mr-1w"
aria-hidden="true"
/>
{new Date(feed.created_at).toLocaleDateString('fr-FR', { {new Date(feed.created_at).toLocaleDateString('fr-FR', {
day: 'numeric', day: 'numeric',
month: 'long', month: 'long',
year: 'numeric', year: 'numeric',
})} })}
</div> </p>
</div>
<article <hr className="fr-mt-2w fr-mb-4w" />
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"
<div
className="fr-col-12 fr-col-md-10"
dangerouslySetInnerHTML={{ __html: feed.contenu }} dangerouslySetInnerHTML={{ __html: feed.contenu }}
/> />
</div> </div>
+33 -57
View File
@@ -1,86 +1,62 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { BookOpen, Calendar } from 'lucide-react';
import { useFeeds } from '@/hooks/useFeeds'; import { useFeeds } from '@/hooks/useFeeds';
import Card from '@/components/ui/Card';
import Skeleton from '@/components/ui/Skeleton';
export default function InformationsPage() { export default function InformationsPage() {
const { data: feeds, isLoading } = useFeeds(); const { data: feeds, isLoading } = useFeeds();
return ( return (
<div className="max-w-6xl mx-auto px-4 py-12"> <div className="fr-container fr-py-6w">
<div className="text-center mb-12"> <h1>Informations &amp; Ressources</h1>
<h1 className="font-display text-4xl font-bold text-gray-900 dark:text-white mb-4"> <p className="fr-text--lead">
Informations & Ressources Des contenus validés sur la santé mentale, la gestion du stress et le
</h1> bien-être émotionnel.
<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> </p>
</div>
{isLoading ? ( {isLoading ? (
<div className="grid md:grid-cols-2 gap-6"> <p className="fr-text--sm">Chargement des articles…</p>
{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>
) : feeds && feeds.length > 0 ? ( ) : 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) => ( {feeds.map((feed) => (
<Link key={feed.id} href={`/informations/${feed.slug}`}> <div className="fr-col-12 fr-col-md-6" key={feed.id}>
<Card hover className="h-full"> <div className="fr-card fr-enlarge-link">
{feed.image_url && ( <div className="fr-card__body">
<div className="relative w-full h-48 mb-4 rounded-xl overflow-hidden bg-gray-100 dark:bg-gray-800"> <div className="fr-card__content">
<Image <h2 className="fr-card__title">
src={feed.image_url} <Link href={`/informations/${feed.slug}`}>
alt={feed.titre}
width={800}
height={400}
className="w-full h-full object-cover"
unoptimized
/>
</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} {feed.titre}
</Link>
</h2> </h2>
<div <p className="fr-card__desc">
className="text-gray-500 dark:text-gray-400 text-sm line-clamp-3 mb-4" {(feed.contenu ?? '')
dangerouslySetInnerHTML={{ .replace(/<[^>]*>/g, '')
__html: (feed.contenu ?? '').replace(/<[^>]*>/g, '').substring(0, 200) + '...', .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"
/> />
<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', { {new Date(feed.created_at).toLocaleDateString('fr-FR', {
day: 'numeric', day: 'numeric',
month: 'long', month: 'long',
year: 'numeric', year: 'numeric',
})} })}
</p>
</div>
</div>
</div>
</div>
</div> </div>
</Card>
</Link>
))} ))}
</div> </div>
) : ( ) : (
<div className="text-center py-16 text-gray-400 dark:text-gray-600"> <div className="fr-callout">
<BookOpen size={48} className="mx-auto mb-4 opacity-50" /> <p className="fr-callout__text">
<p className="text-lg">Aucun article disponible pour le moment</p> Aucun article disponible pour le moment.
</p>
</div> </div>
)} )}
</div> </div>
@@ -1,73 +1,47 @@
import Card from '@/components/ui/Card';
export default function MentionsLegalesPage() { export default function MentionsLegalesPage() {
return ( return (
<div className="max-w-3xl mx-auto px-4 py-12"> <div className="fr-container fr-py-6w">
<h1 className="font-display text-4xl font-bold text-gray-900 dark:text-white mb-8"> <h1>Mentions légales</h1>
Mentions légales
</h1>
<div className="space-y-6"> <h2>Éditeur de l&apos;application</h2>
<Card> <p>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
Éditeur de l&apos;application
</h2>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
CESIZen est une application développée dans le cadre d&apos;un projet CESIZen est une application développée dans le cadre d&apos;un projet
pédagogique au sein de{' '} pédagogique au sein de <strong>CESI École d&apos;Ingénieurs</strong>.
<strong>CESI École d&apos;Ingénieurs</strong>. Cette application est Cette application est réalisée à but éducatif et n&apos;a pas de vocation
réalisée à but éducatif et n&apos;a pas de vocation commerciale. commerciale.
</p> </p>
</Card>
<Card> <h2>Hébergement</h2>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3"> <p>
Hébergement
</h2>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
L&apos;application est hébergée dans le cadre de l&apos;infrastructure L&apos;application est hébergée dans le cadre de l&apos;infrastructure
pédagogique de CESI. Les données sont stockées sur des serveurs pédagogique de CESI. Les données sont stockées sur des serveurs
sécurisés situés en France. sécurisés situés en France.
</p> </p>
</Card>
<Card> <h2>Propriété intellectuelle</h2>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3"> <p>
Propriété intellectuelle
</h2>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
L&apos;ensemble des contenus présents sur CESIZen (textes, images, L&apos;ensemble des contenus présents sur CESIZen (textes, images,
graphismes, logo, icônes) sont la propriété exclusive de leurs auteurs 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 respectifs et sont protégés par les lois françaises et internationales
relatives à la propriété intellectuelle. relatives à la propriété intellectuelle.
</p> </p>
</Card>
<Card> <h2>Responsabilité</h2>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3"> <p>
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 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, cas à un avis médical professionnel. En cas de détresse psychologique,
veuillez contacter les services d&apos;urgence ou un professionnel de veuillez contacter les services d&apos;urgence ou un professionnel de
santé. santé.
</p> </p>
</Card>
<Card> <h2>Contact</h2>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3"> <p>
Contact
</h2>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
Pour toute question relative aux mentions légales, vous pouvez nous Pour toute question relative aux mentions légales, vous pouvez nous
contacter via l&apos;adresse suivante :{' '} contacter via l&apos;adresse suivante :{' '}
<span className="text-malachite-600 dark:text-malachite-400"> <a className="fr-link" href="mailto:contact@cesizen.fr">
contact@cesizen.fr contact@cesizen.fr
</span> </a>
</p> </p>
</Card>
</div>
</div> </div>
); );
} }
@@ -1,105 +1,73 @@
import Card from '@/components/ui/Card';
export default function PolitiqueConfidentialitePage() { export default function PolitiqueConfidentialitePage() {
return ( return (
<div className="max-w-3xl mx-auto px-4 py-12"> <div className="fr-container fr-py-6w">
<h1 className="font-display text-4xl font-bold text-gray-900 dark:text-white mb-8"> <h1>Politique de confidentialité</h1>
Politique de confidentialité
</h1>
<div className="space-y-6"> <h2>Conformité RGPD</h2>
<Card> <p>
<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&apos;engage à respecter le Règlement Général sur la Protection CESIZen s&apos;engage à respecter le Règlement Général sur la Protection
des Données (RGPD) et la loi Informatique et Libertés. La protection des Données (RGPD) et la loi Informatique et Libertés. La protection de
de vos données personnelles est une priorité. vos données personnelles est une priorité.
</p> </p>
</Card>
<Card> <h2>Données collectées</h2>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3"> <p>
Données collectées
</h2>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mb-3">
Dans le cadre de l&apos;utilisation de CESIZen, les données suivantes Dans le cadre de l&apos;utilisation de CESIZen, les données suivantes
sont collectées : sont collectées :
</p> </p>
<ul className="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-1"> <ul>
<li>Adresse e-mail</li> <li>Adresse e-mail</li>
<li>Nom et prénom</li> <li>Nom et prénom</li>
<li>Données relatives aux émotions enregistrées</li> <li>Données relatives aux émotions enregistrées</li>
</ul> </ul>
</Card>
<Card> <h2>Finalité du traitement</h2>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3"> <p>
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 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 personnalisé du bien-être émotionnel. Elles ne sont ni vendues, ni
partagées avec des tiers à des fins commerciales. partagées avec des tiers à des fins commerciales.
</p> </p>
</Card>
<Card> <h2>Durée de conservation</h2>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3"> <p>
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 Les données personnelles sont conservées pendant toute la durée
d&apos;utilisation du compte. En cas de suppression du compte, les d&apos;utilisation du compte. En cas de suppression du compte, les
données sont effacées dans un délai de 30 jours. données sont effacées dans un délai de 30 jours.
</p> </p>
</Card>
<Card> <h2>Sécurité des données</h2>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3"> <p>
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 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&apos;accès aux passe sont hachés avec des algorithmes sécurisés. L&apos;accès aux
données est strictement limité aux personnes habilitées. données est strictement limité aux personnes habilitées.
</p> </p>
</Card>
<Card> <h2>Vos droits</h2>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3"> <p>Conformément au RGPD, vous disposez des droits suivants :</p>
Vos droits <ul>
</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> <li>
<strong>Droit d&apos;accès :</strong> obtenir une copie de vos <strong>Droit d&apos;accès :</strong> obtenir une copie de vos données
données personnelles personnelles
</li> </li>
<li> <li>
<strong>Droit de rectification :</strong> corriger des données <strong>Droit de rectification :</strong> corriger des données
inexactes ou incomplètes inexactes ou incomplètes
</li> </li>
<li> <li>
<strong>Droit de suppression :</strong> demander l&apos;effacement <strong>Droit de suppression :</strong> demander l&apos;effacement de
de vos données personnelles vos données personnelles
</li> </li>
<li> <li>
<strong>Droit à la portabilité :</strong> récupérer vos données dans <strong>Droit à la portabilité :</strong> récupérer vos données dans
un format structuré un format structuré
</li> </li>
</ul> </ul>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mt-3"> <p>
Pour exercer ces droits, contactez-nous à l&apos;adresse :{' '} Pour exercer ces droits, contactez-nous à l&apos;adresse :{' '}
<span className="text-malachite-600 dark:text-malachite-400"> <a className="fr-link" href="mailto:contact@cesizen.fr">
contact@cesizen.fr contact@cesizen.fr
</span> </a>
</p> </p>
</Card>
</div>
</div> </div>
); );
} }
@@ -57,17 +57,14 @@ export default function EditSaisieModal({ saisie, isOpen, onClose }: EditSaisieM
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title="Modifier la saisie" size="lg"> <Modal isOpen={isOpen} onClose={onClose} title="Modifier la saisie" size="lg">
<div className="space-y-6">
{/* Émotion */} {/* Émotion */}
<div> <div className="fr-input-group">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <p className="fr-label">Émotion</p>
Émotion
</label>
<EmotionPicker value={emotionId} onChange={setEmotionId} /> <EmotionPicker value={emotionId} onChange={setEmotionId} />
</div> </div>
{/* Intensité */} {/* Intensité */}
<div> <div className="fr-input-group">
<Slider <Slider
value={intensite} value={intensite}
onChange={setIntensite} onChange={setIntensite}
@@ -75,45 +72,54 @@ export default function EditSaisieModal({ saisie, isOpen, onClose }: EditSaisieM
max={10} max={10}
label="Intensité de l'émotion" label="Intensité de l'émotion"
/> />
<div className="flex justify-between text-xs text-gray-400 mt-1"> <div className="fr-grid-row fr-grid-row--middle fr-mt-1v">
<span>Faible</span> <span className="fr-col fr-text--sm">Faible</span>
<span>Modérée</span> <span className="fr-col fr-text--sm" style={{ textAlign: 'center' }}>
<span>Forte</span> Modérée
</span>
<span className="fr-col fr-text--sm" style={{ textAlign: 'right' }}>
Forte
</span>
</div> </div>
</div> </div>
{/* Date */} {/* Date */}
<div> <div className="fr-input-group">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <label className="fr-label" htmlFor="edit-saisie-date">
Date de la saisie Date de la saisie
</label> </label>
<input <input
id="edit-saisie-date"
className="fr-input"
type="date" type="date"
value={dateSaisie} value={dateSaisie}
onChange={(e) => setDateSaisie(e.target.value)} 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>
{/* Note */} {/* Note */}
<div> <div className="fr-input-group">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <label className="fr-label" htmlFor="edit-saisie-note">
Note (optionnel) Note (optionnel)
</label> </label>
<textarea <textarea
id="edit-saisie-note"
className="fr-input"
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
placeholder="Décrivez ce que vous ressentez..." placeholder="Décrivez ce que vous ressentez..."
rows={3} 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> </div>
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-3 pt-2"> <ul className="fr-btns-group fr-btns-group--inline-md fr-btns-group--right">
<Button variant="ghost" onClick={onClose}> <li>
<Button variant="secondary" onClick={onClose}>
Annuler Annuler
</Button> </Button>
</li>
<li>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
loading={updateSaisie.isPending} loading={updateSaisie.isPending}
@@ -121,8 +127,8 @@ export default function EditSaisieModal({ saisie, isOpen, onClose }: EditSaisieM
> >
Enregistrer Enregistrer
</Button> </Button>
</div> </li>
</div> </ul>
</Modal> </Modal>
); );
} }
@@ -38,7 +38,7 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
} }
return ( return (
<div className="w-full h-80"> <div style={{ width: '100%', height: '20rem' }}>
<ResponsiveContainer width="100%" height={320}> <ResponsiveContainer width="100%" height={320}>
<PieChart> <PieChart>
<Pie <Pie
@@ -88,10 +88,10 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
} }
return ( return (
<div className="w-full h-80"> <div style={{ width: '100%', height: '20rem' }}>
<ResponsiveContainer width="100%" height={320}> <ResponsiveContainer width="100%" height={320}>
<LineChart data={lineData}> <LineChart data={lineData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" /> <CartesianGrid strokeDasharray="3 3" style={{ opacity: 0.3 }} />
<XAxis dataKey="date" tick={{ fontSize: 12 }} /> <XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} /> <YAxis tick={{ fontSize: 12 }} />
<Tooltip <Tooltip
@@ -131,10 +131,10 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
} }
return ( return (
<div className="w-full h-80"> <div style={{ width: '100%', height: '20rem' }}>
<ResponsiveContainer width="100%" height={320}> <ResponsiveContainer width="100%" height={320}>
<BarChart data={barData}> <BarChart data={barData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" /> <CartesianGrid strokeDasharray="3 3" style={{ opacity: 0.3 }} />
<XAxis dataKey="date" tick={{ fontSize: 12 }} /> <XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis domain={[0, 10]} tick={{ fontSize: 12 }} /> <YAxis domain={[0, 10]} tick={{ fontSize: 12 }} />
<Tooltip <Tooltip
@@ -146,7 +146,7 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
}} }}
formatter={(value: ValueType | undefined) => [`${Number(value).toFixed(1)}`, 'Intensité moyenne']} 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> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -158,8 +158,17 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
function EmptyChart({ message }: { message: string }) { function EmptyChart({ message }: { message: string }) {
return ( return (
<div className="w-full h-80 flex items-center justify-center text-gray-400 dark:text-gray-600"> <div
<p className="text-lg">{message}</p> 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> </div>
); );
} }
@@ -14,13 +14,7 @@ export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
const [selectedParent, setSelectedParent] = useState<Emotion | null>(null); const [selectedParent, setSelectedParent] = useState<Emotion | null>(null);
if (isLoading) { if (isLoading) {
return ( return <p className="fr-text--sm fr-text-mention--grey">Chargement des émotions…</p>;
<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>
);
} }
// Si une émotion parent est sélectionnée et qu'elle a des enfants, afficher les sous-émotions // Si une émotion parent est sélectionnée et qu'elle a des enfants, afficher les sous-émotions
@@ -30,50 +24,53 @@ export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
<button <button
type="button" type="button"
onClick={() => setSelectedParent(null)} 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> </button>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3"> <p className="fr-text--sm fr-mb-2w">
Précisez votre émotion de <span className="font-medium" style={{ color: selectedParent.couleur }}>{selectedParent.nom}</span> : Précisez votre émotion de{' '}
<span className="fr-text--bold" style={{ color: selectedParent.couleur }}>
{selectedParent.nom}
</span>{' '}
:
</p> </p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> <ul className="fr-tags-group">
{selectedParent.enfants.map((child) => ( {selectedParent.enfants.map((child) => {
const isSelected = value === child.id;
return (
<li key={child.id}>
<button <button
key={child.id}
type="button" type="button"
aria-pressed={isSelected}
onClick={() => onChange(child.id)} onClick={() => onChange(child.id)}
className={` className={`fr-tag${isSelected ? ' fr-tag--green-emeraude' : ''}`}
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 aria-hidden="true" className="fr-mr-1v">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{child.nom}</span> {child.icone || '🔵'}
</span>
{child.nom}
</button> </button>
))} </li>
</div> );
})}
</ul>
</div> </div>
); );
} }
return ( return (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3"> <ul className="fr-tags-group">
{emotions?.map((emotion) => { {emotions?.map((emotion) => {
const hasChildren = emotion.enfants && emotion.enfants.length > 0; 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 ( return (
<li key={emotion.id}>
<button <button
key={emotion.id}
type="button" type="button"
aria-pressed={!!isSelected}
onClick={() => { onClick={() => {
if (hasChildren) { if (hasChildren) {
setSelectedParent(emotion); setSelectedParent(emotion);
@@ -81,25 +78,21 @@ export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
onChange(emotion.id); onChange(emotion.id);
} }
}} }}
className={` className={`fr-tag fr-tag--md${isSelected ? ' fr-tag--green-emeraude' : ''}`}
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 aria-hidden="true" className="fr-mr-1v">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{emotion.nom}</span> {emotion.icone || '🔵'}
</span>
{emotion.nom}
{hasChildren && ( {hasChildren && (
<span className="text-xs text-gray-400">▼</span> <span aria-hidden="true" className="fr-ml-1v">
▾
</span>
)} )}
</button> </button>
</li>
); );
})} })}
</div> </ul>
); );
} }
@@ -2,8 +2,6 @@
import React from 'react'; import React from 'react';
import type { SaisieTracker } from '@/types'; import type { SaisieTracker } from '@/types';
import Badge from '@/components/ui/Badge';
import { Trash2, Edit2 } from 'lucide-react';
interface TrackerTimelineProps { interface TrackerTimelineProps {
saisies: SaisieTracker[]; saisies: SaisieTracker[];
@@ -14,9 +12,11 @@ interface TrackerTimelineProps {
export default function TrackerTimeline({ saisies, onEdit, onDelete }: TrackerTimelineProps) { export default function TrackerTimeline({ saisies, onEdit, onDelete }: TrackerTimelineProps) {
if (saisies.length === 0) { if (saisies.length === 0) {
return ( return (
<div className="text-center py-12 text-gray-400 dark:text-gray-600"> <div className="fr-callout">
<p className="text-lg mb-2">Aucune saisie enregistrée</p> <p className="fr-callout__title">Aucune saisie enregistrée</p>
<p className="text-sm">Commencez par enregistrer votre première émotion</p> <p className="fr-callout__text">
Commencez par enregistrer votre première émotion.
</p>
</div> </div>
); );
} }
@@ -35,78 +35,105 @@ export default function TrackerTimeline({ saisies, onEdit, onDelete }: TrackerTi
}, {}); }, {});
return ( return (
<div className="space-y-8"> <div>
{Object.entries(grouped).map(([date, items]) => ( {Object.entries(grouped).map(([date, items]) => (
<div key={date}> <section key={date} className="fr-mb-4w">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-4"> <h3 className="fr-h6 fr-text-mention--grey fr-mb-2w" style={{ textTransform: 'uppercase' }}>
{date} {date}
</h3> </h3>
<div className="space-y-3"> <div className="fr-grid-row fr-grid-row--gutters">
{items.map((saisie) => ( {items.map((saisie) => (
<div <div key={saisie.id} className="fr-col-12">
key={saisie.id} <div className="fr-card fr-p-2w">
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" <div className="fr-grid-row fr-grid-row--gutters fr-grid-row--top">
>
{/* Icône émotion */} {/* Icône émotion */}
<div <div className="fr-col-auto">
className="shrink-0 w-12 h-12 rounded-xl flex items-center justify-center text-xl" <span
style={{ backgroundColor: `${saisie.emotion?.couleur}20` }} 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 || '🔵'} {saisie.emotion?.icone || '🔵'}
</span>
</div> </div>
{/* Contenu */} {/* Contenu */}
<div className="flex-1 min-w-0"> <div className="fr-col">
<div className="flex items-center gap-2 mb-1"> <p className="fr-mb-1v">
<Badge color={saisie.emotion?.couleur}> <span className="fr-badge fr-badge--info fr-mr-2v">
{saisie.emotion?.nom} {saisie.emotion?.nom}
</Badge> </span>
<span className="text-sm text-gray-400 dark:text-gray-500"> <span className="fr-text--sm fr-text-mention--grey">
Intensité : {saisie.intensite}/10 Intensité : {saisie.intensite}/10
</span> </span>
</div> </p>
{/* Barre d'intensité */} {/* Barre d'intensité */}
<div className="w-full h-1.5 bg-gray-100 dark:bg-gray-800 rounded-full mb-2">
<div <div
className="h-full rounded-full transition-all duration-500" className="fr-mb-1w"
style={{ style={{
width: '100%',
height: '0.375rem',
borderRadius: '0.25rem',
backgroundColor: 'var(--background-contrast-grey)',
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
width: `${saisie.intensite * 10}%`, width: `${saisie.intensite * 10}%`,
backgroundColor: saisie.emotion?.couleur || '#fce117', backgroundColor: saisie.emotion?.couleur || '#000091',
}} }}
/> />
</div> </div>
{saisie.note && ( {saisie.note && (
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"> <p className="fr-text--sm fr-mb-0">{saisie.note}</p>
{saisie.note}
</p>
)} )}
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-1 shrink-0"> <div className="fr-col-auto">
<ul className="fr-btns-group fr-btns-group--inline fr-btns-group--sm">
{onEdit && ( {onEdit && (
<li>
<button <button
type="button"
onClick={() => onEdit(saisie)} 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" className="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-icon-edit-line"
aria-label="Modifier" title="Modifier"
> >
<Edit2 size={16} /> Modifier
</button> </button>
</li>
)} )}
{onDelete && ( {onDelete && (
<li>
<button <button
type="button"
onClick={() => onDelete(saisie.id)} 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" className="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-icon-delete-line"
aria-label="Supprimer" title="Supprimer"
> >
<Trash2 size={16} /> Supprimer
</button> </button>
</li>
)} )}
</ul>
</div>
</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </section>
))} ))}
</div> </div>
); );
+34 -76
View File
@@ -3,41 +3,33 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Users,
FileText,
Heart,
Menu,
X,
} from "lucide-react";
interface SidebarLink { interface SidebarLink {
href: string; href: string;
label: string; label: string;
icon: React.ReactNode; icon: string;
} }
const sidebarLinks: SidebarLink[] = [ const sidebarLinks: SidebarLink[] = [
{ {
href: "/admin", href: "/admin",
label: "Dashboard", label: "Dashboard",
icon: <LayoutDashboard size={20} />, icon: "fr-icon-dashboard-3-line",
}, },
{ {
href: "/admin/utilisateurs", href: "/admin/utilisateurs",
label: "Utilisateurs", label: "Utilisateurs",
icon: <Users size={20} />, icon: "fr-icon-user-line",
}, },
{ {
href: "/admin/contenus", href: "/admin/contenus",
label: "Contenus", label: "Contenus",
icon: <FileText size={20} />, icon: "fr-icon-file-text-line",
}, },
{ {
href: "/admin/emotions", href: "/admin/emotions",
label: "Émotions", label: "Émotions",
icon: <Heart size={20} />, icon: "fr-icon-heart-line",
}, },
]; ];
@@ -51,83 +43,49 @@ export default function Sidebar() {
}; };
return ( return (
<> <nav
{/* Mobile toggle */} className="fr-sidemenu"
aria-labelledby="fr-sidemenu-title"
role="navigation"
>
<div className="fr-sidemenu__inner">
<button <button
type="button"
className="fr-sidemenu__btn"
aria-controls="fr-sidemenu-wrapper"
aria-expanded={collapsed}
onClick={() => setCollapsed(!collapsed)} 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} />} Menu d&apos;administration
</button> </button>
{/* Overlay mobile */}
{collapsed && (
<div <div
className="lg:hidden fixed inset-0 z-30 bg-black/50 backdrop-blur-sm" className={`fr-collapse${collapsed ? " fr-collapse--expanded" : ""}`}
onClick={() => setCollapsed(false)} id="fr-sidemenu-wrapper"
/>
)}
{/* 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="fr-sidemenu__title" id="fr-sidemenu-title">
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200 dark:border-gray-800"> <Link href="/admin" className="fr-sidemenu__link">
<Link href="/admin" className="flex items-center gap-0.5"> Administration CESIZen
<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> </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> </div>
<ul className="fr-sidemenu__list">
{/* Navigation */} {sidebarLinks.map((link) => {
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto"> const active = isActive(link.href);
{sidebarLinks.map((link) => ( return (
<li className="fr-sidemenu__item" key={link.href}>
<Link <Link
key={link.href} className={`fr-sidemenu__link ${link.icon} fr-link--icon-left`}
href={link.href} href={link.href}
aria-current={active ? "page" : undefined}
onClick={() => setCollapsed(false)} 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} {link.label}
</Link> </Link>
))} </li>
</nav> );
})}
{/* Footer */} </ul>
<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> </div>
</aside> </div>
</> </nav>
); );
} }
+32 -18
View File
@@ -8,27 +8,41 @@ interface BadgeProps {
className?: string; 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({ export default function Badge({
children, children,
color = "#fce117", color = "#fce117",
className = "", className = "",
}: BadgeProps) { }: BadgeProps) {
return ( const classes = ["fr-badge", "fr-badge--sm", badgeVariant(color), className]
<span .filter(Boolean)
className={` .join(" ");
inline-flex items-center
px-2.5 py-0.5 return <span className={classes}>{children}</span>;
rounded-full
text-xs font-medium
transition-all duration-200
${className}
`}
style={{
backgroundColor: `${color}20`,
color: color,
}}
>
{children}
</span>
);
} }
+12 -15
View File
@@ -8,20 +8,17 @@ interface CardProps {
hover?: boolean; 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) { export default function Card({ children, className = "", hover = false }: CardProps) {
return ( const classes = [
<div "fr-card",
className={` "fr-p-3w",
bg-white dark:bg-gray-900 hover ? "fr-enlarge-link" : "",
rounded-2xl shadow-sm className,
border border-gray-100 dark:border-gray-800 ]
p-6 .filter(Boolean)
transition-all duration-200 .join(" ");
${hover ? "hover:shadow-lg hover:-translate-y-0.5" : ""}
${className} return <div className={classes}>{children}</div>;
`}
>
{children}
</div>
);
} }
+10 -27
View File
@@ -1,6 +1,5 @@
"use client"; "use client";
import { AlertTriangle } from "lucide-react";
import Modal from "./Modal"; import Modal from "./Modal";
import Button from "./Button"; import Button from "./Button";
@@ -27,32 +26,10 @@ export default function ConfirmDialog({
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm"> <Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<div className="flex items-start gap-3 mb-6"> <p className="fr-mb-3w">{message}</p>
<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>
<div className="flex justify-end gap-2"> <ul className="fr-btns-group fr-btns-group--right fr-btns-group--inline-reverse fr-btns-group--inline-lg">
<Button variant="ghost" onClick={onClose}> <li>
{cancelLabel}
</Button>
<Button <Button
variant={destructive ? "danger" : "primary"} variant={destructive ? "danger" : "primary"}
onClick={() => { onClick={() => {
@@ -62,7 +39,13 @@ export default function ConfirmDialog({
> >
{confirmLabel} {confirmLabel}
</Button> </Button>
</div> </li>
<li>
<Button variant="secondary" onClick={onClose}>
{cancelLabel}
</Button>
</li>
</ul>
</Modal> </Modal>
); );
} }
+8 -24
View File
@@ -17,42 +17,26 @@ export default function Input({
...inputProps ...inputProps
}: InputProps) { }: InputProps) {
const inputId = id || label?.toLowerCase().replace(/\s+/g, "-"); const inputId = id || label?.toLowerCase().replace(/\s+/g, "-");
const errorId = error ? `${inputId}-error` : undefined;
return ( return (
<div className="w-full"> <div className={`fr-input-group${error ? " fr-input-group--error" : ""}`}>
{label && ( {label && (
<label <label className="fr-label" htmlFor={inputId}>
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
>
{label} {label}
</label> </label>
)} )}
<div className="relative"> <div className={icon ? "fr-input-wrap" : undefined}>
{icon && ( {icon && <span aria-hidden="true">{icon}</span>}
<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>
)}
<input <input
id={inputId} id={inputId}
className={` className={`fr-input${error ? " fr-input--error" : ""} ${className}`.trim()}
w-full rounded-xl border border-gray-300 dark:border-gray-700 aria-describedby={errorId}
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}
`}
{...inputProps} {...inputProps}
/> />
</div> </div>
{error && ( {error && (
<p className="mt-1.5 text-sm text-red-500 dark:text-red-400"> <p id={errorId} className="fr-error-text">
{error} {error}
</p> </p>
)} )}
+40 -35
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import React, { useEffect, useCallback } from "react"; import React, { useEffect, useCallback } from "react";
import { X } from "lucide-react";
type ModalSize = "sm" | "md" | "lg"; type ModalSize = "sm" | "md" | "lg";
@@ -13,10 +12,11 @@ interface ModalProps {
size?: ModalSize; size?: ModalSize;
} }
const sizeClasses: Record<ModalSize, string> = { // Largeur de la colonne DSFR selon la taille demandée.
sm: "max-w-sm", const sizeColClasses: Record<ModalSize, string> = {
md: "max-w-lg", sm: "fr-col-12 fr-col-md-4",
lg: "max-w-2xl", md: "fr-col-12 fr-col-md-6",
lg: "fr-col-12 fr-col-md-8",
}; };
export default function Modal({ export default function Modal({
@@ -46,44 +46,49 @@ export default function Modal({
if (!isOpen) return null; 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 ( return (
<div className="fixed inset-0 z-100 flex items-center justify-center p-4"> <dialog
{/* Overlay */} className="fr-modal fr-modal--opened"
<div aria-modal="true"
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200" role="dialog"
onClick={onClose} open
/> style={{
position: "fixed",
{/* Modal */} inset: 0,
<div zIndex: 1000,
className={` display: "block",
relative w-full ${sizeClasses[size]} background: "transparent",
bg-white dark:bg-gray-900 border: "none",
rounded-2xl shadow-2xl maxWidth: "none",
border border-gray-100 dark:border-gray-800 maxHeight: "none",
p-6 width: "100%",
animate-in zoom-in-95 fade-in duration-200 height: "100%",
`} }}
> >
{/* Header */} <div className="fr-container fr-container--fluid fr-container-md">
<div className="flex items-center justify-between mb-4"> <div className="fr-grid-row fr-grid-row--center">
{title && ( <div className={sizeColClasses[size]}>
<h2 className="text-xl font-bold font-display text-gray-900 dark:text-gray-100"> <div className="fr-modal__body">
{title} <div className="fr-modal__header">
</h2>
)}
<button <button
onClick={onClose} type="button"
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" className="fr-btn--close fr-btn"
aria-label="Fermer" aria-label="Fermer"
onClick={onClose}
> >
<X size={20} /> Fermer
</button> </button>
</div> </div>
<div className="fr-modal__content">
{/* Content */} {title && <h1 className="fr-modal__title">{title}</h1>}
<div>{children}</div> {children}
</div> </div>
</div> </div>
</div>
</div>
</div>
</dialog>
); );
} }
+14 -10
View File
@@ -9,10 +9,12 @@ interface SkeletonProps {
variant?: SkeletonVariant; variant?: SkeletonVariant;
} }
const variantClasses: Record<SkeletonVariant, string> = { // Dimensions/forme selon la variante. Couleur neutre gérée en ligne pour
text: "h-4 w-full rounded", // rester cohérent avec le thème DSFR (sans couleur custom Tailwind).
circle: "h-10 w-10 rounded-full", const variantStyle: Record<SkeletonVariant, React.CSSProperties> = {
rect: "h-24 w-full rounded-xl", 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({ export default function Skeleton({
@@ -21,12 +23,14 @@ export default function Skeleton({
}: SkeletonProps) { }: SkeletonProps) {
return ( return (
<div <div
className={` className={className}
animate-pulse aria-hidden="true"
bg-gray-200 dark:bg-gray-700 style={{
${variantClasses[variant]} ...variantStyle[variant],
${className} backgroundColor: "var(--background-contrast-grey)",
`} opacity: 0.6,
animation: "fr-skeleton-pulse 1.5s ease-in-out infinite",
}}
/> />
); );
} }
+27 -15
View File
@@ -19,33 +19,45 @@ export default function Slider({
label, label,
showValue = true, showValue = true,
}: SliderProps) { }: SliderProps) {
const rangeId = label
? `range-${label.toLowerCase().replace(/\s+/g, "-")}`
: "range";
return ( return (
<div className="w-full"> <div className="fr-range-group">
{(label || showValue) && ( {(label || showValue) && (
<div className="flex items-center justify-between mb-2"> <label className="fr-label" htmlFor={rangeId}>
{label && (
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{label} {label}
{showValue && (
<span className="fr-hint-text">Valeur sélectionnée : {value}</span>
)}
</label> </label>
)} )}
{showValue && ( <div
<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"> className="fr-range fr-range--sm"
{value} data-fr-js-range="true"
</span> data-fr-min={min}
)} data-fr-max={max}
</div> >
)} <span
className="fr-range__output"
aria-hidden="true"
>{`${value}`}</span>
<input <input
id={rangeId}
type="range" type="range"
min={min} min={min}
max={max} max={max}
value={value} value={value}
step={1}
onChange={(e) => onChange(Number(e.target.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"> <div className="fr-range__min" aria-hidden="true">
<span className="text-xs text-gray-400 dark:text-gray-500">{min}</span> {min}
<span className="text-xs text-gray-400 dark:text-gray-500">{max}</span> </div>
<div className="fr-range__max" aria-hidden="true">
{max}
</div>
</div> </div>
</div> </div>
); );
+25 -9
View File
@@ -1,35 +1,51 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { useTheme } from "next-themes";
import { Sun, Moon } from "lucide-react"; type Scheme = "light" | "dark";
export default function ThemeToggle() { export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = React.useState(false); const [mounted, setMounted] = React.useState(false);
const [scheme, setScheme] = React.useState<Scheme>("light");
React.useEffect(() => { React.useEffect(() => {
setMounted(true); 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) { if (!mounted) {
return ( return (
<button <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" aria-label="Changer le thème"
> >
<div className="h-5 w-5" /> Thème
</button> </button>
); );
} }
return ( return (
<button <button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} type="button"
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" onClick={toggle}
aria-label={theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"} 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> </button>
); );
} }