Galerie photos illimitée pour les rencontres passées + page publique dédiée + carrousel
Photos : - Nouvelle table rencontre_passee_photos (photos illimitées par rencontre passée, avec position). Migration idempotente au démarrage des anciennes colonnes image_path/_2/_3 vers cette table (puis colonnes nullées) - Le nombre de photos affiché est désormais celui réellement uploadé (calculé), le champ manuel nb_photos n'est plus utilisé - Gestion admin : ajout illimité, choix de la photo « principale » (mise en avant), suppression réservée aux admins (les modérateurs ajoutent et réordonnent mais ne suppriment pas) Participants auto : - rencontres_passees.rencontre_id (FK, ON DELETE SET NULL) ; quand une rencontre passée est liée à une rencontre, le nombre de participants est calculé depuis ses inscriptions ; sinon valeur manuelle de repli. Le formulaire admin propose la liaison et affiche le compte auto Public : - Nouvelle page /rencontres-passees (menu « Rencontres passées ») listant toutes les rencontres passées - Carrousel plein écran (composant Carousel) montrant TOUTES les photos (flèches, clavier ←/→/Échap, compteur, miniatures) ; ouvert au clic sur une carte. La grille d'accueil montre 3 aperçus avec badge « +N » et un lien « Voir toutes les rencontres → » - Composant PastEventCard partagé entre l'accueil et la page dédiée Vérifié : 34 + 21 + 26 tests existants au vert, 19 nouveaux tests (participants auto liés/déliés, upload illimité, nb_photos calculé, principale, restrictions modérateur sur suppression de photo, migration legacy des colonnes inline), parcours navigateur complet (galerie admin, page publique, carrousel). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
13 files changed
+476
-139
No files matched your search
@@ -4,26 +4,111 @@ import { useAuth } from '../../lib/AuthContext.jsx';
|
||||
import Modal from '../Modal.jsx';
|
||||
import ImageSlot from '../ImageSlot.jsx';
|
||||
|
||||
function PastEventFormModal({ event, onClose, onSaved }) {
|
||||
// Manages the gallery of one past event: unlimited photos, add / delete /
|
||||
// set-as-principal. Only available once the event exists (has an id).
|
||||
function PhotoManager({ eventId, photos, setPhotos }) {
|
||||
const { user } = useAuth();
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const add = async (path, id) => {
|
||||
// ImageSlot uploads then hands us the stored path; the route also
|
||||
// returns the new photo id, but ImageSlot only forwards the path, so we
|
||||
// reload the event's photos to stay in sync.
|
||||
setError('');
|
||||
try {
|
||||
const d = await api.get(`/api/admin/rencontres-passees`);
|
||||
const ev = d.rencontresPassees.find((e) => e.id === eventId);
|
||||
if (ev) setPhotos(ev.photos || []);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (photo) => {
|
||||
setError('');
|
||||
try {
|
||||
await api.del(`/api/admin/rencontres-passees/${eventId}/photos/${photo.id}`);
|
||||
setPhotos((prev) => prev.filter((p) => p.id !== photo.id));
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const setPrincipal = async (photo) => {
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/api/admin/rencontres-passees/${eventId}/photos/${photo.id}/principale`);
|
||||
setPhotos((prev) => [photo, ...prev.filter((p) => p.id !== photo.id)]);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="field" style={{ marginBottom: 8 }}>Photos ({photos.length})</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(96px, 1fr))', gap: 10 }}>
|
||||
{photos.map((p, idx) => (
|
||||
<div key={p.id} style={{ position: 'relative', aspectRatio: '4/3', borderRadius: 4, overflow: 'hidden', border: idx === 0 ? '2px solid var(--red)' : '1px solid var(--border)' }}>
|
||||
<img src={p.path} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
{idx === 0 && (
|
||||
<span style={{ position: 'absolute', top: 4, left: 4, background: 'var(--red)', color: '#fff', fontSize: 9, fontWeight: 700, padding: '2px 6px', borderRadius: 3, letterSpacing: '.04em' }}>PRINCIPALE</span>
|
||||
)}
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, display: 'flex', justifyContent: 'space-between', gap: 4, padding: 4, background: 'linear-gradient(transparent, rgba(0,0,0,.55))' }}>
|
||||
{idx !== 0 && (
|
||||
<button type="button" title="Définir comme principale" onClick={() => setPrincipal(p)} style={{ background: 'rgba(255,255,255,.9)', border: 'none', cursor: 'pointer', borderRadius: 3, fontSize: 11, padding: '2px 6px' }}>★</button>
|
||||
)}
|
||||
{user.role === 'admin' && (
|
||||
<button type="button" title="Supprimer" onClick={() => remove(p)} style={{ marginLeft: 'auto', background: 'rgba(255,255,255,.9)', border: 'none', cursor: 'pointer', borderRadius: 3, fontSize: 11, padding: '2px 6px', color: 'var(--red)' }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ aspectRatio: '4/3' }}>
|
||||
<ImageSlot endpoint={`/api/admin/rencontres-passees/${eventId}/photos`} placeholder="+ Ajouter une photo" onUploaded={add} />
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="error-text" style={{ marginTop: 10 }}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PastEventFormModal({ event, rencontres, onClose, onSaved }) {
|
||||
const { user } = useAuth();
|
||||
const [savedId, setSavedId] = useState(event?.id ?? null);
|
||||
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 }
|
||||
? { date_label: event.date_label, lieu: event.lieu, titre: event.titre, texte: event.texte, participants: event.participants, rencontre_id: event.rencontre_id || '' }
|
||||
: { date_label: '', lieu: '', titre: '', texte: '', participants: 0, rencontre_id: '' }
|
||||
);
|
||||
const [images, setImages] = useState([event?.image_path || '', event?.image_path_2 || '', event?.image_path_3 || '']);
|
||||
const [photos, setPhotos] = useState(event?.photos || []);
|
||||
const [error, setError] = useState('');
|
||||
const [savedMsg, setSavedMsg] = useState(false);
|
||||
|
||||
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 linked = form.rencontre_id ? rencontres.find((r) => r.id === Number(form.rencontre_id)) : null;
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
const body = {
|
||||
date_label: form.date_label,
|
||||
lieu: form.lieu,
|
||||
titre: form.titre,
|
||||
texte: form.texte,
|
||||
participants: Number(form.participants) || 0,
|
||||
rencontre_id: form.rencontre_id ? Number(form.rencontre_id) : null,
|
||||
};
|
||||
try {
|
||||
if (event) await api.put(`/api/admin/rencontres-passees/${event.id}`, form);
|
||||
else await api.post('/api/admin/rencontres-passees', form);
|
||||
onSaved();
|
||||
if (savedId) {
|
||||
await api.put(`/api/admin/rencontres-passees/${savedId}`, body);
|
||||
setSavedMsg(true);
|
||||
setTimeout(() => setSavedMsg(false), 1500);
|
||||
} else {
|
||||
const res = await api.post('/api/admin/rencontres-passees', body);
|
||||
setSavedId(res.id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
@@ -32,7 +117,7 @@ function PastEventFormModal({ event, onClose, onSaved }) {
|
||||
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}`);
|
||||
await api.del(`/api/admin/rencontres-passees/${savedId}`);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -40,37 +125,9 @@ function PastEventFormModal({ event, onClose, onSaved }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} maxWidth={620} header={{ kicker: 'Rencontres passées', title: event ? 'Modifier la rencontre passée' : 'Nouvelle rencontre passée' }}>
|
||||
<Modal onClose={onSaved} maxWidth={640} header={{ kicker: 'Rencontres passées', title: savedId ? '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>
|
||||
@@ -85,61 +142,78 @@ function PastEventFormModal({ event, onClose, onSaved }) {
|
||||
<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>
|
||||
<label className="field">Rencontre liée (participants automatiques)
|
||||
<select name="rencontre_id" value={form.rencontre_id} onChange={onChange}>
|
||||
<option value="">Aucune — saisir le nombre à la main</option>
|
||||
{rencontres.map((r) => <option key={r.id} value={r.id}>{r.titre}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">Participants
|
||||
{linked ? (
|
||||
<input value={`${linked.inscrits} (depuis « ${linked.titre} »)`} disabled />
|
||||
) : (
|
||||
<input name="participants" type="number" min={0} max={100000} value={form.participants} onChange={onChange} />
|
||||
)}
|
||||
</label>
|
||||
</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'}
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 20, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn btn-red btn-sm" style={{ fontSize: 14.5, padding: '13px 24px' }}>
|
||||
{savedId ? 'Enregistrer' : 'Créer et ajouter des photos'}
|
||||
</button>
|
||||
{event && (
|
||||
<PastDeleteButton onDelete={remove} />
|
||||
{savedMsg && <span className="success-text">✓ Enregistré</span>}
|
||||
{savedId && user.role === 'admin' && (
|
||||
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={remove}>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
|
||||
Annuler
|
||||
<button type="button" className="btn btn-outline-soft btn-sm" style={{ marginLeft: 'auto', fontSize: 14.5, padding: '13px 22px' }} onClick={onSaved}>
|
||||
Fermer
|
||||
</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>
|
||||
{savedId && (
|
||||
<div style={{ padding: '4px 30px 30px', borderTop: '1px solid var(--border-soft)', marginTop: 6 }}>
|
||||
<div style={{ paddingTop: 20 }}>
|
||||
<PhotoManager eventId={savedId} photos={photos} setPhotos={setPhotos} />
|
||||
<p style={{ fontSize: 12.5, color: 'var(--gray-light)', lineHeight: 1.55, marginTop: 12 }}>
|
||||
La première photo (★ principale) est mise en avant sur la page d'accueil. Le nombre de photos
|
||||
affiché sur le site correspond automatiquement aux photos ci-dessus.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function PastEventsTab() {
|
||||
const [events, setEvents] = useState([]);
|
||||
const [rencontres, setRencontres] = useState([]);
|
||||
const [modal, setModal] = useState(null); // null | 'new' | event
|
||||
|
||||
const reload = () => api.get('/api/admin/rencontres-passees').then((d) => setEvents(d.rencontresPassees)).catch(() => {});
|
||||
const reload = () =>
|
||||
Promise.all([api.get('/api/admin/rencontres-passees'), api.get('/api/admin/rencontres')])
|
||||
.then(([p, r]) => {
|
||||
setEvents(p.rencontresPassees);
|
||||
setRencontres(r.rencontres);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
|
||||
const cover = (ev) => (ev.photos && ev.photos[0]) || null;
|
||||
|
||||
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.
|
||||
Section « Ils y étaient » de la page d'accueil et page « Rencontres passées ».
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-red btn-sm" style={{ fontSize: 13, padding: '10px 18px' }} onClick={() => setModal('new')}>
|
||||
@@ -149,13 +223,13 @@ export function PastEventsTab() {
|
||||
<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 className={cover(ev) ? '' : 'placeholder-pattern'} style={{ width: 84, height: 60, borderRadius: 4, overflow: 'hidden', flexShrink: 0 }}>
|
||||
{cover(ev) && <img src={cover(ev).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
|
||||
{ev.date_label} · {ev.lieu} · {ev.participants_display} participants · {ev.nb_photos} photo{ev.nb_photos > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13, padding: '9px 16px' }} onClick={() => setModal(ev)}>
|
||||
@@ -172,6 +246,7 @@ export function PastEventsTab() {
|
||||
{modal && (
|
||||
<PastEventFormModal
|
||||
event={modal === 'new' ? null : modal}
|
||||
rencontres={rencontres}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={() => { setModal(null); reload(); }}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user