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
+107 -88
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 { 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
Nouvel article type="button"
</Button> className="fr-btn fr-btn--icon-left fr-icon-add-line"
onClick={openCreate}
>
Nouvel article
</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">
{feeds.map((feed) => ( <table>
<Card key={feed.id} className="flex flex-col sm:flex-row sm:items-center gap-4"> <thead>
<div className="flex-1 min-w-0"> <tr>
<div className="flex items-center gap-2 mb-1"> <th scope="col">Titre</th>
<h3 className="font-semibold text-gray-900 dark:text-white truncate"> <th scope="col">Slug</th>
{feed.titre} <th scope="col">Ordre</th>
</h3> <th scope="col">Statut</th>
<Badge color={feed.est_publie ? '#06c656' : '#9ca3af'}> <th scope="col">Actions</th>
{feed.est_publie ? 'Publié' : 'Brouillon'} </tr>
</Badge> </thead>
</div> <tbody>
<p className="text-sm text-gray-500 dark:text-gray-400"> {feeds.map((feed) => (
/{feed.slug} · Ordre: {feed.ordre} <tr key={feed.id}>
</p> <td>{feed.titre}</td>
</div> <td>/{feed.slug}</td>
<div className="flex items-center gap-2 shrink-0"> <td>{feed.ordre}</td>
<button <td>
onClick={() => openEdit(feed)} <span className={`fr-badge fr-badge--sm ${feed.est_publie ? 'fr-badge--success' : 'fr-badge--info'}`}>
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" {feed.est_publie ? 'Publié' : 'Brouillon'}
title="Modifier" </span>
> </td>
<Edit2 size={16} className="text-gray-500" /> <td>
</button> <ul className="fr-btns-group fr-btns-group--inline fr-btns-group--sm">
<button <li>
onClick={() => handleDelete(feed.id)} <button
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors" type="button"
title="Supprimer" onClick={() => openEdit(feed)}
> className="fr-btn fr-btn--sm fr-btn--tertiary fr-btn--icon-left fr-icon-edit-line"
<Trash2 size={16} className="text-red-500" /> title="Modifier"
</button> >
</div> Modifier
</Card> </button>
))} </li>
<li>
<button
type="button"
onClick={() => handleDelete(feed.id)}
className="fr-btn fr-btn--sm fr-btn--tertiary fr-btn--icon-left fr-icon-delete-line"
title="Supprimer"
>
Supprimer
</button>
</li>
</ul>
</td>
</tr>
))}
</tbody>
</table>
</div> </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">
Ordre d&apos;affichage <label className="fr-label" htmlFor="feed-ordre">
</label> Ordre d&apos;affichage
<input </label>
type="number" <input
value={form.ordre} id="feed-ordre"
onChange={(e) => setForm({ ...form, ordre: parseInt(e.target.value) || 0 })} type="number"
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" className="fr-input"
/> value={form.ordre}
onChange={(e) => setForm({ ...form, ordre: parseInt(e.target.value) || 0 })}
/>
</div>
</div> </div>
<div className="flex items-end pb-1"> <div className="fr-col-6">
<label className="flex items-center gap-2 cursor-pointer"> <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>
<div className="flex gap-3 pt-2"> <ul className="fr-btns-group fr-btns-group--inline fr-mt-2w">
<Button type="submit" loading={formLoading}> <li>
{editingFeed ? 'Mettre à jour' : 'Créer'} <Button type="submit" loading={formLoading}>
</Button> {editingFeed ? 'Mettre à jour' : 'Créer'}
<Button variant="ghost" onClick={() => setModalOpen(false)}> </Button>
Annuler </li>
</Button> <li>
</div> <Button variant="ghost" onClick={() => setModalOpen(false)}>
Annuler
</Button>
</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 Vue d&apos;ensemble de la plateforme CESIZen
</h1> </p>
<p className="text-gray-500 dark:text-gray-400">
Vue d&apos;ensemble de la plateforme CESIZen
</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">
</p> {loading ? '…' : card.value}
</p>
</div>
</div> </div>
</div> </div>
</Card> </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>
<Link </h3>
href="/admin/contenus" </div>
className="flex items-center gap-3 p-4 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" </div>
> </div>
<FileText size={20} className="text-malachite-500" /> </div>
<span className="font-medium text-gray-700 dark:text-gray-300">Gérer les contenus</span> ))}
</Link> </div>
<Link
href="/admin/emotions"
className="flex items-center gap-3 p-4 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<Heart size={20} className="text-candlelight-500" />
<span className="font-medium text-gray-700 dark:text-gray-300">Gérer les émotions</span>
</Link>
</div>
</Card>
</div> </div>
); );
} }
+219 -147
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,193 +97,268 @@ export default function AdminEmotionsPage() {
} }
}; };
const colorSwatch = (couleur: string) => (
<span
aria-hidden="true"
style={{
display: 'inline-block',
width: '1rem',
height: '1rem',
borderRadius: '50%',
backgroundColor: couleur,
verticalAlign: 'middle',
marginRight: '0.5rem',
}}
/>
);
return ( 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
Nouvelle émotion type="button"
</Button> className="fr-btn fr-btn--icon-left fr-icon-add-line"
onClick={() => openCreate()}
>
Nouvelle émotion
</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">
{parentEmotions.map((emotion) => ( <table>
<Card key={emotion.id}> <thead>
<div className="flex items-center gap-4 mb-3"> <tr>
<div <th scope="col">Émotion</th>
className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl shrink-0" <th scope="col">Niveau</th>
style={{ backgroundColor: `${emotion.couleur}20` }} <th scope="col">Couleur</th>
> <th scope="col">Statut</th>
{emotion.icone || '🔵'} <th scope="col">Actions</th>
</div> </tr>
<div className="flex-1 min-w-0"> </thead>
<div className="flex items-center gap-2"> <tbody>
<h3 className="font-semibold text-gray-900 dark:text-white"> {parentEmotions.map((emotion) => (
{emotion.nom} <FragmentRows
</h3> key={emotion.id}
<Badge color={emotion.couleur}>{emotion.couleur}</Badge> emotion={emotion}
{!emotion.est_actif && ( colorSwatch={colorSwatch}
<Badge color="#9ca3af">Inactif</Badge> onAddChild={openCreate}
)} onEdit={openEdit}
</div> onDelete={handleDelete}
<p className="text-sm text-gray-400">Niveau 1</p> />
</div> ))}
<div className="flex items-center gap-1 shrink-0"> </tbody>
<button </table>
onClick={() => openCreate(emotion.id)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title="Ajouter sous-émotion"
>
<Plus size={16} className="text-malachite-500" />
</button>
<button
onClick={() => openEdit(emotion)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title="Modifier"
>
<Edit2 size={16} className="text-gray-500" />
</button>
<button
onClick={() => handleDelete(emotion.id)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors"
title="Supprimer"
>
<Trash2 size={16} className="text-red-500" />
</button>
</div>
</div>
{/* Sous-émotions */}
{emotion.enfants && emotion.enfants.length > 0 && (
<div className="ml-8 pl-4 border-l-2 border-gray-200 dark:border-gray-700 space-y-2">
{emotion.enfants.map((child) => (
<div key={child.id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<span className="text-lg">{child.icone || '🔵'}</span>
<span className="flex-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{child.nom}
</span>
<div
className="w-4 h-4 rounded-full shrink-0"
style={{ backgroundColor: child.couleur }}
/>
{!child.est_actif && <Badge color="#9ca3af">Inactif</Badge>}
<button
onClick={() => openEdit(child)}
className="p-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<Edit2 size={14} className="text-gray-400" />
</button>
<button
onClick={() => handleDelete(child.id)}
className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors"
>
<Trash2 size={14} className="text-red-400" />
</button>
</div>
))}
</div>
)}
</Card>
))}
</div> </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
type="color" id="emotion-couleur"
value={form.couleur} type="color"
onChange={(e) => setForm({ ...form, couleur: e.target.value })} value={form.couleur}
className="w-10 h-10 rounded-lg border border-gray-300 dark:border-gray-700 cursor-pointer" onChange={(e) => setForm({ ...form, couleur: e.target.value })}
/> style={{ width: '2.5rem', height: '2.5rem', border: 'none', padding: 0, cursor: 'pointer' }}
<input />
type="text" </div>
value={form.couleur} <div className="fr-col">
onChange={(e) => setForm({ ...form, couleur: e.target.value })} <input
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" type="text"
/> className="fr-input"
value={form.couleur}
onChange={(e) => setForm({ ...form, couleur: e.target.value })}
/>
</div>
</div>
</div> </div>
</div> </div>
<Input <div className="fr-col-6">
label="Icône (emoji)" <Input
value={form.icone} label="Icône (emoji)"
onChange={(e) => setForm({ ...form, icone: e.target.value })} value={form.icone}
placeholder="😊" onChange={(e) => setForm({ ...form, icone: e.target.value })}
/> 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">
<Button type="submit" loading={formLoading}>
{editingEmotion ? 'Mettre à jour' : 'Créer'}
</Button>
<Button variant="ghost" onClick={() => setModalOpen(false)}>
Annuler
</Button>
</div> </div>
<ul className="fr-btns-group fr-btns-group--inline fr-mt-2w">
<li>
<Button type="submit" loading={formLoading}>
{editingEmotion ? 'Mettre à jour' : 'Créer'}
</Button>
</li>
<li>
<Button variant="ghost" onClick={() => setModalOpen(false)}>
Annuler
</Button>
</li>
</ul>
</form> </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,145 +73,153 @@ 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
Nouvel utilisateur type="button"
</Button> className="fr-btn fr-btn--icon-left fr-icon-add-line"
onClick={() => setCreateModalOpen(true)}
>
Nouvel utilisateur
</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>
<tr className="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700"> <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">Utilisateur</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">Email</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">Rôle</th> <th scope="col">Statut</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">Actions</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Actions</th> </tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5}>Chargement…</td>
</tr> </tr>
</thead> ) : filtered.length === 0 ? (
<tbody className="divide-y divide-gray-100 dark:divide-gray-800"> <tr>
{loading ? ( <td colSpan={5}>Aucun utilisateur trouvé</td>
Array.from({ length: 5 }).map((_, i) => ( </tr>
<tr key={i}> ) : (
<td colSpan={5} className="px-6 py-4"> filtered.map((user) => (
<div className="h-4 bg-gray-100 dark:bg-gray-800 rounded animate-pulse" /> <tr key={user.id}>
</td> <td>{user.prenom} {user.nom}</td>
</tr> <td>{user.email}</td>
)) <td>
) : filtered.length === 0 ? ( <span className={roleBadgeClass(user.role?.nom)}>
<tr> {user.role?.nom || 'N/A'}
<td colSpan={5} className="px-6 py-8 text-center text-gray-400"> </span>
Aucun utilisateur trouvé
</td> </td>
</tr> <td>
) : ( <span className={`fr-badge fr-badge--sm ${user.est_actif ? 'fr-badge--success' : 'fr-badge--error'}`}>
filtered.map((user) => ( {user.est_actif ? 'Actif' : 'Inactif'}
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"> </span>
<td className="px-6 py-4"> </td>
<span className="font-medium text-gray-900 dark:text-white"> <td>
{user.prenom} {user.nom} <ul className="fr-btns-group fr-btns-group--inline fr-btns-group--sm">
</span> <li>
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{user.email}
</td>
<td className="px-6 py-4">
<Badge color={roleColor(user.role?.nom)}>
{user.role?.nom || 'N/A'}
</Badge>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center gap-1.5 text-sm ${user.est_actif ? 'text-malachite-600' : 'text-red-500'}`}>
<span className={`w-2 h-2 rounded-full ${user.est_actif ? 'bg-malachite-500' : 'bg-red-500'}`} />
{user.est_actif ? 'Actif' : 'Inactif'}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-1">
<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>
</td> </ul>
</tr> </td>
)) </tr>
)} ))
</tbody> )}
</table> </tbody>
</div> </table>
</Card> </div>
{/* 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">
<Input label="Prénom" value={newUser.prenom} onChange={(e) => setNewUser({ ...newUser, prenom: e.target.value })} required /> <div className="fr-col-6">
<Input label="Nom" value={newUser.nom} onChange={(e) => setNewUser({ ...newUser, nom: 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 />
</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">
<Button type="submit" loading={formLoading}>Créer</Button> <li>
<Button variant="ghost" onClick={() => setCreateModalOpen(false)}>Annuler</Button> <Button type="submit" loading={formLoading}>Créer</Button>
</div> </li>
<li>
<Button variant="ghost" onClick={() => setCreateModalOpen(false)}>Annuler</Button>
</li>
</ul>
</form> </form>
</Modal> </Modal>
</div> </div>
+10 -8
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">
<Sidebar /> <div className="fr-grid-row fr-grid-row--gutters">
<main className="flex-1 bg-gray-50 dark:bg-gray-950"> <div className="fr-col-12 fr-col-md-3 fr-col-lg-2">
<div className="p-6 lg:p-8"> <Sidebar />
{children}
</div> </div>
</main> <main className="fr-col-12 fr-col-md-9 fr-col-lg-10" id="contenu">
{children}
</main>
</div>
</div> </div>
</div> </>
); );
} }
+67 -83
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"> {errorMessage && (
<span className="text-malachite-600 dark:text-malachite-400">CESI</span> <div className="fr-alert fr-alert--error fr-alert--sm fr-mb-3w" role="alert">
<span className="text-candlelight-500">ZEN</span> <p>{errorMessage}</p>
</h1> </div>
</Link> )}
<p className="text-gray-500 dark:text-gray-400 mt-2">Connectez-vous à votre compte</p>
<form onSubmit={handleSubmit}>
<div className="fr-input-group">
<label className="fr-label" htmlFor="email">
Adresse email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="fr-input"
placeholder="vous@exemple.fr"
autoComplete="email"
/>
</div> </div>
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-800 p-8"> <div className="fr-password" id="password-group">
<form onSubmit={handleSubmit} className="space-y-5"> <label className="fr-label" htmlFor="password">
<div> Mot de passe
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> </label>
Adresse email <div className="fr-input-wrap">
</label> <input
<div className="relative"> id="password"
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> type={showPassword ? 'text' : 'password'}
<input value={password}
id="email" onChange={(e) => setPassword(e.target.value)}
type="email" required
value={email} className="fr-password__input fr-input"
onChange={(e) => setEmail(e.target.value)} placeholder="Votre mot de passe"
required autoComplete="current-password"
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all" />
placeholder="vous@exemple.fr" </div>
/> <div className="fr-password__checkbox fr-checkbox-group fr-checkbox-group--sm">
</div> <input
</div> id="password-show"
type="checkbox"
<div> checked={showPassword}
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> onChange={() => setShowPassword(!showPassword)}
Mot de passe />
</label> <label className="fr-label" htmlFor="password-show">
<div className="relative"> Afficher le mot de passe
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> </label>
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full pl-11 pr-12 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
placeholder="Votre mot de passe"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-candlelight-500 hover:bg-candlelight-600 disabled:opacity-50 disabled:cursor-not-allowed text-black font-semibold py-3 rounded-xl transition-all duration-200 hover:shadow-lg"
>
{loading ? (
<span className="inline-flex items-center gap-2">
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Connexion...
</span>
) : (
'Se connecter'
)}
</button>
</form>
<div className="mt-6 text-center text-sm text-gray-500 dark:text-gray-400">
Pas encore de compte ?{' '}
<Link href="/inscription" className="text-malachite-600 dark:text-malachite-400 font-medium hover:underline">
Créer un compte
</Link>
</div> </div>
</div> </div>
</div>
</div> <button type="submit" disabled={loading} className="fr-btn fr-btn--lg">
{loading ? 'Connexion…' : 'Se connecter'}
</button>
</form>
<p className="fr-mt-4w">
Pas encore de compte ?{' '}
<Link href="/inscription" className="fr-link">
Créer un compte
</Link>
</p>
</>
); );
} }
+163 -160
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,174 +49,178 @@ 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>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="prenom"
name="prenom"
type="text"
value={form.prenom}
onChange={handleChange}
required
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
placeholder="Jean"
/>
</div>
{errors.prenom && <p className="text-red-500 text-xs mt-1">{errors.prenom[0]}</p>}
</div>
<div>
<label htmlFor="nom" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Nom
</label>
<input
id="nom"
name="nom"
type="text"
value={form.nom}
onChange={handleChange}
required
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
placeholder="Dupont"
/>
{errors.nom && <p className="text-red-500 text-xs mt-1">{errors.nom[0]}</p>}
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Adresse email
</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
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
required
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
placeholder="vous@exemple.fr"
/>
</div>
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email[0]}</p>}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Mot de passe
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={handleChange}
required
minLength={8}
className="w-full pl-11 pr-12 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
placeholder="Minimum 8 caractères"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password[0]}</p>}
</div>
<div>
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Confirmer le mot de passe
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="password_confirmation"
name="password_confirmation"
type={showPassword ? 'text' : 'password'}
value={form.password_confirmation}
onChange={handleChange}
required
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
placeholder="Retapez votre mot de passe"
/>
</div>
</div>
<div className="flex items-start gap-3 pt-2">
<input <input
id="consentement_rgpd" id="prenom"
name="consentement_rgpd" name="prenom"
type="checkbox" type="text"
checked={form.consentement_rgpd} value={form.prenom}
onChange={handleChange} onChange={handleChange}
required required
className="mt-1 w-4 h-4 rounded border-gray-300 text-candlelight-500 focus:ring-candlelight-500" className="fr-input"
placeholder="Jean"
aria-describedby={errors.prenom ? 'prenom-error' : undefined}
/> />
<label htmlFor="consentement_rgpd" className="text-sm text-gray-600 dark:text-gray-400"> {errors.prenom && (
J&apos;accepte le traitement de mes données personnelles conformément à la{' '} <p id="prenom-error" className="fr-error-text">
<Link href="/politique-de-confidentialite" className="text-malachite-600 dark:text-malachite-400 hover:underline"> {errors.prenom[0]}
politique de confidentialité </p>
</Link>
. Mes données sont chiffrées et je peux supprimer mon compte à tout moment.
</label>
</div>
{errors.consentement_rgpd && <p className="text-red-500 text-xs">{errors.consentement_rgpd[0]}</p>}
<button
type="submit"
disabled={loading || !form.consentement_rgpd}
className="w-full bg-malachite-500 hover:bg-malachite-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold py-3 rounded-xl transition-all duration-200 hover:shadow-lg mt-2"
>
{loading ? (
<span className="inline-flex items-center gap-2">
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Inscription...
</span>
) : (
'Créer mon compte'
)} )}
</button> </div>
</form> </div>
<div className="mt-6 text-center text-sm text-gray-500 dark:text-gray-400"> <div className="fr-col-12 fr-col-md-6">
Déjà un compte ?{' '} <div className={`fr-input-group${errors.nom ? ' fr-input-group--error' : ''}`}>
<Link href="/connexion" className="text-malachite-600 dark:text-malachite-400 font-medium hover:underline"> <label className="fr-label" htmlFor="nom">
Se connecter Nom
</Link> </label>
<input
id="nom"
name="nom"
type="text"
value={form.nom}
onChange={handleChange}
required
className="fr-input"
placeholder="Dupont"
aria-describedby={errors.nom ? 'nom-error' : undefined}
/>
{errors.nom && (
<p id="nom-error" className="fr-error-text">
{errors.nom[0]}
</p>
)}
</div>
</div> </div>
</div> </div>
</div>
</div> <div className={`fr-input-group${errors.email ? ' fr-input-group--error' : ''}`}>
<label className="fr-label" htmlFor="email">
Adresse email
</label>
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
required
className="fr-input"
placeholder="vous@exemple.fr"
autoComplete="email"
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<p id="email-error" className="fr-error-text">
{errors.email[0]}
</p>
)}
</div>
<div className={`fr-password${errors.password ? ' fr-input-group--error' : ''}`}>
<label className="fr-label" htmlFor="password">
Mot de passe
</label>
<div className="fr-input-wrap">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={handleChange}
required
minLength={8}
className="fr-password__input fr-input"
placeholder="Minimum 8 caractères"
autoComplete="new-password"
aria-describedby={errors.password ? 'password-error' : undefined}
/>
</div>
{errors.password && (
<p id="password-error" className="fr-error-text">
{errors.password[0]}
</p>
)}
</div>
<div className="fr-input-group">
<label className="fr-label" htmlFor="password_confirmation">
Confirmer le mot de passe
</label>
<input
id="password_confirmation"
name="password_confirmation"
type={showPassword ? 'text' : 'password'}
value={form.password_confirmation}
onChange={handleChange}
required
className="fr-input"
placeholder="Retapez votre mot de passe"
autoComplete="new-password"
/>
</div>
<div className="fr-checkbox-group fr-checkbox-group--sm">
<input
id="password-show"
type="checkbox"
checked={showPassword}
onChange={() => setShowPassword(!showPassword)}
/>
<label className="fr-label" htmlFor="password-show">
Afficher les mots de passe
</label>
</div>
<div className={`fr-checkbox-group fr-mt-3w${errors.consentement_rgpd ? ' fr-input-group--error' : ''}`}>
<input
id="consentement_rgpd"
name="consentement_rgpd"
type="checkbox"
checked={form.consentement_rgpd}
onChange={handleChange}
required
aria-describedby={errors.consentement_rgpd ? 'consentement-error' : undefined}
/>
<label className="fr-label" htmlFor="consentement_rgpd">
J&apos;accepte le traitement de mes données personnelles conformément à la{' '}
<Link href="/politique-de-confidentialite" className="fr-link">
politique de confidentialité
</Link>
. Mes données sont chiffrées et je peux supprimer mon compte à tout moment.
</label>
{errors.consentement_rgpd && (
<p id="consentement-error" className="fr-error-text">
{errors.consentement_rgpd[0]}
</p>
)}
</div>
<button
type="submit"
disabled={loading || !form.consentement_rgpd}
className="fr-btn fr-btn--lg fr-mt-3w"
>
{loading ? 'Inscription…' : 'Créer mon compte'}
</button>
</form>
<p className="fr-mt-4w">
Déjà un compte ?{' '}
<Link href="/connexion" className="fr-link">
Se connecter
</Link>
</p>
</>
); );
} }
+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>
);
}
+170 -149
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} 👋 Voici un aperçu de votre bien-être cette semaine.
</h1> </p>
<p className="text-gray-500 dark:text-gray-400">
Voici un aperçu de votre bien-être cette semaine.
</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">
<Link href="/journal/nouvelle-saisie"> <div className="fr-col-12 fr-col-md-4">
<Card hover className="flex items-center gap-4 bg-linear-to-r from-candlelight-50 to-candlelight-100 dark:from-candlelight-950/30 dark:to-candlelight-900/20 border-candlelight-200 dark:border-candlelight-800"> <div className="fr-tile fr-tile--horizontal fr-enlarge-link">
<div className="w-12 h-12 rounded-xl bg-candlelight-200 dark:bg-candlelight-900 flex items-center justify-center"> <div className="fr-tile__body">
<Plus className="text-candlelight-700 dark:text-candlelight-400" size={24} /> <div className="fr-tile__content">
<h3 className="fr-tile__title">
<Link href="/journal/nouvelle-saisie">
<span
className="fr-icon-add-line fr-mr-1w"
aria-hidden="true"
/>
Nouvelle saisie
</Link>
</h3>
<p className="fr-tile__detail">Enregistrer une émotion</p>
</div>
</div> </div>
<div> </div>
<p className="font-semibold text-gray-900 dark:text-white">Nouvelle saisie</p> </div>
<p className="text-sm text-gray-500 dark:text-gray-400">Enregistrer une émotion</p>
</div>
</Card>
</Link>
<Link href="/journal"> <div className="fr-col-12 fr-col-md-4">
<Card hover className="flex items-center gap-4"> <div className="fr-tile fr-tile--horizontal fr-enlarge-link">
<div className="w-12 h-12 rounded-xl bg-malachite-100 dark:bg-malachite-900 flex items-center justify-center"> <div className="fr-tile__body">
<BookOpen className="text-malachite-600 dark:text-malachite-400" size={24} /> <div className="fr-tile__content">
<h3 className="fr-tile__title">
<Link href="/journal">
<span
className="fr-icon-book-2-line fr-mr-1w"
aria-hidden="true"
/>
Mon journal
</Link>
</h3>
<p className="fr-tile__detail">Voir toutes mes saisies</p>
</div>
</div> </div>
<div> </div>
<p className="font-semibold text-gray-900 dark:text-white">Mon journal</p> </div>
<p className="text-sm text-gray-500 dark:text-gray-400">Voir toutes mes saisies</p>
</div>
</Card>
</Link>
<Link href="/journal/rapports"> <div className="fr-col-12 fr-col-md-4">
<Card hover className="flex items-center gap-4"> <div className="fr-tile fr-tile--horizontal fr-enlarge-link">
<div className="w-12 h-12 rounded-xl bg-blue-100 dark:bg-blue-900 flex items-center justify-center"> <div className="fr-tile__body">
<BarChart3 className="text-blue-600 dark:text-blue-400" size={24} /> <div className="fr-tile__content">
<h3 className="fr-tile__title">
<Link href="/journal/rapports">
<span
className="fr-icon-line-chart-line fr-mr-1w"
aria-hidden="true"
/>
Mes rapports
</Link>
</h3>
<p className="fr-tile__detail">Visualiser mes tendances</p>
</div>
</div> </div>
<div> </div>
<p className="font-semibold text-gray-900 dark:text-white">Mes rapports</p> </div>
<p className="text-sm text-gray-500 dark:text-gray-400">Visualiser mes tendances</p>
</div>
</Card>
</Link>
</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">
{rapport?.stats.total_saisies || 0} <div className="fr-tile__content">
</p> <p className="fr-tile__detail">Saisies cette semaine</p>
</Card> <p className="fr-h2 fr-mb-0">
<Card> {rapport?.stats.total_saisies || 0}
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Intensité moyenne</p> </p>
<div className="flex items-center gap-2"> </div>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{rapport?.stats.intensite_moyenne?.toFixed(1) || '—'}
</p>
<span className="text-sm text-gray-400">/10</span>
</div>
</Card>
<Card>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Émotion dominante</p>
{rapport?.stats.emotion_dominante ? (
<div className="flex items-center gap-2">
<Badge color={rapport.stats.emotion_dominante.couleur}>
{rapport.stats.emotion_dominante.nom}
</Badge>
<span className="text-sm text-gray-400">
({rapport.stats.emotion_dominante.count}x)
</span>
</div> </div>
) : ( </div>
<p className="text-xl text-gray-300 dark:text-gray-600">—</p> </div>
)} <div className="fr-col-12 fr-col-md-4">
</Card> <div className="fr-tile fr-tile--vertical">
<div className="fr-tile__body">
<div className="fr-tile__content">
<p className="fr-tile__detail">Intensité moyenne</p>
<p className="fr-h2 fr-mb-0">
{rapport?.stats.intensite_moyenne?.toFixed(1) || '—'}
<span className="fr-text--sm"> /10</span>
</p>
</div>
</div>
</div>
</div>
<div className="fr-col-12 fr-col-md-4">
<div className="fr-tile fr-tile--vertical">
<div className="fr-tile__body">
<div className="fr-tile__content">
<p className="fr-tile__detail">Émotion dominante</p>
{rapport?.stats.emotion_dominante ? (
<p className="fr-mb-0">
<span className="fr-badge fr-badge--info">
{rapport.stats.emotion_dominante.nom}
</span>
<span className="fr-text--sm">
{' '}
({rapport.stats.emotion_dominante.count}x)
</span>
</p>
) : (
<p className="fr-h4 fr-mb-0">—</p>
)}
</div>
</div>
</div>
</div>
</> </>
)} )}
</div> </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
> </Link>
Voir tout → </p>
</Link> </div>
</div>
{saisiesLoading ? ( {saisiesLoading ? (
<div className="space-y-3"> <p>Chargement…</p>
{Array.from({ length: 3 }).map((_, i) => ( ) : recentSaisies.length > 0 ? (
<div key={i} className="flex items-center gap-3"> <ul className="fr-raw-list">
<Skeleton variant="circle" /> {recentSaisies.map((saisie) => (
<div className="flex-1"> <li
<Skeleton className="h-4 w-32 mb-1" /> key={saisie.id}
<Skeleton className="h-3 w-48" /> className="fr-py-1w"
</div> style={{ borderBottom: '1px solid var(--border-default-grey)' }}
</div> >
))} <div className="fr-grid-row fr-grid-row--middle">
</div> <span className="fr-mr-2w" style={{ fontSize: '1.5rem' }}>
) : recentSaisies.length > 0 ? ( {saisie.emotion?.icone || '🔵'}
<div className="space-y-3"> </span>
{recentSaisies.map((saisie) => ( <span className="fr-col">
<div <strong>{saisie.emotion?.nom}</strong>{' '}
key={saisie.id} <span className="fr-text--sm">
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors" {saisie.intensite}/10
> </span>
<div {saisie.note && (
className="w-10 h-10 rounded-lg flex items-center justify-center text-lg shrink-0" <span className="fr-text--sm fr-text-mention--grey fr-display-block">
style={{ backgroundColor: `${saisie.emotion?.couleur}20` }} {saisie.note}
</span>
)}
</span>
<span className="fr-text--sm fr-text-mention--grey">
{new Date(saisie.date_saisie).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</div>
</li>
))}
</ul>
) : (
<div className="fr-callout">
<p className="fr-callout__text">Aucune saisie pour le moment.</p>
<Link
className="fr-btn fr-btn--sm fr-icon-add-line fr-btn--icon-left"
href="/journal/nouvelle-saisie"
> >
{saisie.emotion?.icone || '🔵'} Faire ma première saisie
</div> </Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white text-sm">
{saisie.emotion?.nom}
</span>
<span className="text-xs text-gray-400">
{saisie.intensite}/10
</span>
</div>
{saisie.note && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{saisie.note}
</p>
)}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
{new Date(saisie.date_saisie).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</div> </div>
))} )}
</div> </div>
) : ( </div>
<div className="text-center py-8 text-gray-400 dark:text-gray-600"> </div>
<Heart size={32} className="mx-auto mb-2 opacity-50" />
<p>Aucune saisie pour le moment</p>
<Link
href="/journal/nouvelle-saisie"
className="text-sm text-candlelight-600 hover:underline mt-2 inline-block"
>
Faire ma première saisie
</Link>
</div>
)}
</Card>
</div> </div>
); );
} }
@@ -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,139 +46,144 @@ 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">
<button <div className="fr-grid-row fr-grid-row--center">
onClick={() => router.back()} <div className="fr-col-12 fr-col-md-8">
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" <button
> type="button"
<ArrowLeft size={16} /> onClick={() => router.back()}
Retour className="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-icon-arrow-left-line fr-btn--icon-left fr-mb-2w"
</button> >
Retour
</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> Enregistrez votre émotion en quelques étapes simples.
<p className="text-gray-500 dark:text-gray-400 mb-8"> </p>
Enregistrez votre émotion en quelques étapes simples.
</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 {nextStep && (
? 'bg-candlelight-500 text-black' <p className="fr-stepper__details">
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400' <span className="fr-text--bold">Étape suivante :</span> {nextStep}
} </p>
`}
>
{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'
}`}
/>
)} )}
</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} {STEPS[step - 1].description}
</h2> </p>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{STEPS[step - 1].description}
</p>
{step === 1 && ( {step === 1 && (
<EmotionPicker value={emotionId} onChange={setEmotionId} /> <EmotionPicker value={emotionId} onChange={setEmotionId} />
)} )}
{step === 2 && ( {step === 2 && (
<div className="space-y-6"> <div>
<Slider <Slider
value={intensite} value={intensite}
onChange={setIntensite} onChange={setIntensite}
min={1} min={1}
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
</div> </span>
<span className="fr-col fr-text--sm" style={{ textAlign: 'right' }}>
Forte
</span>
</div>
</div>
)}
{step === 3 && (
<div>
<div className="fr-input-group">
<label className="fr-label" htmlFor="saisie-date">
Date de la saisie
</label>
<input
id="saisie-date"
className="fr-input"
type="date"
value={dateSaisie}
onChange={(e) => setDateSaisie(e.target.value)}
/>
</div>
<div className="fr-input-group">
<label className="fr-label" htmlFor="saisie-note">
Note (optionnel)
</label>
<textarea
id="saisie-note"
className="fr-input"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Décrivez ce que vous ressentez, ce qui a déclenché cette émotion..."
rows={4}
/>
</div>
</div>
)}
</div> </div>
)}
{step === 3 && ( {/* Navigation */}
<div className="space-y-4"> <ul className="fr-btns-group fr-btns-group--inline-md">
<div> <li>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <button
Date de la saisie type="button"
</label> className="fr-btn fr-btn--secondary fr-icon-arrow-left-line fr-btn--icon-left"
<input onClick={() => setStep(step - 1)}
type="date" disabled={step === 1}
value={dateSaisie} >
onChange={(e) => setDateSaisie(e.target.value)} Précédent
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" </button>
/> </li>
</div> {step < 3 ? (
<div> <li>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> <button
Note (optionnel) type="button"
</label> className="fr-btn fr-icon-arrow-right-line fr-btn--icon-right"
<textarea onClick={() => setStep(step + 1)}
value={note} disabled={!canNext()}
onChange={(e) => setNote(e.target.value)} >
placeholder="Décrivez ce que vous ressentez, ce qui a déclenché cette émotion..." Suivant
rows={4} </button>
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" </li>
/> ) : (
</div> <li>
</div> <button
)} type="button"
</Card> className="fr-btn fr-icon-check-line fr-btn--icon-left"
onClick={handleSubmit}
{/* Navigation */} disabled={!emotionId || createSaisie.isPending}
<div className="flex justify-between"> >
<Button Enregistrer
variant="ghost" </button>
onClick={() => setStep(step - 1)} </li>
disabled={step === 1} )}
> </ul>
<ArrowLeft size={16} /> </div>
Précédent
</Button>
{step < 3 ? (
<Button
onClick={() => setStep(step + 1)}
disabled={!canNext()}
>
Suivant
<ArrowRight size={16} />
</Button>
) : (
<Button
onClick={handleSubmit}
loading={createSaisie.isPending}
disabled={!emotionId}
>
<Check size={16} />
Enregistrer
</Button>
)}
</div> </div>
</div> </div>
); );
+105 -90
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,107 +67,126 @@ 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
onClick={() => setShowFilters(!showFilters)} type="button"
> className="fr-btn fr-btn--tertiary fr-btn--sm fr-icon-filter-line fr-btn--icon-left"
<Filter size={16} /> onClick={() => setShowFilters(!showFilters)}
Filtrer >
</Button> Filtrer
<Button variant="ghost" size="sm" onClick={handleExport} title="Exporter mes saisies (RGPD)"> </button>
<Download size={16} /> </li>
Export CSV <li>
</Button> <button
<Link href="/journal/nouvelle-saisie"> type="button"
<Button size="sm"> className="fr-btn fr-btn--tertiary fr-btn--sm fr-icon-download-line fr-btn--icon-left"
<Plus size={16} /> onClick={handleExport}
Nouvelle saisie title="Exporter mes saisies (RGPD)"
</Button> >
</Link> Export CSV
</button>
</li>
<li>
<Link
href="/journal/nouvelle-saisie"
className="fr-btn fr-btn--sm fr-icon-add-line fr-btn--icon-left"
>
Nouvelle saisie
</Link>
</li>
</ul>
</div> </div>
</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">
Date début <label className="fr-label" htmlFor="filtre-date-debut">
</label> Date début
<input </label>
type="date" <input
value={filters.date_debut || ''} id="filtre-date-debut"
onChange={(e) => setFilters({ ...filters, date_debut: e.target.value || undefined })} className="fr-input"
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" type="date"
/> value={filters.date_debut || ''}
onChange={(e) =>
setFilters({ ...filters, date_debut: e.target.value || undefined })
}
/>
</div>
</div> </div>
<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">
Date fin <label className="fr-label" htmlFor="filtre-date-fin">
</label> Date fin
<input </label>
type="date" <input
value={filters.date_fin || ''} id="filtre-date-fin"
onChange={(e) => setFilters({ ...filters, date_fin: e.target.value || undefined })} className="fr-input"
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" type="date"
/> value={filters.date_fin || ''}
onChange={(e) =>
setFilters({ ...filters, date_fin: e.target.value || undefined })
}
/>
</div>
</div> </div>
<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-select-group">
Émotion <label className="fr-label" htmlFor="filtre-emotion">
</label> Émotion
<select </label>
value={filters.emotion_id || ''} <select
onChange={(e) => setFilters({ ...filters, emotion_id: e.target.value ? Number(e.target.value) : undefined })} id="filtre-emotion"
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" className="fr-select"
value={filters.emotion_id || ''}
onChange={(e) =>
setFilters({
...filters,
emotion_id: e.target.value ? Number(e.target.value) : undefined,
})
}
>
<option value="">Toutes</option>
{emotions?.map((emotion) => (
<option key={emotion.id} value={emotion.id}>
{emotion.icone} {emotion.nom}
</option>
))}
</select>
</div>
</div>
</div>
<ul className="fr-btns-group fr-btns-group--inline-md fr-mt-2w">
<li>
<button
type="button"
className="fr-btn fr-btn--tertiary fr-btn--sm"
onClick={() => setFilters({})}
> >
<option value="">Toutes</option> Réinitialiser
{emotions?.map((emotion) => ( </button>
<option key={emotion.id} value={emotion.id}> </li>
{emotion.icone} {emotion.nom} </ul>
</option> </div>
))}
</select>
</div>
</div>
<div className="mt-4 flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setFilters({})}
>
Réinitialiser
</Button>
</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">
{PERIODS.map((p) => ( <fieldset className="fr-segmented fr-segmented--sm">
<button <legend className="fr-segmented__legend">Période</legend>
key={p.value} <div className="fr-segmented__elements">
onClick={() => setPeriod(p.value)} {PERIODS.map((p) => (
className={` <div className="fr-segmented__element" key={p.value}>
px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 <input
${period === p.value type="radio"
? 'bg-white dark:bg-gray-700 shadow-sm text-gray-900 dark:text-white' id={`period-${p.value}`}
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200' name="period"
} checked={period === p.value}
`} onChange={() => setPeriod(p.value)}
> />
{p.label} <label className="fr-label" htmlFor={`period-${p.value}`}>
</button> {p.label}
))} </label>
</div>
))}
</div>
</fieldset>
</div> </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">
</div> <h3 className="fr-tile__title">
<p className="text-3xl font-bold text-gray-900 dark:text-white"> <span
{rapport.stats.total_saisies} className="fr-icon-bar-chart-box-line fr-mr-1w"
</p> aria-hidden="true"
<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')} Total saisies
</p> </h3>
</Card> <p className="fr-h3 fr-mb-1v">{rapport.stats.total_saisies}</p>
<p className="fr-tile__detail">
<Card> Du {new Date(rapport.date_debut).toLocaleDateString('fr-FR')} au{' '}
<div className="flex items-center gap-3 mb-2"> {new Date(rapport.date_fin).toLocaleDateString('fr-FR')}
<TrendingUp size={18} className="text-malachite-500" /> </p>
<p className="text-sm text-gray-500 dark:text-gray-400">Intensité moyenne</p> </div>
</div>
<div className="flex items-baseline gap-2">
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}
</p>
<span className="text-sm text-gray-400">/10</span>
</div>
</Card>
<Card>
<div className="flex items-center gap-3 mb-2">
<PieChart size={18} className="text-blue-500" />
<p className="text-sm text-gray-500 dark:text-gray-400">Émotion dominante</p>
</div>
{rapport.stats.emotion_dominante ? (
<div>
<Badge color={rapport.stats.emotion_dominante.couleur}>
{rapport.stats.emotion_dominante.nom}
</Badge>
<p className="text-xs text-gray-400 mt-1">
{rapport.stats.emotion_dominante.count} fois
</p>
</div> </div>
) : ( </div>
<p className="text-xl text-gray-300 dark:text-gray-600">—</p> </div>
)}
</Card> <div className="fr-col-12 fr-col-md-4">
<div className="fr-tile fr-tile--vertical">
<div className="fr-tile__body">
<div className="fr-tile__content">
<h3 className="fr-tile__title">
<span
className="fr-icon-line-chart-line fr-mr-1w"
aria-hidden="true"
/>
Intensité moyenne
</h3>
<p className="fr-h3 fr-mb-0">
{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}
<span className="fr-text--sm"> /10</span>
</p>
</div>
</div>
</div>
</div>
<div className="fr-col-12 fr-col-md-4">
<div className="fr-tile fr-tile--vertical">
<div className="fr-tile__body">
<div className="fr-tile__content">
<h3 className="fr-tile__title">
<span
className="fr-icon-pie-chart-2-line fr-mr-1w"
aria-hidden="true"
/>
Émotion dominante
</h3>
{rapport.stats.emotion_dominante ? (
<p className="fr-mb-0">
<span className="fr-badge fr-badge--info">
{rapport.stats.emotion_dominante.nom}
</span>
<span className="fr-text--sm fr-display-block fr-mt-1v">
{rapport.stats.emotion_dominante.count} fois
</span>
</p>
) : (
<p className="fr-h4 fr-mb-0">—</p>
)}
</div>
</div>
</div>
</div>
</div> </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>
))} ))}
</div> </ul>
</Card> </div>
)} )}
</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>
); );
+214 -145
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,157 +73,229 @@ 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>
{editing ? (
<form onSubmit={handleUpdateProfil}>
<div className="fr-grid-row fr-grid-row--gutters">
<div className="fr-col-12 fr-col-md-6">
<div className="fr-input-group">
<label className="fr-label" htmlFor="profil-prenom">
Prénom
</label>
<input
id="profil-prenom"
className="fr-input"
value={form.prenom}
onChange={(e) => setForm({ ...form, prenom: e.target.value })}
required
/>
</div>
</div>
<div className="fr-col-12 fr-col-md-6">
<div className="fr-input-group">
<label className="fr-label" htmlFor="profil-nom">
Nom
</label>
<input
id="profil-nom"
className="fr-input"
value={form.nom}
onChange={(e) => setForm({ ...form, nom: e.target.value })}
required
/>
</div>
</div>
</div>
<div className="fr-input-group">
<label className="fr-label" htmlFor="profil-email">
Email
</label>
<input
id="profil-email"
className="fr-input"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
/>
</div>
<ul className="fr-btns-group fr-btns-group--inline-md">
<li>
<Button type="submit" loading={loading}>
Enregistrer
</Button>
</li>
<li>
<Button variant="secondary" type="button" onClick={() => setEditing(false)}>
Annuler
</Button>
</li>
</ul>
</form>
) : (
<Button
variant="secondary"
onClick={() => {
setForm({
nom: user?.nom || '',
prenom: user?.prenom || '',
email: user?.email || '',
});
setEditing(true);
}}
>
Modifier mes informations
</Button>
)}
</div> </div>
</div>
{editing ? ( {/* Changer mot de passe */}
<form onSubmit={handleUpdateProfil} className="space-y-4"> <div className="fr-card fr-p-3w fr-mb-3w">
<div className="grid sm:grid-cols-2 gap-4"> <h2 className="fr-h5">
<Input <span className="fr-icon-lock-line fr-mr-1w" aria-hidden="true" />
label="Prénom" Mot de passe
value={form.prenom} </h2>
onChange={(e) => setForm({ ...form, prenom: e.target.value })}
icon={<User size={18} />} {changingPassword ? (
required <form onSubmit={handleChangePassword}>
/> <div className="fr-password fr-input-group">
<Input <label className="fr-label" htmlFor="pwd-actuel">
label="Nom" Mot de passe actuel
value={form.nom} </label>
onChange={(e) => setForm({ ...form, nom: e.target.value })} <input
icon={<User size={18} />} id="pwd-actuel"
required className="fr-input"
/> type="password"
</div> value={passwordForm.ancien_mot_de_passe}
<Input onChange={(e) =>
label="Email" setPasswordForm({ ...passwordForm, ancien_mot_de_passe: e.target.value })
type="email" }
value={form.email} required
onChange={(e) => setForm({ ...form, email: e.target.value })} />
icon={<Mail size={18} />} </div>
required <div className="fr-password fr-input-group">
/> <label className="fr-label" htmlFor="pwd-nouveau">
<div className="flex gap-3"> Nouveau mot de passe
<Button type="submit" loading={loading}> </label>
Enregistrer <input
id="pwd-nouveau"
className="fr-input"
type="password"
value={passwordForm.password}
onChange={(e) =>
setPasswordForm({ ...passwordForm, password: e.target.value })
}
required
/>
</div>
<div className="fr-password fr-input-group">
<label className="fr-label" htmlFor="pwd-confirm">
Confirmer le nouveau mot de passe
</label>
<input
id="pwd-confirm"
className="fr-input"
type="password"
value={passwordForm.password_confirmation}
onChange={(e) =>
setPasswordForm({
...passwordForm,
password_confirmation: e.target.value,
})
}
required
/>
</div>
<ul className="fr-btns-group fr-btns-group--inline-md">
<li>
<Button type="submit" loading={loading}>
Changer le mot de passe
</Button>
</li>
<li>
<Button
variant="secondary"
type="button"
onClick={() => setChangingPassword(false)}
>
Annuler
</Button>
</li>
</ul>
</form>
) : (
<Button variant="secondary" onClick={() => setChangingPassword(true)}>
Changer mon mot de passe
</Button> </Button>
<Button variant="ghost" onClick={() => setEditing(false)}> )}
Annuler </div>
</Button>
</div>
</form>
) : (
<Button variant="outline" onClick={() => {
setForm({ nom: user?.nom || '', prenom: user?.prenom || '', email: user?.email || '' });
setEditing(true);
}}>
Modifier mes informations
</Button>
)}
</Card>
{/* Changer mot de passe */} {/* Zone dangereuse — RGPD */}
<Card className="mb-6"> <div className="fr-card fr-p-3w">
<div className="flex items-center gap-3 mb-4"> <h2 className="fr-h5">
<Lock size={20} className="text-gray-400" /> <span className="fr-icon-warning-line fr-mr-1w" aria-hidden="true" />
<h3 className="font-display font-semibold text-gray-900 dark:text-white"> Zone dangereuse
Mot de passe </h2>
</h3> <p className="fr-text--sm">
La suppression de votre compte est irréversible. Toutes vos données
seront effacées conformément au RGPD.
</p>
<Button
variant="secondary"
className="fr-icon-delete-line fr-btn--icon-left"
onClick={() => setDeleteModalOpen(true)}
>
Supprimer mon compte
</Button>
</div>
{/* Modal confirmation suppression */}
<Modal
isOpen={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
title="Confirmer la suppression"
size="sm"
>
<p>
Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est
irréversible.
</p>
<ul className="fr-btns-group fr-btns-group--inline-md">
<li>
<Button variant="secondary" onClick={handleDeleteAccount} loading={loading}>
Oui, supprimer
</Button>
</li>
<li>
<Button variant="secondary" onClick={() => setDeleteModalOpen(false)}>
Annuler
</Button>
</li>
</ul>
</Modal>
</div> </div>
</div>
{changingPassword ? (
<form onSubmit={handleChangePassword} className="space-y-4">
<Input
label="Mot de passe actuel"
type="password"
value={passwordForm.ancien_mot_de_passe}
onChange={(e) => setPasswordForm({ ...passwordForm, ancien_mot_de_passe: e.target.value })}
required
/>
<Input
label="Nouveau mot de passe"
type="password"
value={passwordForm.password}
onChange={(e) => setPasswordForm({ ...passwordForm, password: e.target.value })}
required
/>
<Input
label="Confirmer le nouveau mot de passe"
type="password"
value={passwordForm.password_confirmation}
onChange={(e) => setPasswordForm({ ...passwordForm, password_confirmation: e.target.value })}
required
/>
<div className="flex gap-3">
<Button type="submit" loading={loading}>
Changer le mot de passe
</Button>
<Button variant="ghost" onClick={() => setChangingPassword(false)}>
Annuler
</Button>
</div>
</form>
) : (
<Button variant="outline" onClick={() => setChangingPassword(true)}>
Changer mon mot de passe
</Button>
)}
</Card>
{/* Zone dangereuse */}
<Card className="border-red-200 dark:border-red-900">
<div className="flex items-center gap-3 mb-4">
<Shield size={20} className="text-red-500" />
<h3 className="font-display font-semibold text-red-600 dark:text-red-400">
Zone dangereuse
</h3>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
La suppression de votre compte est irréversible. Toutes vos données seront effacées conformément au RGPD.
</p>
<Button variant="danger" onClick={() => setDeleteModalOpen(true)}>
<Trash2 size={16} />
Supprimer mon compte
</Button>
</Card>
{/* Modal confirmation suppression */}
<Modal
isOpen={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
title="Confirmer la suppression"
size="sm"
>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.
</p>
<div className="flex gap-3">
<Button variant="danger" onClick={handleDeleteAccount} loading={loading}>
Oui, supprimer
</Button>
<Button variant="ghost" onClick={() => setDeleteModalOpen(false)}>
Annuler
</Button>
</div>
</Modal>
</div> </div>
); );
} }
@@ -1,6 +1,3 @@
import Card from '@/components/ui/Card';
import { Phone } from 'lucide-react';
export default function ContactsUrgencePage() { 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
</div> d&apos;urgence européen).
<div className="flex-1 min-w-0"> </p>
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-1">
{contact.name}
</h2>
<a
href={`tel:${contact.number.replace(/\s/g, '')}`}
className="inline-block text-xl font-bold text-malachite-600 dark:text-malachite-400 mb-2 hover:underline"
>
{contact.number}
</a>
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
{contact.description}
</p>
</div>
</div>
</Card>
))}
</div> </div>
<Card className="mt-8 bg-red-50 dark:bg-red-950/30 border-red-100 dark:border-red-900"> <div className="fr-grid-row fr-grid-row--gutters">
<p className="text-sm text-red-700 dark:text-red-400 text-center leading-relaxed"> {contacts.map((contact) => (
<strong>En cas d&apos;urgence vitale</strong>, appelez immédiatement le{' '} <div className="fr-col-12 fr-col-md-6" key={contact.number}>
<strong>15</strong> (SAMU) ou le <strong>112</strong> (numéro d&apos;urgence <div className="fr-card">
européen). <div className="fr-card__body">
</p> <div className="fr-card__content">
</Card> <h2 className="fr-card__title">{contact.name}</h2>
<p className="fr-card__desc">{contact.description}</p>
<div className="fr-card__end">
<p className="fr-card__detail">
<a
className="fr-link"
href={`tel:${contact.number.replace(/\s/g, '')}`}
>
<span
className="fr-icon-phone-line fr-mr-1w"
aria-hidden="true"
/>
{contact.number}
</a>
</p>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div> </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> <p>
<Link L&apos;article demandé n&apos;existe pas ou n&apos;est plus
href="/informations" disponible.
className="text-malachite-600 dark:text-malachite-400 hover:underline" </p>
> </div>
← Retour aux informations <Link className="fr-link fr-mt-2w" href="/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"
</Link> className="fr-breadcrumb__button"
aria-expanded="false"
{feed.image_url && ( aria-controls="breadcrumb-article"
<div className="relative w-full h-64 sm:h-80 rounded-2xl overflow-hidden bg-gray-100 dark:bg-gray-800 mb-8"> >
<Image Voir le fil d&apos;Ariane
src={feed.image_url} </button>
alt={feed.titre} <div className="fr-collapse" id="breadcrumb-article">
width={800} <ol className="fr-breadcrumb__list">
height={400} <li>
className="w-full h-full object-cover" <Link className="fr-breadcrumb__link" href="/">
unoptimized Accueil
/> </Link>
</li>
<li>
<Link className="fr-breadcrumb__link" href="/informations">
Informations
</Link>
</li>
<li>
<a className="fr-breadcrumb__link" aria-current="page">
{feed.titre}
</a>
</li>
</ol>
</div> </div>
)} </nav>
<h1 className="font-display text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4"> <h1>{feed.titre}</h1>
{feed.titre}
</h1>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400 dark:text-gray-500 mb-8 pb-8 border-b border-gray-200 dark:border-gray-800"> <p className="fr-text--sm">
{feed.auteur && ( {feed.auteur && (
<div className="flex items-center gap-1.5"> <>
<User size={14} /> <span
className="fr-icon-user-line fr-mr-1w"
aria-hidden="true"
/>
{feed.auteur.prenom} {feed.auteur.nom} {feed.auteur.prenom} {feed.auteur.nom}
</div> {' — '}
</>
)} )}
<div className="flex items-center gap-1.5"> <span
<Calendar size={14} /> className="fr-icon-calendar-line fr-mr-1w"
{new Date(feed.created_at).toLocaleDateString('fr-FR', { aria-hidden="true"
day: 'numeric', />
month: 'long', {new Date(feed.created_at).toLocaleDateString('fr-FR', {
year: 'numeric', day: 'numeric',
})} month: 'long',
</div> year: 'numeric',
</div> })}
</p>
<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>
+41 -65
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"> </p>
Des contenus validés sur la santé mentale, la gestion du stress
et le bien-être émotionnel.
</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} {feed.titre}
width={800} </Link>
height={400} </h2>
className="w-full h-full object-cover" <p className="fr-card__desc">
unoptimized {(feed.contenu ?? '')
/> .replace(/<[^>]*>/g, '')
.substring(0, 200) + '…'}
</p>
<div className="fr-card__end">
<p className="fr-card__detail">
<span
className="fr-icon-calendar-line fr-mr-1w"
aria-hidden="true"
/>
{new Date(feed.created_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
</div>
</div> </div>
)}
<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> </div>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-2 line-clamp-2"> </div>
{feed.titre} </div>
</h2>
<div
className="text-gray-500 dark:text-gray-400 text-sm line-clamp-3 mb-4"
dangerouslySetInnerHTML={{
__html: (feed.contenu ?? '').replace(/<[^>]*>/g, '').substring(0, 200) + '...',
}}
/>
<div className="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500">
<Calendar size={14} />
{new Date(feed.created_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</div>
</Card>
</Link>
))} ))}
</div> </div>
) : ( ) : (
<div 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"> CESIZen est une application développée dans le cadre d&apos;un projet
Éditeur de l&apos;application pédagogique au sein de <strong>CESI École d&apos;Ingénieurs</strong>.
</h2> Cette application est réalisée à but éducatif et n&apos;a pas de vocation
<p className="text-gray-600 dark:text-gray-400 leading-relaxed"> commerciale.
CESIZen est une application développée dans le cadre d&apos;un projet </p>
pédagogique au sein de{' '}
<strong>CESI École d&apos;Ingénieurs</strong>. Cette application est
réalisée à but éducatif et n&apos;a pas de vocation commerciale.
</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 L&apos;application est hébergée dans le cadre de l&apos;infrastructure
</h2> pédagogique de CESI. Les données sont stockées sur des serveurs
<p className="text-gray-600 dark:text-gray-400 leading-relaxed"> sécurisés situés en France.
L&apos;application est hébergée dans le cadre de l&apos;infrastructure </p>
pédagogique de CESI. Les données sont stockées sur des serveurs
sécurisés situés en France.
</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 L&apos;ensemble des contenus présents sur CESIZen (textes, images,
</h2> graphismes, logo, icônes) sont la propriété exclusive de leurs auteurs
<p className="text-gray-600 dark:text-gray-400 leading-relaxed"> respectifs et sont protégés par les lois françaises et internationales
L&apos;ensemble des contenus présents sur CESIZen (textes, images, relatives à la propriété intellectuelle.
graphismes, logo, icônes) sont la propriété exclusive de leurs auteurs </p>
respectifs et sont protégés par les lois françaises et internationales
relatives à la propriété intellectuelle.
</p>
</Card>
<Card> <h2>Responsabilité</h2>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3"> <p>
Responsabilité CESIZen est un outil de suivi du bien-être et ne se substitue en aucun
</h2> cas à un avis médical professionnel. En cas de détresse psychologique,
<p className="text-gray-600 dark:text-gray-400 leading-relaxed"> veuillez contacter les services d&apos;urgence ou un professionnel de
CESIZen est un outil de suivi du bien-être et ne se substitue en aucun santé.
cas à un avis médical professionnel. En cas de détresse psychologique, </p>
veuillez contacter les services d&apos;urgence ou un professionnel de
santé.
</p>
</Card>
<Card> <h2>Contact</h2>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3"> <p>
Contact Pour toute question relative aux mentions légales, vous pouvez nous
</h2> contacter via l&apos;adresse suivante :{' '}
<p className="text-gray-600 dark:text-gray-400 leading-relaxed"> <a className="fr-link" href="mailto:contact@cesizen.fr">
Pour toute question relative aux mentions légales, vous pouvez nous contact@cesizen.fr
contacter via l&apos;adresse suivante :{' '} </a>
<span className="text-malachite-600 dark:text-malachite-400"> </p>
contact@cesizen.fr
</span>
</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"> CESIZen s&apos;engage à respecter le Règlement Général sur la Protection
Conformité RGPD des Données (RGPD) et la loi Informatique et Libertés. La protection de
</h2> vos données personnelles est une priorité.
<p className="text-gray-600 dark:text-gray-400 leading-relaxed"> </p>
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
de vos données personnelles est une priorité.
</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 Dans le cadre de l&apos;utilisation de CESIZen, les données suivantes
</h2> sont collectées :
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mb-3"> </p>
Dans le cadre de l&apos;utilisation de CESIZen, les données suivantes <ul>
sont collectées : <li>Adresse e-mail</li>
</p> <li>Nom et prénom</li>
<ul className="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-1"> <li>Données relatives aux émotions enregistrées</li>
<li>Adresse e-mail</li> </ul>
<li>Nom et prénom</li>
<li>Données relatives aux émotions enregistrées</li>
</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 Ces données sont collectées uniquement dans le but de fournir un suivi
</h2> personnalisé du bien-être émotionnel. Elles ne sont ni vendues, ni
<p className="text-gray-600 dark:text-gray-400 leading-relaxed"> partagées avec des tiers à des fins commerciales.
Ces données sont collectées uniquement dans le but de fournir un suivi </p>
personnalisé du bien-être émotionnel. Elles ne sont ni vendues, ni
partagées avec des tiers à des fins commerciales.
</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 Les données personnelles sont conservées pendant toute la durée
</h2> d&apos;utilisation du compte. En cas de suppression du compte, les
<p className="text-gray-600 dark:text-gray-400 leading-relaxed"> données sont effacées dans un délai de 30 jours.
Les données personnelles sont conservées pendant toute la durée </p>
d&apos;utilisation du compte. En cas de suppression du compte, les
données sont effacées dans un délai de 30 jours.
</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 Les données sont chiffrées en transit (HTTPS) et au repos. Les mots de
</h2> passe sont hachés avec des algorithmes sécurisés. L&apos;accès aux
<p className="text-gray-600 dark:text-gray-400 leading-relaxed"> données est strictement limité aux personnes habilitées.
Les données sont chiffrées en transit (HTTPS) et au repos. Les mots de </p>
passe sont hachés avec des algorithmes sécurisés. L&apos;accès aux
données est strictement limité aux personnes habilitées.
</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> <li>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mb-3"> <strong>Droit d&apos;accès :</strong> obtenir une copie de vos données
Conformément au RGPD, vous disposez des droits suivants : personnelles
</p> </li>
<ul className="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-1"> <li>
<li> <strong>Droit de rectification :</strong> corriger des données
<strong>Droit d&apos;accès :</strong> obtenir une copie de vos inexactes ou incomplètes
données personnelles </li>
</li> <li>
<li> <strong>Droit de suppression :</strong> demander l&apos;effacement de
<strong>Droit de rectification :</strong> corriger des données vos données personnelles
inexactes ou incomplètes </li>
</li> <li>
<li> <strong>Droit à la portabilité :</strong> récupérer vos données dans
<strong>Droit de suppression :</strong> demander l&apos;effacement un format structuré
de vos données personnelles </li>
</li> </ul>
<li> <p>
<strong>Droit à la portabilité :</strong> récupérer vos données dans Pour exercer ces droits, contactez-nous à l&apos;adresse :{' '}
un format structuré <a className="fr-link" href="mailto:contact@cesizen.fr">
</li> contact@cesizen.fr
</ul> </a>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mt-3"> </p>
Pour exercer ces droits, contactez-nous à l&apos;adresse :{' '}
<span className="text-malachite-600 dark:text-malachite-400">
contact@cesizen.fr
</span>
</p>
</Card>
</div>
</div> </div>
); );
} }
@@ -57,63 +57,69 @@ 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 className="fr-input-group">
<div> <p className="fr-label">Émotion</p>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <EmotionPicker value={emotionId} onChange={setEmotionId} />
Émotion </div>
</label>
<EmotionPicker value={emotionId} onChange={setEmotionId} />
</div>
{/* Intensité */} {/* Intensité */}
<div> <div className="fr-input-group">
<Slider <Slider
value={intensite} value={intensite}
onChange={setIntensite} onChange={setIntensite}
min={1} min={1}
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
</div> </span>
<span className="fr-col fr-text--sm" style={{ textAlign: 'right' }}>
Forte
</span>
</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
type="date" id="edit-saisie-date"
value={dateSaisie} className="fr-input"
onChange={(e) => setDateSaisie(e.target.value)} type="date"
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" value={dateSaisie}
/> onChange={(e) => setDateSaisie(e.target.value)}
</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
value={note} id="edit-saisie-note"
onChange={(e) => setNote(e.target.value)} className="fr-input"
placeholder="Décrivez ce que vous ressentez..." value={note}
rows={3} onChange={(e) => setNote(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-3 focus:outline-none focus:ring-2 focus:ring-candlelight-500 placeholder:text-gray-400 resize-none" placeholder="Décrivez ce que vous ressentez..."
/> rows={3}
</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,76 +24,75 @@ 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) => {
<button const isSelected = value === child.id;
key={child.id} return (
type="button" <li key={child.id}>
onClick={() => onChange(child.id)} <button
className={` type="button"
flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-200 aria-pressed={isSelected}
${value === child.id onClick={() => onChange(child.id)}
? 'border-current shadow-lg scale-105' className={`fr-tag${isSelected ? ' fr-tag--green-emeraude' : ''}`}
: 'border-gray-200 dark:border-gray-700 hover:border-current hover:shadow-md' >
} <span aria-hidden="true" className="fr-mr-1v">
`} {child.icone || '🔵'}
style={{ </span>
color: child.couleur, {child.nom}
borderColor: value === child.id ? child.couleur : undefined, </button>
}} </li>
> );
<span className="text-2xl">{child.icone || '🔵'}</span> })}
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{child.nom}</span> </ul>
</button>
))}
</div>
</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 (
<button <li key={emotion.id}>
key={emotion.id} <button
type="button" type="button"
onClick={() => { aria-pressed={!!isSelected}
if (hasChildren) { onClick={() => {
setSelectedParent(emotion); if (hasChildren) {
} else { setSelectedParent(emotion);
onChange(emotion.id); } else {
} onChange(emotion.id);
}} }
className={` }}
flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-200 className={`fr-tag fr-tag--md${isSelected ? ' fr-tag--green-emeraude' : ''}`}
${isSelected >
? 'shadow-lg scale-105' <span aria-hidden="true" className="fr-mr-1v">
: 'border-gray-200 dark:border-gray-700 hover:shadow-md' {emotion.icone || '🔵'}
} </span>
`} {emotion.nom}
style={{ {hasChildren && (
borderColor: isSelected ? emotion.couleur : undefined, <span aria-hidden="true" className="fr-ml-1v">
}} ▾
> </span>
<span className="text-3xl">{emotion.icone || '🔵'}</span> )}
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{emotion.nom}</span> </button>
{hasChildren && ( </li>
<span className="text-xs text-gray-400">▼</span>
)}
</button>
); );
})} })}
</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 className="fr-col-auto">
<div <span
className="shrink-0 w-12 h-12 rounded-xl flex items-center justify-center text-xl" aria-hidden="true"
style={{ backgroundColor: `${saisie.emotion?.couleur}20` }} style={{
> display: 'inline-flex',
{saisie.emotion?.icone || '🔵'} alignItems: 'center',
</div> justifyContent: 'center',
width: '3rem',
height: '3rem',
borderRadius: '0.5rem',
fontSize: '1.5rem',
backgroundColor: `${saisie.emotion?.couleur}20`,
}}
>
{saisie.emotion?.icone || '🔵'}
</span>
</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="fr-mb-1w"
className="h-full rounded-full transition-all duration-500" style={{
style={{ width: '100%',
width: `${saisie.intensite * 10}%`, height: '0.375rem',
backgroundColor: saisie.emotion?.couleur || '#fce117', borderRadius: '0.25rem',
}} backgroundColor: 'var(--background-contrast-grey)',
/> overflow: 'hidden',
</div> }}
{saisie.note && ( >
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"> <div
{saisie.note} style={{
</p> height: '100%',
)} width: `${saisie.intensite * 10}%`,
</div> backgroundColor: saisie.emotion?.couleur || '#000091',
}}
/>
</div>
{saisie.note && (
<p className="fr-text--sm fr-mb-0">{saisie.note}</p>
)}
</div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-1 shrink-0"> <div className="fr-col-auto">
{onEdit && ( <ul className="fr-btns-group fr-btns-group--inline fr-btns-group--sm">
<button {onEdit && (
onClick={() => onEdit(saisie)} <li>
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" <button
aria-label="Modifier" type="button"
> onClick={() => onEdit(saisie)}
<Edit2 size={16} /> className="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-icon-edit-line"
</button> title="Modifier"
)} >
{onDelete && ( Modifier
<button </button>
onClick={() => onDelete(saisie.id)} </li>
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 text-gray-400 hover:text-red-500 transition-colors" )}
aria-label="Supprimer" {onDelete && (
> <li>
<Trash2 size={16} /> <button
</button> type="button"
)} onClick={() => onDelete(saisie.id)}
className="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-icon-delete-line"
title="Supprimer"
>
Supprimer
</button>
</li>
)}
</ul>
</div>
</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </section>
))} ))}
</div> </div>
); );
+46 -88
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"
<button aria-labelledby="fr-sidemenu-title"
onClick={() => setCollapsed(!collapsed)} role="navigation"
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" <div className="fr-sidemenu__inner">
> <button
{collapsed ? <X size={20} /> : <Menu size={20} />} type="button"
</button> className="fr-sidemenu__btn"
aria-controls="fr-sidemenu-wrapper"
{/* Overlay mobile */} aria-expanded={collapsed}
{collapsed && ( onClick={() => setCollapsed(!collapsed)}
>
Menu d&apos;administration
</button>
<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"
/> >
)} <div className="fr-sidemenu__title" id="fr-sidemenu-title">
<Link href="/admin" className="fr-sidemenu__link">
{/* Sidebar */} Administration CESIZen
<aside
className={`
fixed lg:sticky top-0 left-0 z-40 lg:z-auto
h-screen w-64
bg-white dark:bg-gray-950
border-r border-gray-200 dark:border-gray-800
flex flex-col
transition-transform duration-300 ease-in-out
${collapsed ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
`}
>
{/* Logo */}
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200 dark:border-gray-800">
<Link href="/admin" className="flex items-center gap-0.5">
<span className="text-xl font-bold font-display text-malachite-500">
CESI
</span>
<span className="text-xl font-bold font-display text-candlelight-500">
ZEN
</span>
</Link>
<button
onClick={() => setCollapsed(false)}
className="lg:hidden p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500"
>
<X size={18} />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{sidebarLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setCollapsed(false)}
className={`
flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200
${
isActive(link.href)
? "bg-candlelight-100 dark:bg-candlelight-950 text-candlelight-700 dark:text-candlelight-400 shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-200"
}
`}
>
{link.icon}
{link.label}
</Link> </Link>
))} </div>
</nav> <ul className="fr-sidemenu__list">
{sidebarLinks.map((link) => {
{/* Footer */} const active = isActive(link.href);
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-800"> return (
<p className="text-xs text-gray-400 dark:text-gray-600"> <li className="fr-sidemenu__item" key={link.href}>
Administration CESIZen <Link
</p> className={`fr-sidemenu__link ${link.icon} fr-link--icon-left`}
href={link.href}
aria-current={active ? "page" : undefined}
onClick={() => setCollapsed(false)}
>
{link.label}
</Link>
</li>
);
})}
</ul>
</div> </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>
);
} }
+19 -36
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,42 +26,26 @@ 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> variant={destructive ? "danger" : "primary"}
<Button onClick={() => {
variant={destructive ? "danger" : "primary"} onConfirm();
onClick={() => { onClose();
onConfirm(); }}
onClose(); >
}} {confirmLabel}
> </Button>
{confirmLabel} </li>
</Button> <li>
</div> <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>
)} )}
+46 -41
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
)} type="button"
<button className="fr-btn--close fr-btn"
onClick={onClose} aria-label="Fermer"
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" onClick={onClose}
aria-label="Fermer" >
> Fermer
<X size={20} /> </button>
</button> </div>
<div className="fr-modal__content">
{title && <h1 className="fr-modal__title">{title}</h1>}
{children}
</div>
</div>
</div>
</div> </div>
{/* Content */}
<div>{children}</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",
}}
/> />
); );
} }
+34 -22
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}
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
)}
{showValue && ( {showValue && (
<span className="text-sm font-bold text-candlelight-600 dark:text-candlelight-400 bg-candlelight-50 dark:bg-candlelight-950 px-2 py-0.5 rounded-lg"> <span className="fr-hint-text">Valeur sélectionnée : {value}</span>
{value}
</span>
)} )}
</div> </label>
)} )}
<input <div
type="range" className="fr-range fr-range--sm"
min={min} data-fr-js-range="true"
max={max} data-fr-min={min}
value={value} data-fr-max={max}
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" <span
/> className="fr-range__output"
<div className="flex justify-between mt-1"> aria-hidden="true"
<span className="text-xs text-gray-400 dark:text-gray-500">{min}</span> >{`${value}`}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{max}</span> <input
id={rangeId}
type="range"
min={min}
max={max}
value={value}
step={1}
onChange={(e) => onChange(Number(e.target.value))}
/>
<div className="fr-range__min" aria-hidden="true">
{min}
</div>
<div className="fr-range__max" aria-hidden="true">
{max}
</div>
</div> </div>
</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>
); );
} }