Gestion des rencontres passées + interdiction des actions destructrices aux modérateurs

Rencontres passées (section « Ils y étaient » de l'accueil) :
- Nouvel onglet « Rencontres passées » (admin + modérateur) : création,
  édition, jusqu'à 3 photos par rencontre (1 grande + 2 petites, upload
  après enregistrement — même schéma que la photo des rencontres à venir)
- Nouvelles colonnes rencontres_passees.image_path/_2/_3 (migration
  ALTER TABLE IF NOT EXISTS)
- Home.jsx affiche désormais les vraies photos uploadées (repli sur le
  motif placeholder si absentes) ; ordre passé de ORDER BY id à
  ORDER BY id DESC (les plus récentes en premier, seed réordonné en
  conséquence pour préserver l'affichage actuel)
- Suppression réservée aux administrateurs

Modérateurs : plus aucune action destructrice possible, imposé côté
serveur (adminOnly) et masqué côté interface :
- Validation/suspension d'un membre (toggle-valide)
- Suppression d'une rencontre, d'une rencontre passée, d'une inscription

Vérifié : 34 + 21 + 26 tests existants toujours au vert, 21 nouveaux
tests (CRUD rencontres passées, upload des 3 emplacements photo, ordre
d'affichage, restrictions modérateur sur chaque action désormais admin-
only), parcours navigateur complet (upload des 3 photos, rendu réel sur
la page d'accueil).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
Claude committed 2026-07-13 14:58:32 +00:00
1 parent fab64bc38a
commit 214250bc39
11 files changed
+426 -55

No files matched your search

+19 -14
View File
@@ -3,13 +3,16 @@ import { api } from '../lib/api.js';
// Real replacement for the mock's <image-slot>: click or drop an image,
// it is uploaded to `endpoint` and the stored path is displayed.
export default function ImageSlot({ endpoint, value, onUploaded, placeholder, circle }) {
// `disabled` shows the placeholder without any upload interaction — used
// when the endpoint needs an id that doesn't exist yet (e.g. an entity
// that must be saved first).
export default function ImageSlot({ endpoint, value, onUploaded, placeholder, circle, disabled }) {
const inputRef = useRef(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const send = async (file) => {
if (!file) return;
if (!file || disabled) return;
setBusy(true);
setError('');
try {
@@ -25,25 +28,27 @@ export default function ImageSlot({ endpoint, value, onUploaded, placeholder, ci
return (
<div
className={`image-slot${circle ? ' circle' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
style={{ cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1 }}
onClick={() => !disabled && inputRef.current?.click()}
onDragOver={(e) => !disabled && e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
send(e.dataTransfer.files?.[0]);
if (!disabled) send(e.dataTransfer.files?.[0]);
}}
role="button"
tabIndex={0}
tabIndex={disabled ? -1 : 0}
>
{value && <img src={value} alt="" />}
{!value && <span>{busy ? 'Envoi…' : error || placeholder}</span>}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
style={{ display: 'none' }}
onChange={(e) => send(e.target.files?.[0])}
/>
{!disabled && (
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
style={{ display: 'none' }}
onChange={(e) => send(e.target.files?.[0])}
/>
)}
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import { api } from '../../lib/api.js';
import { useAuth } from '../../lib/AuthContext.jsx';
import Modal from '../Modal.jsx';
import ImageSlot from '../ImageSlot.jsx';
function PastEventFormModal({ event, onClose, onSaved }) {
const [form, setForm] = useState(
event
? { date_label: event.date_label, lieu: event.lieu, titre: event.titre, texte: event.texte, participants: event.participants, nb_photos: event.nb_photos }
: { date_label: '', lieu: '', titre: '', texte: '', participants: 0, nb_photos: 0 }
);
const [images, setImages] = useState([event?.image_path || '', event?.image_path_2 || '', event?.image_path_3 || '']);
const [error, setError] = useState('');
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
const setImageAt = (i) => (path) => setImages((prev) => prev.map((p, idx) => (idx === i ? path : p)));
const submit = async (e) => {
e.preventDefault();
setError('');
try {
if (event) await api.put(`/api/admin/rencontres-passees/${event.id}`, form);
else await api.post('/api/admin/rencontres-passees', form);
onSaved();
} catch (err) {
setError(err.message);
}
};
const remove = async () => {
if (!window.confirm('Supprimer cette rencontre passée et ses photos ?')) return;
try {
await api.del(`/api/admin/rencontres-passees/${event.id}`);
onSaved();
} catch (err) {
setError(err.message);
}
};
return (
<Modal onClose={onClose} maxWidth={620} header={{ kicker: 'Rencontres passées', title: event ? 'Modifier la rencontre passée' : 'Nouvelle rencontre passée' }}>
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<div className="field" style={{ marginBottom: 8 }}>Photos</div>
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gridTemplateRows: '1fr 1fr', gap: 8, height: 160 }}>
<div style={{ gridRow: 'span 2' }}>
<ImageSlot
endpoint={event ? `/api/admin/rencontres-passees/${event.id}/image/1` : undefined}
value={images[0]}
disabled={!event}
placeholder={event ? 'Photo principale' : "Enregistrez d'abord"}
onUploaded={setImageAt(0)}
/>
</div>
<ImageSlot
endpoint={event ? `/api/admin/rencontres-passees/${event.id}/image/2` : undefined}
value={images[1]}
disabled={!event}
placeholder="Photo"
onUploaded={setImageAt(1)}
/>
<ImageSlot
endpoint={event ? `/api/admin/rencontres-passees/${event.id}/image/3` : undefined}
value={images[2]}
disabled={!event}
placeholder="Photo"
onUploaded={setImageAt(2)}
/>
</div>
</div>
<label className="field">Titre
<input name="titre" value={form.titre} onChange={onChange} required maxLength={200} />
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 12 }}>
<label className="field">Date affichée
<input name="date_label" value={form.date_label} onChange={onChange} placeholder="Juin 2026" required maxLength={60} />
</label>
<label className="field">Lieu
<input name="lieu" value={form.lieu} onChange={onChange} required maxLength={200} />
</label>
</div>
<label className="field">Description
<textarea name="texte" value={form.texte} onChange={onChange} rows={3} required maxLength={2000} />
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<label className="field">Participants
<input name="participants" type="number" min={0} max={100000} value={form.participants} onChange={onChange} required />
</label>
<label className="field">Nombre de photos affiché
<input name="nb_photos" type="number" min={0} max={100000} value={form.nb_photos} onChange={onChange} required />
</label>
</div>
</div>
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<div style={{ display: 'flex', gap: 12, marginTop: 24, alignItems: 'center' }}>
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>
{event ? 'Enregistrer' : 'Créer'}
</button>
{event && (
<PastDeleteButton onDelete={remove} />
)}
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
Annuler
</button>
</div>
</form>
</Modal>
);
}
// Only admins can delete — moderators manage this section but can't
// destroy data (same rule as members/rencontres/inscriptions).
function PastDeleteButton({ onDelete }) {
const { user } = useAuth();
if (user.role !== 'admin') return null;
return (
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={onDelete}>
Supprimer
</button>
);
}
export function PastEventsTab() {
const [events, setEvents] = useState([]);
const [modal, setModal] = useState(null); // null | 'new' | event
const reload = () => api.get('/api/admin/rencontres-passees').then((d) => setEvents(d.rencontresPassees)).catch(() => {});
useEffect(() => {
reload();
}, []);
return (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 22 }}>
<div>
<h3 className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Rencontres passées</h3>
<div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginTop: 3 }}>
Section « Ils y étaient » de la page d'accueil.
</div>
</div>
<button className="btn btn-red btn-sm" style={{ fontSize: 13, padding: '10px 18px' }} onClick={() => setModal('new')}>
+ Ajouter une rencontre passée
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{events.map((ev) => (
<div key={ev.id} className="card" style={{ borderRadius: 6, padding: '18px 20px', display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 20, alignItems: 'center' }}>
<div className={ev.image_path ? '' : 'placeholder-pattern'} style={{ width: 84, height: 60, borderRadius: 4, overflow: 'hidden', flexShrink: 0 }}>
{ev.image_path && <img src={ev.image_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 600 }}>{ev.titre}</div>
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 4 }}>
{ev.date_label} · {ev.lieu} · {ev.participants} participants
</div>
</div>
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13, padding: '9px 16px' }} onClick={() => setModal(ev)}>
Gérer
</button>
</div>
))}
{events.length === 0 && (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--gray-light)', fontSize: 14 }}>
Aucune rencontre passée pour le moment.
</div>
)}
</div>
{modal && (
<PastEventFormModal
event={modal === 'new' ? null : modal}
onClose={() => setModal(null)}
onSaved={() => { setModal(null); reload(); }}
/>
)}
</>
);
}
+40 -12
View File
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { api, dateParts, seasonLabel, statutLabel } from '../../lib/api.js';
import { useAuth } from '../../lib/AuthContext.jsx';
import Modal from '../Modal.jsx';
import ImageSlot from '../ImageSlot.jsx';
import { AccessCell, CredentialsModal } from './AccessControls.jsx';
/* ---------------- Members ---------------- */
@@ -90,6 +92,8 @@ function MemberFormModal({ member, categories, onClose, onSaved }) {
}
export function MembersTab() {
const { user } = useAuth();
const isAdmin = user.role === 'admin';
const [members, setMembers] = useState([]);
const [categories, setCategories] = useState([]);
const [modal, setModal] = useState(null); // null | 'new' | member
@@ -176,9 +180,11 @@ export function MembersTab() {
/>
</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn-link-gray" style={{ color: 'var(--gray)', marginRight: 14 }} onClick={() => toggle(m)}>
{m.valide ? 'Suspendre' : 'Valider'}
</button>
{isAdmin && (
<button className="btn-link-gray" style={{ color: 'var(--gray)', marginRight: 14 }} onClick={() => toggle(m)}>
{m.valide ? 'Suspendre' : 'Valider'}
</button>
)}
<button className="btn-link" style={{ fontSize: 13 }} onClick={() => setModal(m)}>Modifier</button>
</td>
</tr>
@@ -216,11 +222,13 @@ export function MembersTab() {
/* ---------------- Rencontres ---------------- */
function RencFormModal({ renc, onClose, onSaved }) {
const { user } = useAuth();
const [form, setForm] = useState(
renc
? { titre: renc.titre, date_renc: String(renc.date_renc).slice(0, 10), heure: renc.heure, lieu: renc.lieu, description: renc.description, places: renc.places }
: { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30 }
);
const [image, setImage] = useState(renc?.image_path || '');
const [error, setError] = useState('');
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
@@ -251,9 +259,20 @@ function RencFormModal({ renc, onClose, onSaved }) {
<Modal onClose={onClose} maxWidth={560} header={{ kicker: 'Rencontres', title: renc ? 'Gérer la rencontre' : 'Nouvelle rencontre' }}>
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label className="field">Titre de la rencontre
<input name="titre" value={form.titre} onChange={onChange} required maxLength={200} />
</label>
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
<div style={{ width: 120, height: 84, flexShrink: 0 }}>
<ImageSlot
endpoint={renc ? `/api/admin/rencontres/${renc.id}/image` : undefined}
value={image}
disabled={!renc}
placeholder={renc ? 'Photo de la rencontre' : "Enregistrez d'abord la rencontre"}
onUploaded={setImage}
/>
</div>
<label className="field" style={{ flex: 1 }}>Titre de la rencontre
<input name="titre" value={form.titre} onChange={onChange} required maxLength={200} />
</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<label className="field" style={{ gridColumn: 'span 2' }}>Date
<input name="date_renc" type="date" value={form.date_renc} onChange={onChange} required />
@@ -279,7 +298,7 @@ function RencFormModal({ renc, onClose, onSaved }) {
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>
{renc ? 'Enregistrer' : 'Créer la rencontre'}
</button>
{renc && (
{renc && user.role === 'admin' && (
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={remove}>
Supprimer
</button>
@@ -298,6 +317,7 @@ function escHtml(v) {
}
function ParticipantsModal({ renc, onClose, onEdit, onCancel, refreshKey }) {
const { user } = useAuth();
const [rows, setRows] = useState([]);
const { jour, mois } = dateParts(renc.date_renc);
const meta = `${jour} ${mois} · ${renc.heure} · ${renc.lieu}`;
@@ -370,7 +390,9 @@ function ParticipantsModal({ renc, onClose, onEdit, onCancel, refreshKey }) {
<td><span className={`badge ${p.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(p.statut)}</span></td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn-link" style={{ fontSize: 12.5, marginRight: 12 }} onClick={() => onEdit(p)}>Modifier</button>
<button className="btn-link-gray" style={{ fontSize: 12.5 }} onClick={() => onCancel(p)}>Annuler</button>
{user.role === 'admin' && (
<button className="btn-link-gray" style={{ fontSize: 12.5 }} onClick={() => onCancel(p)}>Annuler</button>
)}
</td>
</tr>
))}
@@ -480,6 +502,7 @@ export function RencontresTab() {
/* ---------------- Inscriptions ---------------- */
function InscriptionEditModal({ inscription, rencontres, onClose, onSaved, onRequestCancel }) {
const { user } = useAuth();
const [form, setForm] = useState({
nom: inscription.nom,
entreprise: inscription.entreprise,
@@ -541,9 +564,11 @@ function InscriptionEditModal({ inscription, rencontres, onClose, onSaved, onReq
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<div style={{ display: 'flex', gap: 12, marginTop: 24, alignItems: 'center' }}>
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>Enregistrer</button>
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={() => onRequestCancel(inscription)}>
Annuler l'inscription
</button>
{user.role === 'admin' && (
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={() => onRequestCancel(inscription)}>
Annuler l'inscription
</button>
)}
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
Fermer
</button>
@@ -587,6 +612,7 @@ function CancelConfirmModal({ inscription, onClose, onDone }) {
}
export function InscriptionsTab() {
const { user } = useAuth();
const [inscriptions, setInscriptions] = useState([]);
const [rencontres, setRencontres] = useState([]);
const [edit, setEdit] = useState(null);
@@ -623,7 +649,9 @@ export function InscriptionsTab() {
<td><span className={`badge ${i.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(i.statut)}</span></td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn-link" style={{ fontSize: 13, marginRight: 14 }} onClick={() => setEdit(i)}>Modifier</button>
<button className="btn-link-gray" onClick={() => setCancel(i)}>Annuler</button>
{user.role === 'admin' && (
<button className="btn-link-gray" onClick={() => setCancel(i)}>Annuler</button>
)}
</td>
</tr>
))}
+7 -3
View File
@@ -4,15 +4,18 @@ import { useAuth } from '../lib/AuthContext.jsx';
import { api, dateParts, statutLabel } from '../lib/api.js';
import { MembersTab, RencontresTab, InscriptionsTab } from '../components/admin/AdminTabs.jsx';
import { CategoriesTab, ContenuTab } from '../components/admin/AdminContent.jsx';
import { PastEventsTab } from '../components/admin/AdminPast.jsx';
import { UsersTab } from '../components/admin/AdminUsers.jsx';
// Moderators only get members/rencontres/inscriptions — everything else
// (dashboard, taxonomy, site content, staff account management) is
// admin-only, enforced both here (sidebar) and server-side (routes).
// Moderators only get members/rencontres/inscriptions/rencontres passées
// (create + edit, never delete) — everything else (dashboard, taxonomy,
// site content, staff account management) is admin-only, enforced both
// here (sidebar) and server-side (routes).
const TABS = [
{ key: 'dashboard', icon: '◧', label: 'Tableau de bord', title: 'Tableau de bord', roles: ['admin'] },
{ key: 'membres', icon: '▤', label: 'Membres', title: 'Gestion des membres', roles: ['admin', 'moderator'] },
{ key: 'rencontres', icon: '◈', label: 'Rencontres', title: 'Rencontres', roles: ['admin', 'moderator'] },
{ key: 'passees', icon: '▦', label: 'Rencontres passées', title: 'Rencontres passées', roles: ['admin', 'moderator'] },
{ key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions', roles: ['admin', 'moderator'] },
{ key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories', roles: ['admin'] },
{ key: 'contenu', icon: '▧', label: 'Contenu du site', title: 'Contenu du site', roles: ['admin'] },
@@ -143,6 +146,7 @@ export function AdminShell() {
{tab === 'dashboard' && <Dashboard />}
{tab === 'membres' && <MembersTab />}
{tab === 'rencontres' && <RencontresTab />}
{tab === 'passees' && <PastEventsTab />}
{tab === 'inscriptions' && <InscriptionsTab />}
{tab === 'categories' && <CategoriesTab />}
{tab === 'contenu' && <ContenuTab />}
+13 -4
View File
@@ -213,7 +213,10 @@ export default function Home() {
const restantes = Math.max(0, e.places - e.inscrits);
return (
<div key={e.id} className="card" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div className="placeholder-pattern" style={{ height: 170, position: 'relative' }}>
<div className={e.image_path ? '' : 'placeholder-pattern'} style={{ height: 170, position: 'relative' }}>
{e.image_path && (
<img src={e.image_path} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} />
)}
<div style={{ position: 'absolute', top: 16, left: 16, background: 'var(--dark)', color: '#fff', borderRadius: 3, padding: '8px 12px', textAlign: 'center', lineHeight: 1.05 }}>
<div className="serif" style={{ fontSize: 24, fontWeight: 600 }}>{jour}</div>
<div style={{ fontSize: 10, letterSpacing: '.12em', textTransform: 'uppercase', color: '#B7AFA6' }}>{mois}</div>
@@ -262,9 +265,15 @@ export default function Home() {
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr', gap: 10, height: 240 }}>
<div className="placeholder-pattern" style={{ gridRow: 'span 2', borderRadius: 3 }} />
<div className="placeholder-pattern" style={{ borderRadius: 3 }} />
<div className="placeholder-pattern" style={{ borderRadius: 3 }} />
<div className={p.image_path ? '' : 'placeholder-pattern'} style={{ gridRow: 'span 2', borderRadius: 3, overflow: 'hidden' }}>
{p.image_path && <img src={p.image_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
</div>
<div className={p.image_path_2 ? '' : 'placeholder-pattern'} style={{ borderRadius: 3, overflow: 'hidden' }}>
{p.image_path_2 && <img src={p.image_path_2} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
</div>
<div className={p.image_path_3 ? '' : 'placeholder-pattern'} style={{ borderRadius: 3, overflow: 'hidden' }}>
{p.image_path_3 && <img src={p.image_path_3} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
</div>
</div>
</article>
))}