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