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
+69
-28
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user