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