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:
Claude committed 2026-07-13 18:13:32 +00:00
1 parent 214250bc39
commit 298c3becd5
13 files changed
+476 -139

No files matched your search

+10 -3
View File
@@ -101,6 +101,9 @@ l'utilisateur est bloqué sur un écran de changement obligatoire avant d'accéd
rencontres passées, formulaire de demande d'adhésion.
- Annuaire : recherche plein texte, filtres par catégorie, fiche détaillée par entreprise.
Seuls les membres **validés pour la saison** apparaissent.
- Rencontres passées : page dédiée listant toutes les rencontres passées ; un clic ouvre un
carrousel plein écran de **toutes** les photos (flèches, clavier, miniatures). Le nombre de
participants et de photos est calculé automatiquement (voir back-office).
- L'association : mission, valeurs, mot du président, chronologie, chiffres clés.
**Espace membre** (connexion email + mot de passe)
@@ -133,9 +136,13 @@ l'utilisateur est bloqué sur un écran de changement obligatoire avant d'accéd
membre ne l'a pas changé. L'admin peut aussi réinitialiser l'accès à tout moment.
- Rencontres : création, édition (avec photo affichée sur les cartes de la page d'accueil) ;
suppression réservée aux administrateurs. Liste des inscrits avec impression et export Excel.
- Rencontres passées : gestion complète de la section « Ils y étaient » de la page d'accueil —
titre, date, lieu, description, participants, et jusqu'à 3 photos par rencontre (1 grande +
2 petites, comme affiché sur l'accueil) ; suppression réservée aux administrateurs.
- Rencontres passées : gestion complète de la section « Ils y étaient » et de la page publique
dédiée — titre, date, lieu, description, et **galerie de photos illimitée** par rencontre
(ajout, suppression réservée aux admins, choix de la photo principale mise en avant). Le
**nombre de photos affiché est celui réellement uploadé** (jamais saisi à la main), et le
**nombre de participants est récupéré automatiquement** quand la rencontre passée est liée à
une rencontre (comptage de ses inscriptions) ; sinon une valeur manuelle sert de repli.
La suppression d'une rencontre passée ou d'une de ses photos est réservée aux administrateurs.
- Inscriptions : modification, confirmation ; annulation (suppression) réservée aux administrateurs.
- Catégories : ajout, renommage, suppression (les entreprises deviennent « Non classée »).
- Contenu du site : citation, signature et photo de la page d'accueil.
+69 -28
View File
@@ -11,7 +11,7 @@ import {
staffUserSchema,
roleChangeSchema,
rencontrePasseeSchema,
pastEventImageParam,
pastPhotoParam,
idParam,
} from '../schemas.js';
import { imageUpload, saveImage, deleteImage } from '../uploads.js';
@@ -237,15 +237,28 @@ adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), asy
});
// ---------- Rencontres passées (admin + moderator manage; admin-only delete) ----------
// Powers the "Rencontres passées" gallery on the home page.
// Powers the "Rencontres passées" section on the home page and its dedicated
// public page. Participant count is derived from the linked rencontre's
// inscriptions when set, otherwise the manual fallback. Photo count and the
// photo list come from the rencontre_passee_photos gallery.
const PAST_SQL = `
SELECT id, date_label, lieu, titre, texte, participants, nb_photos,
image_path, image_path_2, image_path_3
FROM rencontres_passees`;
SELECT rp.id, rp.date_label, rp.lieu, rp.titre, rp.texte, rp.participants, rp.rencontre_id,
CASE WHEN rp.rencontre_id IS NOT NULL THEN COALESCE(rc.inscrits, 0) ELSE rp.participants END AS participants_display,
COALESCE(ph.n, 0) AS nb_photos,
COALESCE(ph.photos, '[]'::json) AS photos
FROM rencontres_passees rp
LEFT JOIN (
SELECT rencontre_passee_id, COUNT(*)::int AS n,
json_agg(json_build_object('id', id, 'path', image_path) ORDER BY position, id) AS photos
FROM rencontre_passee_photos GROUP BY rencontre_passee_id
) ph ON ph.rencontre_passee_id = rp.id
LEFT JOIN (
SELECT rencontre_id, COUNT(*)::int AS inscrits FROM inscriptions GROUP BY rencontre_id
) rc ON rc.rencontre_id = rp.rencontre_id`;
adminRouter.get('/rencontres-passees', async (_req, res, next) => {
try {
const result = await query(`${PAST_SQL} ORDER BY id DESC`);
const result = await query(`${PAST_SQL} ORDER BY rp.id DESC`);
res.json({ rencontresPassees: result.rows });
} catch (err) {
next(err);
@@ -256,9 +269,9 @@ adminRouter.post('/rencontres-passees', validate(rencontrePasseeSchema), async (
try {
const d = req.data;
const result = await query(
`INSERT INTO rencontres_passees (date_label, lieu, titre, texte, participants, nb_photos)
`INSERT INTO rencontres_passees (date_label, lieu, titre, texte, participants, rencontre_id)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
[d.date_label, d.lieu, d.titre, d.texte, d.participants, d.nb_photos]
[d.date_label, d.lieu, d.titre, d.texte, d.participants ?? 0, d.rencontre_id ?? null]
);
res.status(201).json({ id: result.rows[0].id });
} catch (err) {
@@ -270,9 +283,9 @@ adminRouter.put('/rencontres-passees/:id', validate(idParam, 'params'), validate
try {
const d = req.data;
const result = await query(
`UPDATE rencontres_passees SET date_label=$1, lieu=$2, titre=$3, texte=$4, participants=$5, nb_photos=$6
`UPDATE rencontres_passees SET date_label=$1, lieu=$2, titre=$3, texte=$4, participants=$5, rencontre_id=$6
WHERE id=$7 RETURNING id`,
[d.date_label, d.lieu, d.titre, d.texte, d.participants, d.nb_photos, req.params.id]
[d.date_label, d.lieu, d.titre, d.texte, d.participants ?? 0, d.rencontre_id ?? null, req.params.id]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre passée introuvable' });
res.json({ ok: true });
@@ -282,44 +295,72 @@ adminRouter.put('/rencontres-passees/:id', validate(idParam, 'params'), validate
});
// Deleting is admin-only, same reasoning as rencontres/inscriptions above.
// ON DELETE CASCADE removes the photo rows; we delete their files first.
adminRouter.delete('/rencontres-passees/:id', adminOnly, validate(idParam, 'params'), async (req, res, next) => {
try {
const result = await query(
'DELETE FROM rencontres_passees WHERE id = $1 RETURNING image_path, image_path_2, image_path_3',
[req.params.id]
);
if (result.rowCount > 0) {
const row = result.rows[0];
await Promise.all([deleteImage(row.image_path), deleteImage(row.image_path_2), deleteImage(row.image_path_3)]);
}
const photos = await query('SELECT image_path FROM rencontre_passee_photos WHERE rencontre_passee_id = $1', [req.params.id]);
await query('DELETE FROM rencontres_passees WHERE id = $1', [req.params.id]);
await Promise.all(photos.rows.map((p) => deleteImage(p.image_path)));
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// Three photo slots per past event (matches the home page's 1 large + 2
// small grid). :slot is 1, 2 or 3.
const PAST_IMAGE_COLUMNS = { 1: 'image_path', 2: 'image_path_2', 3: 'image_path_3' };
adminRouter.post('/rencontres-passees/:id/image/:slot', validate(pastEventImageParam, 'params'), (req, res, next) => {
// Add a photo to a past event's gallery (unlimited). Admin + moderator.
adminRouter.post('/rencontres-passees/:id/photos', validate(idParam, 'params'), (req, res, next) => {
imageUpload(req, res, async (err) => {
if (err) return res.status(400).json({ error: 'Fichier invalide (2 Mo max).' });
try {
if (!req.file) return res.status(400).json({ error: 'Aucun fichier reçu.' });
const column = PAST_IMAGE_COLUMNS[req.params.slot];
const existing = await query(`SELECT ${column} AS path FROM rencontres_passees WHERE id = $1`, [req.params.id]);
if (existing.rowCount === 0) return res.status(404).json({ error: 'Rencontre passée introuvable' });
const event = await query('SELECT 1 FROM rencontres_passees WHERE id = $1', [req.params.id]);
if (event.rowCount === 0) return res.status(404).json({ error: 'Rencontre passée introuvable' });
const publicPath = await saveImage(req.file.buffer);
if (!publicPath) return res.status(400).json({ error: 'Format accepté : JPEG, PNG ou WebP.' });
await query(`UPDATE rencontres_passees SET ${column} = $1 WHERE id = $2`, [publicPath, req.params.id]);
await deleteImage(existing.rows[0].path);
res.json({ path: publicPath });
const result = await query(
`INSERT INTO rencontre_passee_photos (rencontre_passee_id, image_path, position)
VALUES ($1, $2, COALESCE((SELECT MAX(position) + 1 FROM rencontre_passee_photos WHERE rencontre_passee_id = $1), 0))
RETURNING id`,
[req.params.id, publicPath]
);
res.status(201).json({ id: result.rows[0].id, path: publicPath });
} catch (e) {
next(e);
}
});
});
// Promote a photo to first position (the large one on the home page). Admin + moderator.
adminRouter.post('/rencontres-passees/:id/photos/:photoId/principale', validate(pastPhotoParam, 'params'), async (req, res, next) => {
try {
const result = await query(
`UPDATE rencontre_passee_photos
SET position = COALESCE((SELECT MIN(position) FROM rencontre_passee_photos WHERE rencontre_passee_id = $1), 0) - 1
WHERE id = $2 AND rencontre_passee_id = $1 RETURNING id`,
[req.params.id, req.params.photoId]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Photo introuvable' });
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// Removing a photo is a deletion — admin only.
adminRouter.delete('/rencontres-passees/:id/photos/:photoId', adminOnly, validate(pastPhotoParam, 'params'), async (req, res, next) => {
try {
const result = await query(
'DELETE FROM rencontre_passee_photos WHERE id = $1 AND rencontre_passee_id = $2 RETURNING image_path',
[req.params.photoId, req.params.id]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Photo introuvable' });
await deleteImage(result.rows[0].image_path);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// ---------- Inscriptions (admin + moderator) ----------
adminRouter.get('/inscriptions', async (_req, res, next) => {
try {
+15 -1
View File
@@ -25,7 +25,21 @@ publicRouter.get('/bootstrap', async (_req, res, next) => {
WHERE r.date_renc >= CURRENT_DATE
GROUP BY r.id ORDER BY r.date_renc`
),
query('SELECT * FROM rencontres_passees ORDER BY id DESC'),
query(`
SELECT rp.id, rp.date_label, rp.lieu, rp.titre, rp.texte,
CASE WHEN rp.rencontre_id IS NOT NULL THEN COALESCE(rc.inscrits, 0) ELSE rp.participants END AS participants,
COALESCE(ph.n, 0) AS nb_photos,
COALESCE(ph.photos, '[]'::json) AS photos
FROM rencontres_passees rp
LEFT JOIN (
SELECT rencontre_passee_id, COUNT(*)::int AS n,
json_agg(json_build_object('id', id, 'path', image_path) ORDER BY position, id) AS photos
FROM rencontre_passee_photos GROUP BY rencontre_passee_id
) ph ON ph.rencontre_passee_id = rp.id
LEFT JOIN (
SELECT rencontre_id, COUNT(*)::int AS inscrits FROM inscriptions GROUP BY rencontre_id
) rc ON rc.rencontre_id = rp.rencontre_id
ORDER BY rp.id DESC`),
]);
const contentMap = Object.fromEntries(content.rows.map((r) => [r.key, r.value]));
res.json({
+5 -4
View File
@@ -71,13 +71,14 @@ export const rencontrePasseeSchema = z.object({
lieu: trimmed(200, 1),
titre: trimmed(200, 1),
texte: trimmed(2000, 1),
participants: z.coerce.number().int().min(0).max(100000),
nb_photos: z.coerce.number().int().min(0).max(100000),
// Manual fallback participant count; ignored when rencontre_id is set.
participants: z.coerce.number().int().min(0).max(100000).optional().default(0),
rencontre_id: z.coerce.number().int().positive().nullable().optional(),
});
export const pastEventImageParam = z.object({
export const pastPhotoParam = z.object({
id: z.coerce.number().int().positive(),
slot: z.coerce.number().int().min(1).max(3),
photoId: z.coerce.number().int().positive(),
});
export const categorySchema = z.object({ name: trimmed(80, 1) });
+28 -1
View File
@@ -79,9 +79,14 @@ CREATE TABLE IF NOT EXISTS rencontres_passees (
lieu TEXT NOT NULL,
titre TEXT NOT NULL,
texte TEXT NOT NULL,
-- Fallback participant count, used only when this event is NOT linked to
-- a rencontre. When rencontre_id is set, the count is derived live from
-- that rencontre's inscriptions.
participants INTEGER NOT NULL DEFAULT 0,
-- Optional link to the rencontre this event came from (auto stats).
rencontre_id INTEGER REFERENCES rencontres(id) ON DELETE SET NULL,
-- Legacy inline photo columns, migrated into rencontre_passee_photos below.
nb_photos INTEGER NOT NULL DEFAULT 0,
-- 1 large + 2 small photos, matching the home page grid.
image_path TEXT,
image_path_2 TEXT,
image_path_3 TEXT
@@ -89,6 +94,28 @@ CREATE TABLE IF NOT EXISTS rencontres_passees (
ALTER TABLE rencontres_passees ADD COLUMN IF NOT EXISTS image_path TEXT;
ALTER TABLE rencontres_passees ADD COLUMN IF NOT EXISTS image_path_2 TEXT;
ALTER TABLE rencontres_passees ADD COLUMN IF NOT EXISTS image_path_3 TEXT;
ALTER TABLE rencontres_passees ADD COLUMN IF NOT EXISTS rencontre_id INTEGER REFERENCES rencontres(id) ON DELETE SET NULL;
-- Unlimited photos per past event (gallery / carousel).
CREATE TABLE IF NOT EXISTS rencontre_passee_photos (
id SERIAL PRIMARY KEY,
rencontre_passee_id INTEGER NOT NULL REFERENCES rencontres_passees(id) ON DELETE CASCADE,
image_path TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rpp_event ON rencontre_passee_photos(rencontre_passee_id);
-- One-time migration of the legacy inline image columns into the photos
-- table. Idempotent: after the UPDATE nulls the columns, it becomes a no-op.
INSERT INTO rencontre_passee_photos (rencontre_passee_id, image_path, position)
SELECT id, image_path, 0 FROM rencontres_passees WHERE image_path IS NOT NULL;
INSERT INTO rencontre_passee_photos (rencontre_passee_id, image_path, position)
SELECT id, image_path_2, 1 FROM rencontres_passees WHERE image_path_2 IS NOT NULL;
INSERT INTO rencontre_passee_photos (rencontre_passee_id, image_path, position)
SELECT id, image_path_3, 2 FROM rencontres_passees WHERE image_path_3 IS NOT NULL;
UPDATE rencontres_passees SET image_path = NULL, image_path_2 = NULL, image_path_3 = NULL
WHERE image_path IS NOT NULL OR image_path_2 IS NOT NULL OR image_path_3 IS NOT NULL;
CREATE TABLE IF NOT EXISTS demandes_adhesion (
id SERIAL PRIMARY KEY,
+7 -4
View File
@@ -42,10 +42,13 @@ INSERT INTO inscriptions (rencontre_id, nom, entreprise, email, tel, statut) VAL
-- Inserted oldest-first: the home page and admin list show these newest
-- first (ORDER BY id DESC), so insertion order must be chronological.
INSERT INTO rencontres_passees (date_label, lieu, titre, texte, participants, nb_photos) VALUES
('Février 2026', 'La Filature, Nancy', 'Table ronde — Digitaliser sa PME', 'Retours d''expérience et bonnes pratiques : trois dirigeants membres ont partagé leur parcours de transformation numérique devant une salle comble.', 56, 15),
('Avril 2026', 'Site industriel, Florange', 'Visite privée — Usine ArcelorMittal', 'Immersion industrielle exclusive pour nos membres au cœur d''un fleuron de la métallurgie lorraine, suivie d''un échange avec la direction du site.', 34, 22),
('Juin 2026', 'Château de Rémicourt', 'Soirée de Gala annuelle', 'Plus de 200 convives réunis pour célébrer une saison d''exception. Une soirée d''élégance mêlant remise de trophées, dîner gastronomique et rencontres privilégiées entre membres du Club.', 210, 48);
-- The displayed photo count is derived from actual uploads (0 until photos
-- are added); the participants value here is the manual fallback shown when
-- the event isn't linked to a rencontre.
INSERT INTO rencontres_passees (date_label, lieu, titre, texte, participants) VALUES
('Février 2026', 'La Filature, Nancy', 'Table ronde — Digitaliser sa PME', 'Retours d''expérience et bonnes pratiques : trois dirigeants membres ont partagé leur parcours de transformation numérique devant une salle comble.', 56),
('Avril 2026', 'Site industriel, Florange', 'Visite privée — Usine ArcelorMittal', 'Immersion industrielle exclusive pour nos membres au cœur d''un fleuron de la métallurgie lorraine, suivie d''un échange avec la direction du site.', 34),
('Juin 2026', 'Château de Rémicourt', 'Soirée de Gala annuelle', 'Plus de 200 convives réunis pour célébrer une saison d''exception. Une soirée d''élégance mêlant remise de trophées, dîner gastronomique et rencontres privilégiées entre membres du Club.', 210);
INSERT INTO demandes_adhesion (nom, fonction, entreprise, email) VALUES
('Marc Dubois', 'Directeur général', 'Dubois Traiteur', 'm.dubois@dubois-traiteur.fr'),
+2
View File
@@ -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 />} />
+74
View File
@@ -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>
);
}
+1
View File
@@ -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' }}>
+68
View File
@@ -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)} />
)}
</>
);
}
+145 -70
View File
@@ -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
View File
@@ -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>
+41
View File
@@ -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>
);
}