diff --git a/README.md b/README.md index 8fe94a5..500d1d5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 77ac102..ef98982 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -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 { diff --git a/server/src/routes/public.js b/server/src/routes/public.js index 0bef2b5..6d470ab 100644 --- a/server/src/routes/public.js +++ b/server/src/routes/public.js @@ -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({ diff --git a/server/src/schemas.js b/server/src/schemas.js index 80423c8..95da047 100644 --- a/server/src/schemas.js +++ b/server/src/schemas.js @@ -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) }); diff --git a/server/src/sql/schema.sql b/server/src/sql/schema.sql index 10a8957..2a252a0 100644 --- a/server/src/sql/schema.sql +++ b/server/src/sql/schema.sql @@ -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, diff --git a/server/src/sql/seed.sql b/server/src/sql/seed.sql index 939ee3b..c08ce4a 100644 --- a/server/src/sql/seed.sql +++ b/server/src/sql/seed.sql @@ -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'), diff --git a/web/src/App.jsx b/web/src/App.jsx index 21e78e9..094b668 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -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() { } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/Carousel.jsx b/web/src/components/Carousel.jsx new file mode 100644 index 0000000..18cd2e3 --- /dev/null +++ b/web/src/components/Carousel.jsx @@ -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 ( +
+
+
+
{title}
+
{i + 1} / {n}
+
+ +
+ +
e.stopPropagation()} style={{ flex: 1, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 0, margin: '14px 0' }}> + + {n > 1 && ( + <> + + + + )} +
+ + {n > 1 && ( +
e.stopPropagation()} style={{ display: 'flex', gap: 8, overflowX: 'auto', flexShrink: 0, padding: '4px 2px' }}> + {photos.map((p, idx) => ( + + ))} +
+ )} +
+ ); +} diff --git a/web/src/components/Header.jsx b/web/src/components/Header.jsx index 4ede010..b310f66 100644 --- a/web/src/components/Header.jsx +++ b/web/src/components/Header.jsx @@ -27,6 +27,7 @@ export default function Header() {