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
@@ -5,6 +5,7 @@ import Footer from './components/Footer.jsx';
|
||||
import Home from './pages/Home.jsx';
|
||||
import Annuaire from './pages/Annuaire.jsx';
|
||||
import Association from './pages/Association.jsx';
|
||||
import RencontresPassees from './pages/RencontresPassees.jsx';
|
||||
import Espace from './pages/Espace.jsx';
|
||||
import Admin from './pages/Admin.jsx';
|
||||
import { useAuth } from './lib/AuthContext.jsx';
|
||||
@@ -33,6 +34,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/annuaire" element={<Annuaire />} />
|
||||
<Route path="/rencontres-passees" element={<RencontresPassees />} />
|
||||
<Route path="/association" element={<Association />} />
|
||||
<Route path="/espace-membre" element={<Espace />} />
|
||||
<Route path="/admin/*" element={<Admin />} />
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
// Full-screen photo carousel for a past event. `photos` is an array of
|
||||
// { id, path }. Scrollable with arrows, keyboard (←/→/Esc), a counter and
|
||||
// a thumbnail strip.
|
||||
export default function Carousel({ photos, title, startIndex = 0, onClose }) {
|
||||
const [i, setI] = useState(startIndex);
|
||||
const n = photos.length;
|
||||
const stripRef = useRef(null);
|
||||
|
||||
const go = useCallback((d) => setI((prev) => (prev + d + n) % n), [n]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
else if (e.key === 'ArrowLeft') go(-1);
|
||||
else if (e.key === 'ArrowRight') go(1);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [go, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
stripRef.current?.children[i]?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
}, [i]);
|
||||
|
||||
if (n === 0) return null;
|
||||
|
||||
const arrow = {
|
||||
position: 'absolute', top: '50%', transform: 'translateY(-50%)',
|
||||
width: 52, height: 52, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||
background: 'rgba(255,255,255,.14)', color: '#fff', fontSize: 24, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(10,9,8,.94)', display: 'flex', flexDirection: 'column', padding: '24px 24px 18px' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', color: '#fff', flexShrink: 0 }}>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 20, fontWeight: 600 }}>{title}</div>
|
||||
<div style={{ fontSize: 13, color: '#B7AFA6', marginTop: 2 }}>{i + 1} / {n}</div>
|
||||
</div>
|
||||
<button onClick={onClose} aria-label="Fermer" style={{ background: 'rgba(255,255,255,.14)', border: 'none', cursor: 'pointer', width: 40, height: 40, borderRadius: '50%', fontSize: 20, color: '#fff' }}>×</button>
|
||||
</div>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ flex: 1, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 0, margin: '14px 0' }}>
|
||||
<img src={photos[i].path} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 4 }} />
|
||||
{n > 1 && (
|
||||
<>
|
||||
<button onClick={() => go(-1)} aria-label="Précédente" style={{ ...arrow, left: 8 }}>‹</button>
|
||||
<button onClick={() => go(1)} aria-label="Suivante" style={{ ...arrow, right: 8 }}>›</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{n > 1 && (
|
||||
<div ref={stripRef} onClick={(e) => e.stopPropagation()} style={{ display: 'flex', gap: 8, overflowX: 'auto', flexShrink: 0, padding: '4px 2px' }}>
|
||||
{photos.map((p, idx) => (
|
||||
<button
|
||||
key={p.id ?? idx}
|
||||
onClick={() => setI(idx)}
|
||||
style={{ flexShrink: 0, width: 76, height: 54, borderRadius: 4, overflow: 'hidden', border: idx === i ? '2px solid #fff' : '2px solid transparent', padding: 0, cursor: 'pointer', background: 'none', opacity: idx === i ? 1 : 0.55 }}
|
||||
>
|
||||
<img src={p.path} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export default function Header() {
|
||||
<nav style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<NavLink to="/" end className={navLinkClass}>Accueil</NavLink>
|
||||
<NavLink to="/annuaire" className={navLinkClass}>Annuaire</NavLink>
|
||||
<NavLink to="/rencontres-passees" className={navLinkClass}>Rencontres passées</NavLink>
|
||||
<NavLink to="/association" className={navLinkClass}>L'association</NavLink>
|
||||
<NavLink to="/espace-membre" className={navLinkClass}>Espace membre</NavLink>
|
||||
<Link to="/" style={{ marginLeft: 10, textDecoration: 'none' }}>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import Carousel from './Carousel.jsx';
|
||||
|
||||
// Editorial card for one past event: text on the left, a 1-large-+-2-small
|
||||
// photo grid on the right. Clicking any photo (or "voir les N photos")
|
||||
// opens the full carousel with every photo.
|
||||
export default function PastEventCard({ event }) {
|
||||
const [carouselAt, setCarouselAt] = useState(null); // null | index
|
||||
const photos = event.photos || [];
|
||||
const hasPhotos = photos.length > 0;
|
||||
|
||||
const cell = (idx, extra = {}) => {
|
||||
const photo = photos[idx];
|
||||
return (
|
||||
<div
|
||||
className={photo ? '' : 'placeholder-pattern'}
|
||||
style={{ borderRadius: 3, overflow: 'hidden', cursor: photo ? 'pointer' : 'default', ...extra }}
|
||||
onClick={photo ? () => setCarouselAt(idx) : undefined}
|
||||
>
|
||||
{photo && <img src={photo.path} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<article className="card hero-grid" style={{ display: 'grid', gridTemplateColumns: '1.15fr .85fr', gap: 40, alignItems: 'center', padding: 36 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, letterSpacing: '.14em', textTransform: 'uppercase', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 12 }}>
|
||||
{event.date_label} · {event.lieu}
|
||||
</div>
|
||||
<h3 className="serif" style={{ fontSize: 30, fontWeight: 600, lineHeight: 1.12, marginBottom: 16 }}>{event.titre}</h3>
|
||||
<p style={{ fontSize: 15.5, lineHeight: 1.7, color: 'var(--gray)', marginBottom: 20 }}>{event.texte}</p>
|
||||
<div style={{ display: 'flex', gap: 20, fontSize: 13, color: 'var(--gray-light)', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span>👥 {event.participants} participants</span>
|
||||
<span>📷 {event.nb_photos} photo{event.nb_photos > 1 ? 's' : ''}</span>
|
||||
{hasPhotos && (
|
||||
<button className="btn-link" style={{ fontSize: 13 }} onClick={() => setCarouselAt(0)}>
|
||||
Voir les photos →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr', gap: 10, height: 240 }}>
|
||||
{cell(0, { gridRow: 'span 2' })}
|
||||
{cell(1)}
|
||||
{photos.length > 3 ? (
|
||||
<div
|
||||
style={{ borderRadius: 3, overflow: 'hidden', cursor: 'pointer', position: 'relative' }}
|
||||
onClick={() => setCarouselAt(2)}
|
||||
>
|
||||
<img src={photos[2].path} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'rgba(15,14,13,.55)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: "'Newsreader',serif", fontSize: 22, fontWeight: 600 }}>
|
||||
+{photos.length - 3}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
cell(2)
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{carouselAt !== null && (
|
||||
<Carousel photos={photos} title={event.titre} startIndex={carouselAt} onClose={() => setCarouselAt(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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(); }}
|
||||
/>
|
||||
|
||||
+11
-28
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { usePublicData } from '../lib/usePublic.js';
|
||||
import { api, dateParts } from '../lib/api.js';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
import PastEventCard from '../components/PastEventCard.jsx';
|
||||
|
||||
function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||
const [form, setForm] = useState({ nom: '', entreprise: '', email: '', tel: '' });
|
||||
@@ -246,36 +247,18 @@ export default function Home() {
|
||||
{/* PAST */}
|
||||
<section style={{ background: 'var(--beige)', marginTop: 56, padding: '96px 0' }}>
|
||||
<div className="container">
|
||||
<div style={{ marginBottom: 52 }}>
|
||||
<div className="kicker" style={{ marginBottom: 14 }}>Ils y étaient</div>
|
||||
<h2 className="serif" style={{ fontWeight: 500, fontSize: 44, lineHeight: 1.08 }}>Rencontres passées</h2>
|
||||
<div style={{ marginBottom: 52, display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div className="kicker" style={{ marginBottom: 14 }}>Ils y étaient</div>
|
||||
<h2 className="serif" style={{ fontWeight: 500, fontSize: 44, lineHeight: 1.08 }}>Rencontres passées</h2>
|
||||
</div>
|
||||
<Link to="/rencontres-passees" style={{ textDecoration: 'none' }}>
|
||||
<span className="btn-link" style={{ fontSize: 14, whiteSpace: 'nowrap' }}>Voir toutes les rencontres →</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 34 }}>
|
||||
{passees.map((p) => (
|
||||
<article key={p.id} className="card hero-grid" style={{ display: 'grid', gridTemplateColumns: '1.15fr .85fr', gap: 40, alignItems: 'center', padding: 36 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, letterSpacing: '.14em', textTransform: 'uppercase', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 12 }}>
|
||||
{p.date_label} · {p.lieu}
|
||||
</div>
|
||||
<h3 className="serif" style={{ fontSize: 30, fontWeight: 600, lineHeight: 1.12, marginBottom: 16 }}>{p.titre}</h3>
|
||||
<p style={{ fontSize: 15.5, lineHeight: 1.7, color: 'var(--gray)', marginBottom: 20 }}>{p.texte}</p>
|
||||
<div style={{ display: 'flex', gap: 20, fontSize: 13, color: 'var(--gray-light)' }}>
|
||||
<span>👥 {p.participants} participants</span>
|
||||
<span>📷 {p.nb_photos} photos</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr', gap: 10, height: 240 }}>
|
||||
<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>
|
||||
{passees.slice(0, 3).map((p) => (
|
||||
<PastEventCard key={p.id} event={p} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { usePublicData } from '../lib/usePublic.js';
|
||||
import PastEventCard from '../components/PastEventCard.jsx';
|
||||
|
||||
export default function RencontresPassees() {
|
||||
const { data } = usePublicData();
|
||||
const passees = data?.rencontresPassees ?? [];
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section style={{ background: '#fff', borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="container" style={{ padding: '70px 32px 60px' }}>
|
||||
<div className="kicker" style={{ marginBottom: 16 }}>Ils y étaient</div>
|
||||
<h1 className="serif" style={{ fontWeight: 500, fontSize: 'clamp(36px, 5vw, 54px)', lineHeight: 1.05, marginBottom: 18 }}>
|
||||
Nos rencontres passées
|
||||
</h1>
|
||||
<p style={{ fontSize: 17, lineHeight: 1.6, color: 'var(--gray)', maxWidth: 600 }}>
|
||||
Revivez en images les temps forts du Business Club : galas, visites privées, tables rondes
|
||||
et afterworks. Cliquez sur une rencontre pour parcourir toutes ses photos.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ background: 'var(--beige)', padding: '64px 0 96px' }}>
|
||||
<div className="container">
|
||||
{passees.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '80px 20px', color: 'var(--gray-light)' }}>
|
||||
<div className="serif" style={{ fontSize: 26, color: 'var(--ink)', marginBottom: 10 }}>Aucune rencontre passée</div>
|
||||
<p style={{ fontSize: 15 }}>Les prochains événements apparaîtront ici une fois terminés.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 34 }}>
|
||||
{passees.map((p) => (
|
||||
<PastEventCard key={p.id} event={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user