mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
Prochaines dates : cartes de hauteur uniforme, détail en fenêtre
Une description longue étirait sa carte, donc toute la rangée de la grille, et laissait les voisines avec un grand vide sous leur bouton — même défaut que les cartes de l'annuaire avant leur uniformisation. Le titre (2 lignes), le lieu (1 ligne) et la description (3 lignes) ont désormais une hauteur figée et sont tronqués en fin de ligne ; un espaceur pousse les compteurs et les boutons en bas de carte. La ligne de lieu reste réservée quand la rencontre n'en a pas, sinon sa carte remonterait tout son bas. Le texte complet n'est plus perdu : « Voir le détail » ouvre une fenêtre avec l'image, la date, le lieu, la description entière, les places et le bouton d'inscription. Le texte tronqué reste dans le HTML (troncature CSS), l'indexation n'y perd rien. Les dates sont formatées côté serveur et passées déjà rendues : les formater dans le composant client les aurait rendues dans le fuseau du visiteur et provoqué une erreur d'hydratation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MS61rkgTUGgPYH88tuVx9U
This commit is contained in:
2 files changed
+226
-34
No files matched your search
@@ -5,6 +5,7 @@ import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { MeetingCard } from "@/components/MeetingCard";
|
||||
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
@@ -232,40 +233,31 @@ export default async function AssociationPage() {
|
||||
const remaining = Math.max(0, meeting.capacity - registered);
|
||||
const isRegistered = myMeetingIds.has(meeting.id);
|
||||
return (
|
||||
<article id={`rencontre-${meeting.id}`} key={meeting.id} className="lift" style={{ background: "#fff", border: isRegistered ? "2px solid #1f8a5b" : "1px solid #e6dcc6", borderRadius: 12, overflow: "hidden", scrollMarginTop: 100 }}>
|
||||
<div style={{ position: "relative", aspectRatio: "16 / 10", background: meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : "repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)" }}>
|
||||
{isRegistered && (
|
||||
<span style={{ position: "absolute", top: 12, right: 12, display: "inline-flex", alignItems: "center", gap: 6, background: "#1f8a5b", color: "#fff", borderRadius: 999, padding: "7px 12px", fontSize: 12, fontWeight: 800, boxShadow: "0 4px 14px rgba(22,72,49,0.28)" }}>
|
||||
✓ Inscrit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: 18 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
|
||||
{formatDate(meeting.startsAt)} · {formatTime(meeting.startsAt)}
|
||||
</div>
|
||||
<h3 className="font-display" style={{ margin: "7px 0 7px", color: "#26201a", fontSize: 21 }}>
|
||||
{meeting.title}
|
||||
</h3>
|
||||
{meeting.location && <div style={{ fontSize: 13, color: "#6c6150", fontWeight: 700 }}>{meeting.location}</div>}
|
||||
<p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.6, minHeight: 64 }}>
|
||||
{meeting.description}
|
||||
</p>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
|
||||
<span>{registered}/{meeting.capacity} inscrits</span>
|
||||
<span>{remaining} place(s)</span>
|
||||
</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>
|
||||
Jusqu'à {meeting.participantsPerAccount} participant{meeting.participantsPerAccount > 1 ? "s" : ""} par compte
|
||||
</div>
|
||||
<Link
|
||||
href={`/inscription/${meeting.id}`}
|
||||
style={{ display: "block", textAlign: "center", textDecoration: "none", background: remaining <= 0 && !isRegistered ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
|
||||
>
|
||||
{isRegistered ? "Modifier mon inscription" : remaining <= 0 ? "Complet" : memberId ? "S'inscrire" : "Connexion et inscription"}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
<MeetingCard
|
||||
key={meeting.id}
|
||||
meeting={{
|
||||
id: meeting.id,
|
||||
title: meeting.title,
|
||||
description: meeting.description,
|
||||
location: meeting.location,
|
||||
imageUrl: meeting.imageUrl,
|
||||
// Formatées ici : côté client, le fuseau du visiteur
|
||||
// donnerait une autre heure et casserait l'hydratation.
|
||||
dateLabel: formatDate(meeting.startsAt),
|
||||
timeLabel: formatTime(meeting.startsAt),
|
||||
capacity: meeting.capacity,
|
||||
registered,
|
||||
participantsPerAccount: meeting.participantsPerAccount,
|
||||
isRegistered,
|
||||
ctaLabel: isRegistered
|
||||
? "Modifier mon inscription"
|
||||
: remaining <= 0
|
||||
? "Complet"
|
||||
: memberId
|
||||
? "S'inscrire"
|
||||
: "Connexion et inscription",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Modal } from "@/components/Modal";
|
||||
|
||||
/**
|
||||
* Carte d'une rencontre à venir, de hauteur identique à ses voisines.
|
||||
*
|
||||
* Le texte d'une rencontre est libre : une description longue étirait sa carte,
|
||||
* donc toute la rangée de la grille, et laissait les autres avec un grand vide
|
||||
* sous leur bouton. Titre et description occupent désormais une hauteur figée
|
||||
* et sont tronqués en fin de ligne ; le texte complet reste lisible dans une
|
||||
* fenêtre ouverte par « Voir le détail ».
|
||||
*
|
||||
* Les dates arrivent **déjà formatées** depuis la page serveur : les formater
|
||||
* ici les rendrait dans le fuseau du visiteur, différent de celui du serveur,
|
||||
* et provoquerait une erreur d'hydratation.
|
||||
*/
|
||||
|
||||
export type UpcomingMeetingCardData = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
imageUrl: string | null;
|
||||
/** « vendredi 3 octobre 2026 ». */
|
||||
dateLabel: string;
|
||||
/** « 18:30 ». */
|
||||
timeLabel: string;
|
||||
capacity: number;
|
||||
registered: number;
|
||||
participantsPerAccount: number;
|
||||
isRegistered: boolean;
|
||||
/** Libellé du bouton d'inscription, décidé côté serveur (session, places). */
|
||||
ctaLabel: string;
|
||||
};
|
||||
|
||||
const STRIPE =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
|
||||
/**
|
||||
* Hauteurs figées des deux blocs de texte, même principe que `MemberCard` :
|
||||
* elles alignent les cartes quel que soit le texte saisi par le staff.
|
||||
*/
|
||||
const TITLE_FONT = 21;
|
||||
const TITLE_LINE = 1.25;
|
||||
const TITLE_LINES = 2;
|
||||
const DESC_FONT = 13.5;
|
||||
const DESC_LINE = 1.6;
|
||||
const DESC_LINES = 3;
|
||||
|
||||
const clampLines = (lines: number) =>
|
||||
({
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: lines,
|
||||
WebkitBoxOrient: "vertical" as const,
|
||||
overflow: "hidden",
|
||||
}) as const;
|
||||
|
||||
export function MeetingCard({ meeting }: { meeting: UpcomingMeetingCardData }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const remaining = Math.max(0, meeting.capacity - meeting.registered);
|
||||
const full = remaining <= 0 && !meeting.isRegistered;
|
||||
const perAccount = `Jusqu'à ${meeting.participantsPerAccount} participant${meeting.participantsPerAccount > 1 ? "s" : ""} par compte`;
|
||||
const cover = meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : STRIPE;
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`rencontre-${meeting.id}`}
|
||||
className="lift"
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: meeting.isRegistered ? "2px solid #1f8a5b" : "1px solid #e6dcc6",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
scrollMarginTop: 100,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative", aspectRatio: "16 / 10", background: cover, flexShrink: 0 }}>
|
||||
{meeting.isRegistered && (
|
||||
<span style={{ position: "absolute", top: 12, right: 12, display: "inline-flex", alignItems: "center", gap: 6, background: "#1f8a5b", color: "#fff", borderRadius: 999, padding: "7px 12px", fontSize: 12, fontWeight: 800, boxShadow: "0 4px 14px rgba(22,72,49,0.28)" }}>
|
||||
✓ Inscrit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 18, display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
|
||||
{meeting.dateLabel} · {meeting.timeLabel}
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="font-display"
|
||||
style={{
|
||||
margin: "7px 0 7px",
|
||||
color: "#26201a",
|
||||
fontSize: TITLE_FONT,
|
||||
lineHeight: TITLE_LINE,
|
||||
height: TITLE_FONT * TITLE_LINE * TITLE_LINES,
|
||||
...clampLines(TITLE_LINES),
|
||||
}}
|
||||
>
|
||||
{meeting.title}
|
||||
</h3>
|
||||
|
||||
{/* Le lieu est facultatif : sa ligne est réservée dans tous les cas,
|
||||
sinon une carte sans lieu remonterait tout son bas. */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "#6c6150",
|
||||
fontWeight: 700,
|
||||
height: 13 * 1.4,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{meeting.location}
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
color: "#8c8068",
|
||||
fontSize: DESC_FONT,
|
||||
lineHeight: DESC_LINE,
|
||||
height: DESC_FONT * DESC_LINE * DESC_LINES,
|
||||
margin: "8px 0 0",
|
||||
...clampLines(DESC_LINES),
|
||||
}}
|
||||
>
|
||||
{meeting.description}
|
||||
</p>
|
||||
|
||||
{/* Espaceur : colle le bas de carte au bas du cadre, quelle que soit la
|
||||
hauteur imposée par la plus haute carte de la rangée. */}
|
||||
<div style={{ flex: 1, minHeight: 12 }} />
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 8 }}>
|
||||
<span>{meeting.registered}/{meeting.capacity} inscrits</span>
|
||||
<span>{remaining} place(s)</span>
|
||||
</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>{perAccount}</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
style={{ flex: "0 0 auto", border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", borderRadius: 8, padding: "11px 14px", fontWeight: 800, fontSize: 13.5, cursor: "pointer", fontFamily: "inherit" }}
|
||||
>
|
||||
Voir le détail
|
||||
</button>
|
||||
<Link
|
||||
href={`/inscription/${meeting.id}`}
|
||||
style={{ flex: 1, textAlign: "center", textDecoration: "none", background: full ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
|
||||
>
|
||||
{meeting.ctaLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} title={meeting.title} maxWidth={560}>
|
||||
{meeting.imageUrl && (
|
||||
<div style={{ aspectRatio: "16 / 10", background: cover, borderRadius: 12, marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<div style={{ color: "#9a6638", fontSize: 13, fontWeight: 800 }}>
|
||||
{meeting.dateLabel} · {meeting.timeLabel}
|
||||
</div>
|
||||
{meeting.location && (
|
||||
<div style={{ fontSize: 13.5, color: "#6c6150", fontWeight: 700, marginTop: 4 }}>{meeting.location}</div>
|
||||
)}
|
||||
|
||||
{meeting.description && (
|
||||
<p style={{ color: "#6c6150", fontSize: 14, lineHeight: 1.7, whiteSpace: "pre-line", margin: "14px 0 0" }}>
|
||||
{meeting.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 12, padding: "12px 14px", margin: "18px 0 6px", fontSize: 13, fontWeight: 800, color: "#6c6150" }}>
|
||||
<span>{meeting.registered}/{meeting.capacity} inscrits</span>
|
||||
<span>{remaining} place(s) restante(s)</span>
|
||||
</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 16 }}>{perAccount}</div>
|
||||
|
||||
<Link
|
||||
href={`/inscription/${meeting.id}`}
|
||||
style={{ display: "block", textAlign: "center", textDecoration: "none", background: full ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 12, fontWeight: 800 }}
|
||||
>
|
||||
{meeting.ctaLabel}
|
||||
</Link>
|
||||
</Modal>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user