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

+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 {