mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 06:38:05 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d31935641 |
No files matched your search
@@ -139,12 +139,14 @@ même raison.
|
||||
- Les images de promo sont stockées en data-URI : l'upload se fait donc en
|
||||
binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas
|
||||
par URL.
|
||||
- L'image part **telle quelle** : aucun recadrage ni redimensionnement côté
|
||||
application. Un visuel non carré est donc recadré — ou entouré de bandes de
|
||||
couleur — par la plateforme. `src/lib/image-info.ts` (pur, verrouillé par
|
||||
`tests/image-info.test.ts`) décrit le fichier déposé (dimensions, format,
|
||||
poids) et `MemberSpaceForm` l'affiche sous l'aperçu : on informe, on ne
|
||||
bloque pas.
|
||||
- **Aucun recadrage** : le rapport largeur/hauteur n'est jamais touché, un
|
||||
visuel non carré est donc recadré — ou entouré de bandes de couleur — par la
|
||||
plateforme. `src/lib/image-info.ts` (pur, verrouillé par
|
||||
`tests/image-info.test.ts`) décrit l'image **telle qu'elle sera publiée** et
|
||||
`MemberSpaceForm` l'affiche sous l'aperçu : on informe, on ne bloque pas.
|
||||
- Le poids, lui, est ramené dans les clous **par le navigateur** (cf. Images)
|
||||
avant l'envoi : une photo de téléphone dépassait la limite de 3 Mo du serveur
|
||||
et la publication échouait sans recours depuis l'écran de l'adhérent.
|
||||
- La diffusion est déclenchée **par la validation** (ou par l'échéance d'une
|
||||
publication programmée), pas par un bouton :
|
||||
`promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent,
|
||||
@@ -168,6 +170,64 @@ même raison.
|
||||
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
|
||||
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
|
||||
|
||||
## Images
|
||||
|
||||
Toute image déposée est **compressée dans le navigateur** avant de partir :
|
||||
elle voyage en data-URI à travers une server action plafonnée à 4 Mo et finit
|
||||
stockée en base, alors qu'une photo de téléphone pèse 4 à 12 Mo. L'ancien
|
||||
réflexe — « Image trop lourde (max ~900 Ko), compressez-la d'abord » — revenait
|
||||
à demander à l'utilisateur ce que le navigateur sait faire seul.
|
||||
|
||||
- `src/lib/image-compress.ts` est **pur** (`tests/image-compress.test.ts`) :
|
||||
seuils, `scaleToFit()` (jamais d'agrandissement), `compressionSteps()` (on
|
||||
épuise la qualité à définition pleine **avant** de réduire la définition),
|
||||
`base64Bytes()`, `compressionSummary()` et `chunkByBytes()` pour découper un
|
||||
envoi groupé sous la limite d'une requête.
|
||||
- `src/lib/image-compress-dom.ts` porte `prepareImageFile()` : décodage
|
||||
(orientation EXIF appliquée via `createImageBitmap`, sans quoi une photo de
|
||||
téléphone repartirait couchée), canvas, encodage. Une image **déjà dans le
|
||||
budget n'est pas réencodée** — un réencodage JPEG dégrade toujours un peu —
|
||||
et une image **avec transparence** ne devient jamais un JPEG (WebP, sinon
|
||||
PNG) : un logo se retrouverait sur fond noir.
|
||||
- Budgets : `FIELD_MAX_BYTES` (1,2 Mo) pour une image de formulaire,
|
||||
`PHOTO_MAX_BYTES` (800 Ko) pour une photo de galerie, `MAX_SIDE` 2048 px.
|
||||
- `ImageField` et `MemberSpaceForm` passent par là et affichent ce qui a été
|
||||
fait (« 4,2 Mo → 620 Ko »). Un format que le navigateur ne sait pas décoder
|
||||
(HEIC d'iPhone sur un poste Windows) est le seul refus, et il est expliqué.
|
||||
|
||||
## Rencontres passées
|
||||
|
||||
Le backoffice (`/backend/rencontres-passees`) est une grille de cartes ; tout
|
||||
le reste se passe dans une **fenêtre** (`PastMeetingDialog`) : titre, date,
|
||||
lieu, rencontre liée, récit, participants **et photos**, y compris à la
|
||||
création. Les photos ne dépendent plus d'une archive déjà publiée.
|
||||
|
||||
- `savePastMeeting()` crée ou met à jour, `addPastMeetingPhotos()` attache un
|
||||
lot (plusieurs appels : `chunkByBytes` découpe sous la limite des server
|
||||
actions et rend les identifiants insérés), `savePastMeetingPhotos()` aligne
|
||||
ordre, légendes et suppressions. Cette dernière exige `syncPhotos=1` : sans
|
||||
ce drapeau, un appel amputé de sa liste viderait la galerie en silence.
|
||||
- Les refus attendus sont **renvoyés** (`ActionError`), jamais levés ; la
|
||||
rencontre liée est vérifiée avant l'écriture, une rencontre supprimée
|
||||
entre-temps donnant sinon une violation de clé étrangère sans explication.
|
||||
- **La fenêtre ne se referme qu'une fois la page rafraîchie**
|
||||
(`closing && !refreshing`). Sans cette attente, rouvrir la fiche montre
|
||||
l'état d'avant l'enregistrement — la rencontre liée revenue à « Aucune » —
|
||||
et on croit que rien n'a été retenu.
|
||||
- `ModalShell` (partagé avec le site public) rend la fenêtre dans un
|
||||
**portail** vers `<body>` : une carte survolée porte un `transform`, qui
|
||||
redéfinit le bloc conteneur d'un `position: fixed`, et la fenêtre s'ouvrirait
|
||||
*dans* la carte. Le portail la sort aussi du bouton qui l'ouvre.
|
||||
- Côté public, `PastMeetingCard` rend une carte de **hauteur fixe** (424 px,
|
||||
couverture + titre sur 2 lignes + 3 lignes de texte écrêtées par
|
||||
`line-clamp`, d'où des classes dans `globals.css` plutôt que des styles en
|
||||
ligne) : le récit complet et toutes les photos vivent dans la fenêtre, avec
|
||||
galerie, flèches, vignettes et navigation au clavier. Les sauts de ligne du
|
||||
récit sont conservés (`white-space: pre-line`).
|
||||
- Le nombre de participants affiché vient des inscriptions **si** une rencontre
|
||||
du calendrier est liée, sinon des lignes saisies à la main ; la fenêtre le
|
||||
dit sous le champ pour éviter la surprise.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- **Erreurs des server actions** : un `throw` dans une action est masqué par
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { PastMeetingCard } from "@/components/PastMeetingCard";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import {
|
||||
@@ -45,6 +45,11 @@ function formatDate(date: Date) {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/** Sans le jour de la semaine : la carte d'archive est déjà dense. */
|
||||
function formatPastDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
|
||||
}
|
||||
|
||||
function formatTime(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit" }).format(date);
|
||||
}
|
||||
@@ -290,16 +295,18 @@ export default async function AssociationPage() {
|
||||
? registrationCounts.get(meeting.meetingId) ?? 0
|
||||
: splitLines(meeting.participants ?? "").length;
|
||||
return (
|
||||
<article key={meeting.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 18 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(meeting.eventDate)}</div>
|
||||
<h3 className="font-display" style={{ color: "#26201a", fontSize: 22, margin: "7px 0" }}>{meeting.title}</h3>
|
||||
{meeting.location && <div style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{meeting.location}</div>}
|
||||
{meeting.description && <p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.65 }}>{meeting.description}</p>}
|
||||
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
|
||||
{participantCount} participant{participantCount > 1 ? "s" : ""} · {photos.length} photo{photos.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
<PastMeetingGallery title={meeting.title} photos={photos} />
|
||||
</article>
|
||||
<PastMeetingCard
|
||||
key={meeting.id}
|
||||
meeting={{
|
||||
id: meeting.id,
|
||||
title: meeting.title,
|
||||
dateLabel: formatPastDate(meeting.eventDate),
|
||||
location: meeting.location,
|
||||
description: meeting.description,
|
||||
participantCount,
|
||||
photos: photos.map((photo) => ({ id: photo.id, imageUrl: photo.imageUrl, caption: photo.caption })),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
+149
-57
@@ -982,46 +982,76 @@ export async function deleteMeetingRegistration(formData: FormData) {
|
||||
revalidateMeetingPaths(registration?.meetingId);
|
||||
}
|
||||
|
||||
export async function createPastMeeting(formData: FormData) {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
|
||||
const title = asString(formData, "title");
|
||||
if (!title) return;
|
||||
await db.insert(pastMeetings).values({
|
||||
title,
|
||||
eventDate: asDate(formData, "eventDate"),
|
||||
location: asNullableString(formData, "location"),
|
||||
description: asNullableString(formData, "description"),
|
||||
participants: asNullableString(formData, "participants"),
|
||||
meetingId: formData.get("meetingId") ? Number(formData.get("meetingId")) : null,
|
||||
});
|
||||
/** Chemins où une rencontre passée est affichée, publics comme backoffice. */
|
||||
function revalidatePastMeetingPaths() {
|
||||
revalidatePath("/backend/rencontres");
|
||||
revalidatePath("/association");
|
||||
revalidatePath("/backend/rencontres-passees");
|
||||
revalidatePath("/association");
|
||||
revalidatePath("/rencontres-passees");
|
||||
}
|
||||
|
||||
export async function updatePastMeeting(formData: FormData) {
|
||||
export type SavedPastMeeting = { id: number };
|
||||
|
||||
/**
|
||||
* Crée ou met à jour une rencontre passée. Une seule action pour les deux cas :
|
||||
* la fenêtre du backoffice ne fait pas la différence, elle enregistre une fiche
|
||||
* puis lui attache ses photos.
|
||||
*
|
||||
* Les refus attendus — titre vide, date illisible, rencontre liée disparue —
|
||||
* sont **renvoyés** et affichés par la fenêtre, qui garde la saisie. Un `throw`
|
||||
* deviendrait un digest illisible en production, et la fiche en cours de
|
||||
* rédaction serait perdue.
|
||||
*/
|
||||
export async function savePastMeeting(formData: FormData): Promise<SavedPastMeeting | ActionError> {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
|
||||
const id = Number(formData.get("id"));
|
||||
|
||||
const id = Number(formData.get("id") ?? 0) || null;
|
||||
const title = asString(formData, "title");
|
||||
if (!id || !title) return;
|
||||
await db
|
||||
.update(pastMeetings)
|
||||
.set({
|
||||
title,
|
||||
eventDate: asDate(formData, "eventDate"),
|
||||
location: asNullableString(formData, "location"),
|
||||
description: asNullableString(formData, "description"),
|
||||
participants: asNullableString(formData, "participants"),
|
||||
meetingId: formData.get("meetingId") ? Number(formData.get("meetingId")) : null,
|
||||
})
|
||||
.where(eq(pastMeetings.id, id));
|
||||
revalidatePath("/backend/rencontres");
|
||||
revalidatePath("/association");
|
||||
revalidatePath("/backend/rencontres-passees");
|
||||
revalidatePath("/rencontres-passees");
|
||||
if (!title) return { error: "Donnez un titre à cette rencontre." };
|
||||
|
||||
const rawDate = asString(formData, "eventDate");
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(rawDate)) return { error: "Renseignez la date de la rencontre." };
|
||||
const eventDate = new Date(`${rawDate}T12:00:00`);
|
||||
if (Number.isNaN(eventDate.getTime())) return { error: "Date invalide." };
|
||||
|
||||
// Le lien vers une rencontre du calendrier est vérifié : une rencontre
|
||||
// supprimée entre l'ouverture de la fenêtre et l'enregistrement ferait sinon
|
||||
// échouer l'écriture sur une violation de clé étrangère, donc sans explication.
|
||||
const rawMeetingId = asString(formData, "meetingId");
|
||||
let meetingId: number | null = null;
|
||||
if (rawMeetingId) {
|
||||
meetingId = Number(rawMeetingId);
|
||||
if (!Number.isInteger(meetingId) || meetingId <= 0) return { error: "Rencontre liée invalide." };
|
||||
const [linked] = await db.select({ id: meetings.id }).from(meetings).where(eq(meetings.id, meetingId));
|
||||
if (!linked) return { error: "Cette rencontre du calendrier n'existe plus." };
|
||||
}
|
||||
|
||||
const values = {
|
||||
title,
|
||||
eventDate,
|
||||
location: asNullableString(formData, "location"),
|
||||
// Les formulaires HTML encodent les sauts de ligne en CRLF : on normalise
|
||||
// pour que le texte relu soit celui qui a été écrit.
|
||||
description: asString(formData, "description").replace(/\r\n?/g, "\n") || null,
|
||||
participants: asString(formData, "participants").replace(/\r\n?/g, "\n") || null,
|
||||
meetingId,
|
||||
};
|
||||
|
||||
if (id) {
|
||||
const [updated] = await db
|
||||
.update(pastMeetings)
|
||||
.set(values)
|
||||
.where(eq(pastMeetings.id, id))
|
||||
.returning({ id: pastMeetings.id });
|
||||
if (!updated) return { error: "Cette rencontre passée n'existe plus." };
|
||||
revalidatePastMeetingPaths();
|
||||
return { id: updated.id };
|
||||
}
|
||||
|
||||
const [created] = await db.insert(pastMeetings).values(values).returning({ id: pastMeetings.id });
|
||||
revalidatePastMeetingPaths();
|
||||
return { id: created.id };
|
||||
}
|
||||
|
||||
export async function deletePastMeeting(formData: FormData) {
|
||||
@@ -1030,44 +1060,106 @@ export async function deletePastMeeting(formData: FormData) {
|
||||
const id = Number(formData.get("id"));
|
||||
if (!id) return;
|
||||
await db.delete(pastMeetings).where(eq(pastMeetings.id, id));
|
||||
revalidatePath("/backend/rencontres");
|
||||
revalidatePath("/association");
|
||||
revalidatePath("/backend/rencontres-passees");
|
||||
revalidatePath("/rencontres-passees");
|
||||
revalidatePastMeetingPaths();
|
||||
}
|
||||
|
||||
export async function addPastMeetingPhoto(formData: FormData) {
|
||||
/** Les identifiants reviennent dans l'ordre d'insertion : la fenêtre s'en sert
|
||||
* pour réordonner l'ensemble une fois toutes les photos envoyées. */
|
||||
export type AddedPhotos = { added: number; ids: number[] };
|
||||
|
||||
/**
|
||||
* Attache un lot de photos à une rencontre passée.
|
||||
*
|
||||
* Le navigateur compresse puis découpe l'envoi en paquets tenant sous la limite
|
||||
* des server actions (`chunkByBytes`) : plusieurs appels, donc, et chacun doit
|
||||
* pouvoir échouer seul. Les photos déjà enregistrées le restent.
|
||||
*/
|
||||
export async function addPastMeetingPhotos(formData: FormData): Promise<AddedPhotos | ActionError> {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
|
||||
|
||||
const pastMeetingId = Number(formData.get("pastMeetingId"));
|
||||
const imageUrl = asString(formData, "imageUrl");
|
||||
if (!pastMeetingId || !imageUrl) return;
|
||||
if (!pastMeetingId) return { error: "Rencontre introuvable." };
|
||||
const [archive] = await db.select({ id: pastMeetings.id }).from(pastMeetings).where(eq(pastMeetings.id, pastMeetingId));
|
||||
if (!archive) return { error: "Cette rencontre passée n'existe plus." };
|
||||
|
||||
const images = formData.getAll("imageUrl").map((value) => String(value));
|
||||
const captions = formData.getAll("caption").map((value) => String(value).trim().slice(0, 200));
|
||||
if (images.length === 0) return { added: 0, ids: [] };
|
||||
|
||||
const rows: { pastMeetingId: number; imageUrl: string; caption: string | null; position: number }[] = [];
|
||||
const [{ max } = { max: 0 }] = await db
|
||||
.select({ max: sql<number>`coalesce(max(${pastMeetingPhotos.position}), 0)` })
|
||||
.from(pastMeetingPhotos)
|
||||
.where(eq(pastMeetingPhotos.pastMeetingId, pastMeetingId));
|
||||
await db.insert(pastMeetingPhotos).values({
|
||||
pastMeetingId,
|
||||
imageUrl,
|
||||
caption: asNullableString(formData, "caption"),
|
||||
position: Number(max) + 1,
|
||||
});
|
||||
revalidatePath("/backend/rencontres");
|
||||
revalidatePath("/association");
|
||||
revalidatePath("/backend/rencontres-passees");
|
||||
revalidatePath("/rencontres-passees");
|
||||
|
||||
for (const [index, image] of images.entries()) {
|
||||
const value = image.trim();
|
||||
if (!value) continue;
|
||||
// Une image n'est acceptée qu'en data-URI : une URL ferait charger par le
|
||||
// serveur une adresse choisie par l'utilisateur (SSRF).
|
||||
if (value.length > MAX_IMAGE_DATA_URI) return { error: "Une photo est trop lourde même après compression." };
|
||||
if (!IMAGE_DATA_URI.test(value)) return { error: "Format de photo non accepté." };
|
||||
rows.push({
|
||||
pastMeetingId,
|
||||
imageUrl: value,
|
||||
caption: captions[index] || null,
|
||||
position: Number(max) + index + 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length === 0) return { added: 0, ids: [] };
|
||||
const inserted = await db.insert(pastMeetingPhotos).values(rows).returning({ id: pastMeetingPhotos.id });
|
||||
revalidatePastMeetingPaths();
|
||||
return { added: inserted.length, ids: inserted.map((photo) => photo.id) };
|
||||
}
|
||||
|
||||
export async function deletePastMeetingPhoto(formData: FormData) {
|
||||
export type SyncedPhotos = { removed: number };
|
||||
|
||||
/**
|
||||
* Aligne les photos existantes sur ce que montre la fenêtre : ordre, légendes,
|
||||
* et suppression de celles qui en ont été retirées.
|
||||
*
|
||||
* `syncPhotos` est obligatoire : sans ce drapeau, un appel amputé de sa liste
|
||||
* (requête tronquée, formulaire partiel) effacerait toute la galerie en silence.
|
||||
*/
|
||||
export async function savePastMeetingPhotos(formData: FormData): Promise<SyncedPhotos | ActionError> {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
|
||||
const id = Number(formData.get("id"));
|
||||
if (!id) return;
|
||||
await db.delete(pastMeetingPhotos).where(eq(pastMeetingPhotos.id, id));
|
||||
revalidatePath("/backend/rencontres");
|
||||
revalidatePath("/association");
|
||||
revalidatePath("/backend/rencontres-passees");
|
||||
revalidatePath("/rencontres-passees");
|
||||
if (formData.get("syncPhotos") !== "1") return { error: "Demande incomplète." };
|
||||
|
||||
const pastMeetingId = Number(formData.get("pastMeetingId"));
|
||||
if (!pastMeetingId) return { error: "Rencontre introuvable." };
|
||||
|
||||
const kept = formData
|
||||
.getAll("photoId")
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isInteger(value) && value > 0);
|
||||
const captions = formData.getAll("photoCaption").map((value) => String(value).trim().slice(0, 200));
|
||||
|
||||
const existing = await db
|
||||
.select({ id: pastMeetingPhotos.id })
|
||||
.from(pastMeetingPhotos)
|
||||
.where(eq(pastMeetingPhotos.pastMeetingId, pastMeetingId));
|
||||
const owned = new Set(existing.map((photo) => photo.id));
|
||||
|
||||
let removed = 0;
|
||||
for (const photo of existing) {
|
||||
if (kept.includes(photo.id)) continue;
|
||||
await db.delete(pastMeetingPhotos).where(eq(pastMeetingPhotos.id, photo.id));
|
||||
removed += 1;
|
||||
}
|
||||
|
||||
for (const [index, photoId] of kept.entries()) {
|
||||
if (!owned.has(photoId)) continue;
|
||||
await db
|
||||
.update(pastMeetingPhotos)
|
||||
.set({ caption: captions[index] || null, position: index + 1 })
|
||||
.where(eq(pastMeetingPhotos.id, photoId));
|
||||
}
|
||||
|
||||
revalidatePastMeetingPaths();
|
||||
return { removed };
|
||||
}
|
||||
|
||||
// ---- Paramètres du site ----
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { PromoCategoryGroup } from "@/lib/promo-categories";
|
||||
import { formatValidity, isRangeInvalid } from "@/lib/promo-validity";
|
||||
import { formatSchedule, parseScheduleInput } from "@/lib/promo-schedule";
|
||||
import { imageNotes, imageSummary, type ImageInfo } from "@/lib/image-info";
|
||||
import { FIELD_MAX_BYTES, compressionSummary } from "@/lib/image-compress";
|
||||
import { prepareImageFile } from "@/lib/image-compress-dom";
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
@@ -118,6 +120,7 @@ export function MemberSpaceForm({
|
||||
// c'est ici — dernier moment où l'adhérent peut changer de visuel — qu'on lui
|
||||
// dit ce que les réseaux feront d'un format inattendu.
|
||||
const [imgInfo, setImgInfo] = useState<ImageInfo | null>(null);
|
||||
const [imgNote, setImgNote] = useState<string | null>(null);
|
||||
const [shares, setShares] = useState<SocialNetwork[]>([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
@@ -128,25 +131,30 @@ export function MemberSpaceForm({
|
||||
setSubmitted(false);
|
||||
}
|
||||
|
||||
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
/**
|
||||
* Le navigateur compresse avant l'envoi. Une photo de téléphone dépasse la
|
||||
* limite de 3 Mo du serveur : elle était refusée en pleine publication, sans
|
||||
* que l'adhérent puisse rien y faire depuis son écran. Le cadrage, lui, n'est
|
||||
* pas touché — le rapport largeur/hauteur est conservé, donc les remarques de
|
||||
* `ImageReport` sur ce que feront les réseaux restent vraies.
|
||||
*/
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
const bytes = f.size;
|
||||
setImgInfo(null);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = String(reader.result);
|
||||
setImgData(dataUrl);
|
||||
setImgNote("Compression en cours…");
|
||||
try {
|
||||
const prepared = await prepareImageFile(f, { maxBytes: FIELD_MAX_BYTES });
|
||||
setImgData(prepared.dataUri);
|
||||
setSubmitted(false);
|
||||
// Les dimensions ne sont lisibles qu'une fois l'image décodée par le
|
||||
// navigateur. Un échec de décodage laisse simplement le résumé vide :
|
||||
// l'information est un confort, elle ne conditionne pas le dépôt.
|
||||
const probe = new Image();
|
||||
probe.onload = () => setImgInfo({ width: probe.naturalWidth, height: probe.naturalHeight, bytes });
|
||||
probe.onerror = () => setImgInfo(null);
|
||||
probe.src = dataUrl;
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
// Le résumé décrit l'image telle qu'elle sera publiée, pas le fichier
|
||||
// d'origine : c'est celle-là que verront le site et les réseaux.
|
||||
setImgInfo({ width: prepared.width, height: prepared.height, bytes: prepared.bytes });
|
||||
setImgNote(prepared.recompressed ? compressionSummary(prepared) : null);
|
||||
} catch (cause) {
|
||||
setImgNote(cause instanceof Error ? cause.message : "Image refusée.");
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
@@ -158,6 +166,7 @@ export function MemberSpaceForm({
|
||||
setPublishAt("");
|
||||
setImgData("");
|
||||
setImgInfo(null);
|
||||
setImgNote(null);
|
||||
setShares([]);
|
||||
setSubmitted(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
@@ -245,6 +254,9 @@ export function MemberSpaceForm({
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
|
||||
</label>
|
||||
|
||||
{imgNote && (
|
||||
<div style={{ marginTop: -8, marginBottom: 10, fontSize: 12.5, color: "#6c6150" }}>{imgNote}</div>
|
||||
)}
|
||||
{imgInfo && <ImageReport info={imgInfo} />}
|
||||
|
||||
<label className="field-label">Titre de la promotion</label>
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ModalShell } from "@/components/ModalShell";
|
||||
import { PHOTO_MAX_BYTES, base64Bytes, chunkByBytes, compressionSummary } from "@/lib/image-compress";
|
||||
import { prepareImageFile } from "@/lib/image-compress-dom";
|
||||
import type { ArchiveDraft, DialogPhoto, MeetingOption } from "./draft";
|
||||
import {
|
||||
addPastMeetingPhotos,
|
||||
deletePastMeeting,
|
||||
savePastMeeting,
|
||||
savePastMeetingPhotos,
|
||||
} from "../actions";
|
||||
|
||||
/**
|
||||
* Bouton d'ouverture de la fenêtre d'édition. La fenêtre n'est montée qu'à
|
||||
* l'ouverture : chaque ouverture repart donc de la fiche telle qu'elle est en
|
||||
* base, sans état résiduel de la précédente.
|
||||
*/
|
||||
export function PastMeetingDialogButton({
|
||||
draft,
|
||||
meetings,
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
draft: ArchiveDraft;
|
||||
meetings: MeetingOption[];
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)} style={style}>
|
||||
{children}
|
||||
</button>
|
||||
{open && <PastMeetingDialog draft={draft} meetings={meetings} onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PastMeetingDialog({
|
||||
draft,
|
||||
meetings,
|
||||
onClose,
|
||||
}: {
|
||||
draft: ArchiveDraft;
|
||||
meetings: MeetingOption[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const photosRef = useRef<HTMLDivElement>(null);
|
||||
const [fields, setFields] = useState(draft);
|
||||
const [photos, setPhotos] = useState<DialogPhoto[]>(draft.photos);
|
||||
const [over, setOver] = useState(false);
|
||||
const [working, setWorking] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState<string[]>([]);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [refreshing, startRefresh] = useTransition();
|
||||
|
||||
/**
|
||||
* On ne referme qu'une fois la page rafraîchie. Sans cette attente, rouvrir la
|
||||
* fiche dans la foulée montrerait encore l'état d'avant l'enregistrement — la
|
||||
* rencontre liée revenant à « Aucune », par exemple — et on croirait que rien
|
||||
* n'a été retenu.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (closing && !refreshing) onClose();
|
||||
}, [closing, refreshing, onClose]);
|
||||
|
||||
const linked = meetings.find((meeting) => String(meeting.id) === fields.meetingId);
|
||||
const busy = working !== null || closing;
|
||||
|
||||
function set<K extends keyof ArchiveDraft>(key: K, value: ArchiveDraft[K]) {
|
||||
setFields((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compression **avant** l'envoi : une photo de téléphone pèse 4 à 12 Mo, la
|
||||
* limite d'une server action est à 4 Mo. Le navigateur redimensionne et
|
||||
* réencode, en ne descendant en qualité que le strict nécessaire.
|
||||
*/
|
||||
async function addFiles(list: FileList | null) {
|
||||
if (!list || list.length === 0) return;
|
||||
const files = Array.from(list);
|
||||
setError(null);
|
||||
const added: DialogPhoto[] = [];
|
||||
const messages: string[] = [];
|
||||
for (const [index, file] of files.entries()) {
|
||||
setWorking(`Compression de la photo ${index + 1} sur ${files.length}…`);
|
||||
try {
|
||||
const prepared = await prepareImageFile(file, { maxBytes: PHOTO_MAX_BYTES });
|
||||
added.push({
|
||||
key: `${Date.now()}-${index}-${file.name}`,
|
||||
id: null,
|
||||
imageUrl: prepared.dataUri,
|
||||
caption: "",
|
||||
bytes: prepared.bytes,
|
||||
});
|
||||
messages.push(`${file.name} — ${compressionSummary(prepared)}`);
|
||||
} catch (cause) {
|
||||
messages.push(`${file.name} — ${cause instanceof Error ? cause.message : "photo refusée"}`);
|
||||
}
|
||||
}
|
||||
setWorking(null);
|
||||
setPhotos((current) => [...current, ...added]);
|
||||
setNotes(messages);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
// La galerie est en bas de la fenêtre : sans cela, on dépose des photos
|
||||
// sans jamais voir apparaître ce qu'on vient d'ajouter.
|
||||
photosRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
|
||||
function movePhoto(index: number, step: number) {
|
||||
setPhotos((current) => {
|
||||
const target = index + step;
|
||||
if (target < 0 || target >= current.length) return current;
|
||||
const next = [...current];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setError(null);
|
||||
setWorking("Enregistrement…");
|
||||
try {
|
||||
const form = new FormData();
|
||||
if (fields.id) form.set("id", String(fields.id));
|
||||
form.set("title", fields.title);
|
||||
form.set("eventDate", fields.eventDate);
|
||||
form.set("location", fields.location);
|
||||
form.set("description", fields.description);
|
||||
form.set("participants", fields.participants);
|
||||
form.set("meetingId", fields.meetingId);
|
||||
const saved = await savePastMeeting(form);
|
||||
if ("error" in saved) {
|
||||
setError(saved.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Les nouvelles photos partent en paquets : plusieurs data-URI dans une
|
||||
// seule requête dépasseraient la limite de corps des server actions.
|
||||
const fresh = photos.filter((photo) => photo.id === null);
|
||||
const groups = chunkByBytes(fresh.map((photo) => photo.bytes || base64Bytes(photo.imageUrl)));
|
||||
const freshIds: number[] = [];
|
||||
let sent = 0;
|
||||
for (const group of groups) {
|
||||
setWorking(`Envoi des photos ${sent + 1} à ${sent + group.length} sur ${fresh.length}…`);
|
||||
const batch = new FormData();
|
||||
batch.set("pastMeetingId", String(saved.id));
|
||||
for (const index of group) {
|
||||
batch.append("imageUrl", fresh[index].imageUrl);
|
||||
batch.append("caption", fresh[index].caption);
|
||||
}
|
||||
const result = await addPastMeetingPhotos(batch);
|
||||
if ("error" in result) {
|
||||
setError(`${result.error} Les photos déjà envoyées sont conservées.`);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
freshIds.push(...result.ids);
|
||||
sent += group.length;
|
||||
}
|
||||
|
||||
// Passage final : ordre affiché, légendes, et suppression de ce qui a été
|
||||
// retiré de la fenêtre.
|
||||
setWorking("Mise en ordre de la galerie…");
|
||||
const sync = new FormData();
|
||||
sync.set("syncPhotos", "1");
|
||||
sync.set("pastMeetingId", String(saved.id));
|
||||
let cursor = 0;
|
||||
for (const photo of photos) {
|
||||
const photoId = photo.id ?? freshIds[cursor++];
|
||||
if (!photoId) continue;
|
||||
sync.append("photoId", String(photoId));
|
||||
sync.append("photoCaption", photo.caption);
|
||||
}
|
||||
const synced = await savePastMeetingPhotos(sync);
|
||||
if ("error" in synced) {
|
||||
setError(synced.error);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
setClosing(true);
|
||||
startRefresh(() => router.refresh());
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Enregistrement impossible.");
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!fields.id) return;
|
||||
if (!window.confirm("Supprimer définitivement cette rencontre passée et ses photos ?")) return;
|
||||
setWorking("Suppression…");
|
||||
const form = new FormData();
|
||||
form.set("id", String(fields.id));
|
||||
await deletePastMeeting(form);
|
||||
setWorking(null);
|
||||
setClosing(true);
|
||||
startRefresh(() => router.refresh());
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
label={fields.id ? `Modifier ${fields.title}` : "Nouvelle rencontre passée"}
|
||||
onClose={() => {
|
||||
if (busy) return;
|
||||
onClose();
|
||||
}}
|
||||
panelClassName="pm-panel pm-panel--form"
|
||||
>
|
||||
<div className="pm-form">
|
||||
<div className="pm-form__scroll">
|
||||
<h2 className="font-display" style={{ margin: "0 0 4px", fontSize: 22, color: "#26201a", paddingRight: 44 }}>
|
||||
{fields.id ? "Modifier la rencontre passée" : "Nouvelle rencontre passée"}
|
||||
</h2>
|
||||
<p style={{ color: "#8c8068", fontSize: 13, margin: "0 0 18px" }}>
|
||||
Tout se remplit ici : le récit, les participants et les photos — y compris pour une rencontre qui n'est pas
|
||||
encore publiée.
|
||||
</p>
|
||||
|
||||
{fields.refused.length > 0 && (
|
||||
<div style={{ background: "#fbe9e6", border: "1px solid #f0c4bb", color: "#b53a25", borderRadius: 8, padding: "11px 13px", fontSize: 12.5, lineHeight: 1.5, marginBottom: 14 }}>
|
||||
<strong>Droit à l'image refusé :</strong> {fields.refused.join(", ")}. Vérifiez que ces personnes ne
|
||||
figurent pas sur les photos publiées.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-2" style={{ gap: 14 }}>
|
||||
<div>
|
||||
<label className="field-label">Titre</label>
|
||||
<input className="field" value={fields.title} onChange={(event) => set("title", event.target.value)} maxLength={200} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Date</label>
|
||||
<input type="date" className="field" value={fields.eventDate} onChange={(event) => set("eventDate", event.target.value)} />
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Lieu</label>
|
||||
<input className="field" value={fields.location} onChange={(event) => set("location", event.target.value)} maxLength={240} />
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Rencontre liée (facultatif)</label>
|
||||
<select className="field" value={fields.meetingId} onChange={(event) => set("meetingId", event.target.value)}>
|
||||
<option value="">Aucune — participants saisis à la main</option>
|
||||
{meetings.map((meeting) => (
|
||||
<option key={meeting.id} value={String(meeting.id)}>
|
||||
{meeting.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ fontSize: 12.5, color: "#8c8068", marginTop: 6, lineHeight: 1.5 }}>
|
||||
{linked
|
||||
? `Le site affichera ${linked.registered} participant${linked.registered > 1 ? "s" : ""} d'après les inscriptions ; la liste saisie à la main est alors ignorée.`
|
||||
: "Sans rencontre liée, le nombre affiché est celui des lignes saisies ci-dessous (une personne par ligne)."}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Récit de la rencontre</label>
|
||||
<textarea
|
||||
className="field"
|
||||
rows={12}
|
||||
value={fields.description}
|
||||
onChange={(event) => set("description", event.target.value)}
|
||||
placeholder="Déroulé de la matinée, intervenants, décisions prises, prochaines étapes…"
|
||||
style={{ minHeight: 260, resize: "vertical", lineHeight: 1.7 }}
|
||||
/>
|
||||
<div style={{ fontSize: 12.5, color: "#8c8068", marginTop: 6 }}>
|
||||
Les paragraphes sont conservés tels quels sur le site. {fields.description.length} caractères.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Participants (une personne par ligne)</label>
|
||||
<textarea
|
||||
className="field"
|
||||
rows={4}
|
||||
value={fields.participants}
|
||||
onChange={(event) => set("participants", event.target.value)}
|
||||
style={{ resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={photosRef} style={{ marginTop: 22, borderTop: "1px solid #efe7d6", paddingTop: 18 }}>
|
||||
<label className="field-label">Photos ({photos.length})</label>
|
||||
<div
|
||||
className={over ? "pm-dropzone pm-dropzone--over" : "pm-dropzone"}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setOver(true);
|
||||
}}
|
||||
onDragLeave={() => setOver(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setOver(false);
|
||||
void addFiles(event.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
Glissez plusieurs photos ici, ou choisissez-les sur votre appareil. Elles sont redimensionnées et
|
||||
compressées automatiquement : aucune n'est trop lourde.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
style={{ border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", fontWeight: 700, fontSize: 13, padding: "9px 16px", borderRadius: 9, cursor: busy ? "progress" : "pointer" }}
|
||||
>
|
||||
Choisir des photos
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={(event) => void addFiles(event.target.files)}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{notes.length > 0 && (
|
||||
<ul style={{ margin: "10px 0 0", paddingLeft: 18, color: "#8c8068", fontSize: 12.5, lineHeight: 1.6 }}>
|
||||
{notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{photos.length > 0 && (
|
||||
<div className="pm-photo-grid">
|
||||
{photos.map((photo, index) => (
|
||||
<div key={photo.key} className="pm-photo">
|
||||
<div className="pm-photo__image" style={{ backgroundImage: `url(${photo.imageUrl})` }} />
|
||||
<div className="pm-photo__tools">
|
||||
<button type="button" className="pm-photo__tool" onClick={() => movePhoto(index, -1)} disabled={index === 0} aria-label="Déplacer avant">←</button>
|
||||
<button type="button" className="pm-photo__tool" onClick={() => movePhoto(index, 1)} disabled={index === photos.length - 1} aria-label="Déplacer après">→</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pm-photo__tool pm-photo__tool--danger"
|
||||
onClick={() => setPhotos((current) => current.filter((item) => item.key !== photo.key))}
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="field"
|
||||
style={{ border: "none", borderTop: "1px solid #efe7d6", borderRadius: 0, fontSize: 12.5 }}
|
||||
placeholder="Légende (facultative)"
|
||||
maxLength={200}
|
||||
value={photo.caption}
|
||||
onChange={(event) =>
|
||||
setPhotos((current) =>
|
||||
current.map((item) => (item.key === photo.key ? { ...item, caption: event.target.value } : item)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12.5, color: "#8c8068", marginTop: 10 }}>
|
||||
La première photo sert de couverture à la carte ; les flèches changent l'ordre de la galerie.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pm-form__foot">
|
||||
<div style={{ fontSize: 13, color: error ? "#a3372e" : "#6c6150", fontWeight: error ? 700 : 600, flex: "1 1 240px" }}>
|
||||
{error ?? working ?? (closing ? "Mise à jour de la page…" : fields.id ? "" : "La rencontre sera visible sur le site dès l'enregistrement.")}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
||||
{fields.id && (
|
||||
<button type="button" onClick={remove} disabled={busy} style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 800, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" }}>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onClose} disabled={busy} style={{ border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", fontWeight: 700, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" }}>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="button" onClick={save} disabled={busy} style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 18px", borderRadius: 8, cursor: busy ? "progress" : "pointer", opacity: busy ? 0.7 : 1 }}>
|
||||
{busy ? "Patientez…" : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Forme d'une rencontre passée telle que la manipule la fenêtre d'édition.
|
||||
*
|
||||
* Ce module n'est **pas** marqué `"use client"` à dessein : la page (composant
|
||||
* serveur) appelle `emptyDraft()` pour préparer une fiche vierge. Exporté
|
||||
* depuis un module client, cet appel deviendrait une référence client, que le
|
||||
* serveur ne peut pas exécuter.
|
||||
*/
|
||||
|
||||
export type MeetingOption = { id: number; label: string; registered: number };
|
||||
|
||||
export type DialogPhoto = {
|
||||
/** Clé stable côté navigateur : une photo pas encore enregistrée n'a pas d'id. */
|
||||
key: string;
|
||||
id: number | null;
|
||||
imageUrl: string;
|
||||
caption: string;
|
||||
/** Poids de la data-URI, utilisé pour découper les envois en paquets. */
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
export type ArchiveDraft = {
|
||||
id: number | null;
|
||||
title: string;
|
||||
eventDate: string;
|
||||
location: string;
|
||||
description: string;
|
||||
participants: string;
|
||||
meetingId: string;
|
||||
photos: DialogPhoto[];
|
||||
/** Personnes ayant refusé le droit à l'image sur la rencontre liée. */
|
||||
refused: string[];
|
||||
};
|
||||
|
||||
/** Fiche vierge, datée d'aujourd'hui dans le fuseau du navigateur du serveur. */
|
||||
export function emptyDraft(): ArchiveDraft {
|
||||
const today = new Date();
|
||||
today.setMinutes(today.getMinutes() - today.getTimezoneOffset());
|
||||
return {
|
||||
id: null,
|
||||
title: "",
|
||||
eventDate: today.toISOString().slice(0, 10),
|
||||
location: "",
|
||||
description: "",
|
||||
participants: "",
|
||||
meetingId: "",
|
||||
photos: [],
|
||||
refused: [],
|
||||
};
|
||||
}
|
||||
@@ -4,15 +4,10 @@ import { asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||
import { ImageField } from "@/components/ImageField";
|
||||
import { can } from "@/lib/rbac";
|
||||
import {
|
||||
addPastMeetingPhoto,
|
||||
createPastMeeting,
|
||||
deletePastMeeting,
|
||||
deletePastMeetingPhoto,
|
||||
updatePastMeeting,
|
||||
} from "../actions";
|
||||
import { splitLines } from "@/lib/site-settings";
|
||||
import { PastMeetingDialogButton } from "./PastMeetingDialog";
|
||||
import { emptyDraft, type ArchiveDraft, type MeetingOption } from "./draft";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -22,6 +17,10 @@ function dateValue(date: Date) {
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
|
||||
}
|
||||
|
||||
export default async function PastMeetingsAdminPage() {
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||
@@ -52,121 +51,110 @@ export default async function PastMeetingsAdminPage() {
|
||||
const refusalsByMeeting = new Map<number, string[]>();
|
||||
for (const refusal of refusals) refusalsByMeeting.set(refusal.meetingId, [...(refusalsByMeeting.get(refusal.meetingId) ?? []), refusal.attendeeName]);
|
||||
|
||||
const meetingOptions: MeetingOption[] = meetingRows.map((meeting) => ({
|
||||
id: meeting.id,
|
||||
registered: Number(meeting.registered),
|
||||
label: `${meeting.title} — ${formatDate(meeting.startsAt)} (${Number(meeting.registered)} inscrit${Number(meeting.registered) > 1 ? "s" : ""})`,
|
||||
}));
|
||||
|
||||
const drafts: ArchiveDraft[] = archives.map((archive) => ({
|
||||
id: archive.id,
|
||||
title: archive.title,
|
||||
eventDate: dateValue(archive.eventDate),
|
||||
location: archive.location ?? "",
|
||||
description: archive.description ?? "",
|
||||
participants: archive.participants ?? "",
|
||||
meetingId: archive.meetingId ? String(archive.meetingId) : "",
|
||||
// `bytes` ne sert qu'aux photos à envoyer : celles déjà en base ne repartent pas.
|
||||
photos: (photosByPast.get(archive.id) ?? []).map((photo) => ({
|
||||
key: `photo-${photo.id}`,
|
||||
id: photo.id,
|
||||
imageUrl: photo.imageUrl,
|
||||
caption: photo.caption ?? "",
|
||||
bytes: 0,
|
||||
})),
|
||||
refused: archive.meetingId ? refusalsByMeeting.get(archive.meetingId) ?? [] : [],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 28 }}>
|
||||
<div style={{ display: "grid", gap: 24 }}>
|
||||
<section style={panelStyle}>
|
||||
<h2 className="font-display" style={{ margin: "0 0 6px", fontSize: 20, color: "#26201a" }}>Nouvelle rencontre passée</h2>
|
||||
<p style={{ color: "#8c8068", fontSize: 13, margin: "0 0 18px" }}>Les trois archives les plus récentes apparaissent sur « L'association » et la page complète rassemble toutes les rencontres passées.</p>
|
||||
<form action={createPastMeeting} className="grid grid-2" style={{ gap: 16 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<label className="field-label">Titre</label>
|
||||
<input name="title" className="field" required />
|
||||
<h2 className="font-display" style={{ margin: "0 0 6px", fontSize: 20, color: "#26201a" }}>Rencontres passées</h2>
|
||||
<p style={{ color: "#8c8068", fontSize: 13, margin: 0, maxWidth: 620, lineHeight: 1.6 }}>
|
||||
Une fenêtre par rencontre : récit, participants et photos au même endroit. Les trois plus récentes
|
||||
apparaissent sur « L'association », toutes sur la page des rencontres passées.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Date</label>
|
||||
<input name="eventDate" type="date" className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Lieu</label>
|
||||
<input name="location" className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Rencontre liée</label>
|
||||
<select name="meetingId" className="field" defaultValue="">
|
||||
<option value="">Aucune</option>
|
||||
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)} participants)</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Description</label>
|
||||
<textarea name="description" rows={3} className="field" />
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Participants manuels (un par ligne, uniquement sans rencontre liée)</label>
|
||||
<textarea name="participants" rows={3} className="field" />
|
||||
</div>
|
||||
<button type="submit" style={primaryButton}>Publier l'archive</button>
|
||||
</form>
|
||||
<PastMeetingDialogButton draft={emptyDraft()} meetings={meetingOptions} style={primaryButton}>
|
||||
Nouvelle rencontre passée
|
||||
</PastMeetingDialogButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div style={sectionTitle}>Archives publiées</div>
|
||||
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||
{archives.map((archive) => {
|
||||
const refused = archive.meetingId ? refusalsByMeeting.get(archive.meetingId) ?? [] : [];
|
||||
return (
|
||||
<article key={archive.id} style={cardStyle}>
|
||||
{refused.length > 0 && (
|
||||
<div style={{ background: "#fbe9e6", border: "1px solid #f0c4bb", color: "#b53a25", borderRadius: 8, padding: "11px 13px", fontSize: 12.5, lineHeight: 1.5, marginBottom: 13 }}>
|
||||
<strong>Droit à l'image refusé :</strong> {refused.join(", ")}. Vérifiez que ces personnes ne figurent pas sur les photos publiées.
|
||||
</div>
|
||||
)}
|
||||
<form action={updatePastMeeting} style={{ display: "grid", gap: 12 }}>
|
||||
<input type="hidden" name="id" value={archive.id} />
|
||||
<div>
|
||||
<label className="field-label">Titre</label>
|
||||
<input name="title" defaultValue={archive.title} className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Date</label>
|
||||
<input name="eventDate" type="date" defaultValue={dateValue(archive.eventDate)} className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Lieu</label>
|
||||
<input name="location" defaultValue={archive.location ?? ""} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Rencontre liée</label>
|
||||
<select name="meetingId" className="field" defaultValue={archive.meetingId ?? ""}>
|
||||
<option value="">Aucune</option>
|
||||
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Description</label>
|
||||
<textarea name="description" rows={3} defaultValue={archive.description ?? ""} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Participants manuels</label>
|
||||
<textarea name="participants" rows={3} defaultValue={archive.participants ?? ""} className="field" />
|
||||
</div>
|
||||
<button type="submit" style={primaryButton}>Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: 14, borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
|
||||
<form action={addPastMeetingPhoto} style={{ display: "grid", gap: 10 }}>
|
||||
<input type="hidden" name="pastMeetingId" value={archive.id} />
|
||||
<ImageField name="imageUrl" label="Ajouter une photo" height={100} />
|
||||
<input name="caption" className="field" placeholder="Légende" />
|
||||
<button type="submit" style={primaryButton}>Ajouter la photo</button>
|
||||
</form>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginTop: 12 }}>
|
||||
{(photosByPast.get(archive.id) ?? []).map((photo) => (
|
||||
<form key={photo.id} action={deletePastMeetingPhoto}>
|
||||
<input type="hidden" name="id" value={photo.id} />
|
||||
<button type="submit" title="Supprimer" aria-label={`Supprimer la photo ${photo.caption ?? ""}`} style={{ width: "100%", aspectRatio: "1 / 1", border: "1px solid #e6dcc6", borderRadius: 8, cursor: "pointer", background: `center/cover no-repeat url(${photo.imageUrl})` }} />
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={deletePastMeeting} style={{ marginTop: 12 }}>
|
||||
<input type="hidden" name="id" value={archive.id} />
|
||||
<button type="submit" style={dangerButton}>Supprimer l'archive</button>
|
||||
</form>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{archives.length === 0 && <div style={emptyStyle}>Aucune rencontre passée publiée.</div>}
|
||||
{archives.length === 0 ? (
|
||||
<div style={emptyStyle}>Aucune rencontre passée publiée.</div>
|
||||
) : (
|
||||
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||
{archives.map((archive, index) => {
|
||||
const draft = drafts[index];
|
||||
const archivePhotos = photosByPast.get(archive.id) ?? [];
|
||||
const participantCount = archive.meetingId
|
||||
? meetingOptions.find((option) => option.id === archive.meetingId)?.registered ?? 0
|
||||
: splitLines(archive.participants ?? "").length;
|
||||
const cover = archivePhotos[0]?.imageUrl;
|
||||
return (
|
||||
<PastMeetingDialogButton key={archive.id} draft={draft} meetings={meetingOptions} style={cardButton}>
|
||||
<span
|
||||
className={cover ? "pm-card__cover" : "pm-card__cover pm-card__cover--empty"}
|
||||
style={{ display: "block", borderRadius: "10px 10px 0 0", ...(cover ? { backgroundImage: `url(${cover})` } : {}) }}
|
||||
>
|
||||
{archivePhotos.length > 0 && (
|
||||
<span className="pm-card__count">{archivePhotos.length} photo{archivePhotos.length > 1 ? "s" : ""}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="pm-card__body" style={{ display: "flex" }}>
|
||||
<span style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(archive.eventDate)}</span>
|
||||
<span className="font-display pm-card__title" style={{ display: "-webkit-box" }}>{archive.title}</span>
|
||||
{archive.location && <span className="pm-line" style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{archive.location}</span>}
|
||||
{archive.description && <span className="pm-card__text" style={{ display: "-webkit-box" }}>{archive.description}</span>}
|
||||
<span className="pm-card__meta">
|
||||
<span>{participantCount} participant{participantCount > 1 ? "s" : ""}</span>
|
||||
<span className="pm-card__more">Modifier →</span>
|
||||
</span>
|
||||
</span>
|
||||
{draft.refused.length > 0 && (
|
||||
<span style={{ display: "block", background: "#fbe9e6", color: "#b53a25", fontSize: 12, fontWeight: 700, padding: "8px 14px" }}>
|
||||
Droit à l'image refusé par {draft.refused.length} participant{draft.refused.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</PastMeetingDialogButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panelStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 22 };
|
||||
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 16 };
|
||||
const sectionTitle: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
|
||||
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 16px", borderRadius: 8, cursor: "pointer" };
|
||||
const dangerButton: CSSProperties = { width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 800, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" };
|
||||
const cardButton: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: 424,
|
||||
textAlign: "left",
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
};
|
||||
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, color: "#8c8068" };
|
||||
@@ -1805,3 +1805,306 @@ img {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ---- Rencontres passées : cartes égales et fenêtre de lecture ----
|
||||
Les cartes ont une hauteur fixe quels que soient la longueur du texte et le
|
||||
nombre de photos : c'est la grille qui doit rester lisible, le détail vit
|
||||
dans la fenêtre. D'où les `line-clamp` — un CSS que des styles en ligne ne
|
||||
peuvent pas porter, comme les points de rupture ci-dessous. */
|
||||
.pm-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 424px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1px solid #e6dcc6;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.pm-card:hover,
|
||||
.pm-card:focus-visible {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 14px 30px rgba(38, 32, 26, 0.13);
|
||||
}
|
||||
.pm-card__cover {
|
||||
position: relative;
|
||||
height: 178px;
|
||||
flex: none;
|
||||
background: #ece4d3 center/cover no-repeat;
|
||||
}
|
||||
.pm-card__cover--empty {
|
||||
background: repeating-linear-gradient(135deg, #efe7d6, #efe7d6 12px, #e7ddc8 12px, #e7ddc8 24px);
|
||||
}
|
||||
.pm-card__count {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
background: rgba(19, 50, 79, 0.82);
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 5px 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.pm-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 15px 17px 16px;
|
||||
}
|
||||
.pm-card__title {
|
||||
margin: 6px 0 4px;
|
||||
font-size: 21px;
|
||||
line-height: 1.2;
|
||||
color: #26201a;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pm-card__text {
|
||||
margin: 6px 0 0;
|
||||
color: #8c8068;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pm-card__meta {
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: #6c6150;
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.pm-card__more {
|
||||
color: #13324f;
|
||||
}
|
||||
.pm-line {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Fenêtre (site public et backoffice) ---- */
|
||||
.pm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
background: rgba(19, 50, 79, 0.74);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
.pm-panel {
|
||||
width: min(1080px, 100%);
|
||||
max-height: min(92vh, 940px);
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.85fr);
|
||||
box-shadow: 0 30px 70px rgba(15, 25, 40, 0.38);
|
||||
}
|
||||
.pm-panel--form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
width: min(920px, 100%);
|
||||
}
|
||||
.pm-panel__media {
|
||||
background: #101f2e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.pm-panel__stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px;
|
||||
}
|
||||
.pm-panel__stage img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.pm-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #13324f;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.pm-arrow:hover {
|
||||
background: #fff;
|
||||
}
|
||||
.pm-arrow--prev {
|
||||
left: 12px;
|
||||
}
|
||||
.pm-arrow--next {
|
||||
right: 12px;
|
||||
}
|
||||
.pm-thumbs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.pm-thumb {
|
||||
flex: none;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background: #24384a center/cover no-repeat;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0.62;
|
||||
}
|
||||
.pm-thumb--active {
|
||||
border-color: #e0a63c;
|
||||
opacity: 1;
|
||||
}
|
||||
.pm-panel__info {
|
||||
padding: 26px 26px 28px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.pm-panel__close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 18px;
|
||||
z-index: 2;
|
||||
border: none;
|
||||
background: #fff;
|
||||
color: #13324f;
|
||||
border-radius: 999px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 16px rgba(15, 25, 40, 0.24);
|
||||
}
|
||||
.pm-body-text {
|
||||
color: #5f5646;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.75;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* ---- Fenêtre d'édition du backoffice ---- */
|
||||
.pm-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(92vh, 940px);
|
||||
}
|
||||
.pm-form__scroll {
|
||||
overflow: auto;
|
||||
padding: 22px 24px 8px;
|
||||
}
|
||||
.pm-form__foot {
|
||||
border-top: 1px solid #efe7d6;
|
||||
padding: 14px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: #fdfbf6;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pm-dropzone {
|
||||
border: 1px dashed #d6c9ad;
|
||||
border-radius: 12px;
|
||||
background: #fdfbf6;
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
color: #8c8068;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pm-dropzone--over {
|
||||
border-color: #13324f;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.pm-photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.pm-photo {
|
||||
border: 1px solid #e6dcc6;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.pm-photo__image {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: #f1ebdd center/cover no-repeat;
|
||||
}
|
||||
.pm-photo__tools {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
}
|
||||
.pm-photo__tool {
|
||||
border: 1px solid #e6dcc6;
|
||||
background: #fff;
|
||||
color: #6f6450;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pm-photo__tool--danger {
|
||||
color: #d8472b;
|
||||
border-color: #e9cfc7;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.pm-panel {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-height: 94vh;
|
||||
}
|
||||
.pm-panel__stage {
|
||||
min-height: 220px;
|
||||
}
|
||||
.pm-backdrop {
|
||||
padding: 12px;
|
||||
}
|
||||
.pm-panel__info {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { asc, desc, inArray } from "drizzle-orm";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
||||
import { PastMeetingCard } from "@/components/PastMeetingCard";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { db } from "@/db";
|
||||
@@ -79,16 +79,18 @@ export default async function PastMeetingsPage() {
|
||||
? registrationCounts.get(archive.meetingId) ?? 0
|
||||
: splitLines(archive.participants ?? "").length;
|
||||
return (
|
||||
<article key={archive.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 18 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(archive.eventDate)}</div>
|
||||
<h2 className="font-display" style={{ color: "#26201a", fontSize: 23, margin: "7px 0" }}>{archive.title}</h2>
|
||||
{archive.location && <div style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{archive.location}</div>}
|
||||
{archive.description && <p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.65 }}>{archive.description}</p>}
|
||||
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
|
||||
{participantCount} participant{participantCount > 1 ? "s" : ""} · {archivePhotos.length} photo{archivePhotos.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
<PastMeetingGallery title={archive.title} photos={archivePhotos} />
|
||||
</article>
|
||||
<PastMeetingCard
|
||||
key={archive.id}
|
||||
meeting={{
|
||||
id: archive.id,
|
||||
title: archive.title,
|
||||
dateLabel: formatDate(archive.eventDate),
|
||||
location: archive.location,
|
||||
description: archive.description,
|
||||
participantCount,
|
||||
photos: archivePhotos.map((photo) => ({ id: photo.id, imageUrl: photo.imageUrl, caption: photo.caption })),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { FIELD_MAX_BYTES, compressionSummary } from "@/lib/image-compress";
|
||||
import { prepareImageFile } from "@/lib/image-compress-dom";
|
||||
|
||||
export function ImageField({
|
||||
name,
|
||||
@@ -8,35 +10,48 @@ export function ImageField({
|
||||
defaultValue = "",
|
||||
height = 130,
|
||||
fit = "cover",
|
||||
maxBytes = FIELD_MAX_BYTES,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
defaultValue?: string;
|
||||
height?: number;
|
||||
fit?: "cover" | "contain";
|
||||
maxBytes?: number;
|
||||
}) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
// Limite raisonnable : les data-URL transitent par une server action (~1 Mo max).
|
||||
if (f.size > 900_000) {
|
||||
setError("Image trop lourde (max ~900 Ko). Compressez-la d'abord.");
|
||||
setNote(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
// Plus de refus « image trop lourde » : le navigateur redimensionne et
|
||||
// compresse lui-même, en ne descendant que le nécessaire.
|
||||
const prepared = await prepareImageFile(file, { maxBytes });
|
||||
startTransition(() => {
|
||||
setValue(prepared.dataUri);
|
||||
setNote(compressionSummary(prepared));
|
||||
});
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Image refusée.");
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
return;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setValue(String(reader.result));
|
||||
reader.readAsDataURL(f);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setValue("");
|
||||
setError(null);
|
||||
setNote(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
|
||||
@@ -62,18 +77,19 @@ export function ImageField({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={value} alt="" style={{ maxWidth: "86%", maxHeight: "86%", objectFit: "contain", display: "block" }} />
|
||||
)}
|
||||
{!value && <span style={{ fontSize: 13, color: "#a99c82" }}>Aucune image</span>}
|
||||
{!value && <span style={{ fontSize: 13, color: "#a99c82" }}>{busy ? "Compression en cours…" : "Aucune image"}</span>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
style={{ border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", fontWeight: 600, fontSize: 13, padding: "8px 14px", borderRadius: 9, cursor: "pointer" }}
|
||||
disabled={busy}
|
||||
style={{ border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", fontWeight: 600, fontSize: 13, padding: "8px 14px", borderRadius: 9, cursor: busy ? "progress" : "pointer" }}
|
||||
>
|
||||
{value ? "Changer l'image" : "Choisir une image"}
|
||||
{busy ? "Compression…" : value ? "Changer l'image" : "Choisir une image"}
|
||||
</button>
|
||||
{value && (
|
||||
{value && !busy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
@@ -85,6 +101,7 @@ export function ImageField({
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
|
||||
</div>
|
||||
|
||||
{note && !error && <div style={{ fontSize: 12.5, color: "#8c8068", marginTop: 6 }}>{note}</div>}
|
||||
{error && <div style={{ fontSize: 12.5, color: "#a3372e", marginTop: 6 }}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/**
|
||||
* Enveloppe commune aux fenêtres du site et du backoffice : fond assombri,
|
||||
* fermeture au clic extérieur, à la touche Échap et par le bouton de fermeture,
|
||||
* page figée derrière.
|
||||
*
|
||||
* Le blocage du défilement de l'arrière-plan compte plus qu'il n'en a l'air :
|
||||
* sans lui, la molette dans une galerie plein écran fait défiler la page
|
||||
* *derrière* la fenêtre, et on la retrouve ailleurs en refermant.
|
||||
*
|
||||
* Le rendu passe par un **portail** vers `<body>`, et ce n'est pas un détail :
|
||||
* une carte survolée porte un `transform`, qui redéfinit le bloc conteneur des
|
||||
* éléments `position: fixed` qu'elle contient. Rendue sur place, la fenêtre
|
||||
* s'ouvrirait donc *dans* la carte. Le portail la sort aussi du bouton qui
|
||||
* l'ouvre, où des champs de saisie n'auraient rien à faire.
|
||||
*/
|
||||
export function ModalShell({
|
||||
label,
|
||||
onClose,
|
||||
panelClassName = "pm-panel",
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClose: () => void;
|
||||
panelClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
panelRef.current?.focus();
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="pm-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={panelClassName}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
tabIndex={-1}
|
||||
style={{ position: "relative", outline: "none" }}
|
||||
>
|
||||
<button type="button" className="pm-panel__close" onClick={onClose} aria-label="Fermer">
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ModalShell } from "./ModalShell";
|
||||
|
||||
export type PastMeetingPhotoView = { id: number; imageUrl: string; caption: string | null };
|
||||
|
||||
export type PastMeetingView = {
|
||||
id: number;
|
||||
title: string;
|
||||
/** Date déjà mise en forme côté serveur : le composant ne refait pas de locale. */
|
||||
dateLabel: string;
|
||||
location: string | null;
|
||||
description: string | null;
|
||||
participantCount: number;
|
||||
photos: PastMeetingPhotoView[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Carte d'une rencontre passée, de hauteur fixe, et sa fenêtre de lecture.
|
||||
*
|
||||
* La carte ne montre qu'un aperçu — couverture, titre sur deux lignes, trois
|
||||
* lignes de texte, compteurs : une rencontre richement racontée et une autre en
|
||||
* deux phrases occupent ainsi la même place dans la grille. Le récit complet et
|
||||
* la totalité des photos sont dans la fenêtre, où la place ne manque plus.
|
||||
*/
|
||||
export function PastMeetingCard({ meeting }: { meeting: PastMeetingView }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const cover = meeting.photos[0]?.imageUrl;
|
||||
|
||||
return (
|
||||
<article className="pm-card" style={{ position: "relative" }}>
|
||||
<div
|
||||
className={cover ? "pm-card__cover" : "pm-card__cover pm-card__cover--empty"}
|
||||
style={cover ? { backgroundImage: `url(${cover})` } : undefined}
|
||||
>
|
||||
{meeting.photos.length > 0 && (
|
||||
<span className="pm-card__count">
|
||||
{meeting.photos.length} photo{meeting.photos.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pm-card__body">
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{meeting.dateLabel}</div>
|
||||
<h3 className="font-display pm-card__title">{meeting.title}</h3>
|
||||
{meeting.location && (
|
||||
<div className="pm-line" style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{meeting.location}</div>
|
||||
)}
|
||||
{meeting.description && <p className="pm-card__text">{meeting.description}</p>}
|
||||
<div className="pm-card__meta">
|
||||
<span>
|
||||
{meeting.participantCount} participant{meeting.participantCount > 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="pm-card__more">Voir la rencontre →</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toute la carte est cliquable, mais un seul élément interactif : le
|
||||
titre reste un vrai titre, et la tabulation ne traverse pas la carte. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Ouvrir la rencontre : ${meeting.title}`}
|
||||
style={{ position: "absolute", inset: 0, border: "none", background: "none", cursor: "pointer", borderRadius: 12 }}
|
||||
/>
|
||||
|
||||
{open && <PastMeetingModal meeting={meeting} onClose={() => setOpen(false)} />}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function PastMeetingModal({ meeting, onClose }: { meeting: PastMeetingView; onClose: () => void }) {
|
||||
const photos = meeting.photos;
|
||||
const [index, setIndex] = useState(0);
|
||||
const current = photos[index];
|
||||
|
||||
const move = useCallback(
|
||||
(step: number) => {
|
||||
if (photos.length === 0) return;
|
||||
setIndex((value) => (value + step + photos.length) % photos.length);
|
||||
},
|
||||
[photos.length],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowRight") move(1);
|
||||
if (event.key === "ArrowLeft") move(-1);
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [move]);
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
label={`Rencontre : ${meeting.title}`}
|
||||
onClose={onClose}
|
||||
panelClassName={photos.length > 0 ? "pm-panel" : "pm-panel pm-panel--form"}
|
||||
>
|
||||
{photos.length > 0 && (
|
||||
<div className="pm-panel__media">
|
||||
<div className="pm-panel__stage">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={current.imageUrl} alt={current.caption ?? `${meeting.title} — photo ${index + 1}`} />
|
||||
{photos.length > 1 && (
|
||||
<>
|
||||
<button type="button" className="pm-arrow pm-arrow--prev" onClick={() => move(-1)} aria-label="Photo précédente">‹</button>
|
||||
<button type="button" className="pm-arrow pm-arrow--next" onClick={() => move(1)} aria-label="Photo suivante">›</button>
|
||||
</>
|
||||
)}
|
||||
<span style={{ position: "absolute", left: 16, bottom: 16, background: "rgba(16,31,46,0.78)", color: "#fff", borderRadius: 999, padding: "4px 11px", fontSize: 12, fontWeight: 800 }}>
|
||||
{index + 1} / {photos.length}
|
||||
</span>
|
||||
</div>
|
||||
{current.caption && (
|
||||
<div style={{ color: "#cfe0ee", fontSize: 13, padding: "0 16px", lineHeight: 1.5 }}>{current.caption}</div>
|
||||
)}
|
||||
{photos.length > 1 && (
|
||||
<div className="pm-thumbs">
|
||||
{photos.map((photo, position) => (
|
||||
<button
|
||||
key={photo.id}
|
||||
type="button"
|
||||
className={position === index ? "pm-thumb pm-thumb--active" : "pm-thumb"}
|
||||
style={{ backgroundImage: `url(${photo.imageUrl})` }}
|
||||
onClick={() => setIndex(position)}
|
||||
aria-label={`Photo ${position + 1}`}
|
||||
aria-current={position === index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pm-panel__info">
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800, letterSpacing: "0.04em", textTransform: "uppercase" }}>{meeting.dateLabel}</div>
|
||||
<h2 className="font-display" style={{ margin: "8px 0 10px", color: "#26201a", fontSize: 30, lineHeight: 1.15, paddingRight: 34 }}>{meeting.title}</h2>
|
||||
{meeting.location && <div style={{ color: "#6c6150", fontSize: 14, fontWeight: 700, marginBottom: 6 }}>{meeting.location}</div>}
|
||||
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 16 }}>
|
||||
{meeting.participantCount} participant{meeting.participantCount > 1 ? "s" : ""}
|
||||
{photos.length > 0 && ` · ${photos.length} photo${photos.length > 1 ? "s" : ""}`}
|
||||
</div>
|
||||
{meeting.description && <p className="pm-body-text">{meeting.description}</p>}
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function PastMeetingGallery({
|
||||
title,
|
||||
photos,
|
||||
}: {
|
||||
title: string;
|
||||
photos: { id: number; imageUrl: string; caption: string | null }[];
|
||||
}) {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
if (photos.length === 0) return null;
|
||||
const current = open == null ? null : photos.find((photo) => photo.id === open);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginTop: 14 }}>
|
||||
{photos.slice(0, 4).map((photo, index) => (
|
||||
<button
|
||||
key={photo.id}
|
||||
type="button"
|
||||
onClick={() => setOpen(photo.id)}
|
||||
aria-label={`Ouvrir photo ${index + 1}`}
|
||||
style={{
|
||||
position: "relative",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
aspectRatio: "1 / 1",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
background: `center/cover no-repeat url(${photo.imageUrl})`,
|
||||
}}
|
||||
>
|
||||
{index === 3 && photos.length > 4 && (
|
||||
<span style={{ position: "absolute", inset: 0, background: "rgba(19,50,79,0.62)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800 }}>
|
||||
+{photos.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{current && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Galerie ${title}`}
|
||||
onClick={() => setOpen(null)}
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(19,50,79,0.72)", zIndex: 80, padding: 24, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<div onClick={(event) => event.stopPropagation()} style={{ maxWidth: 980, width: "100%" }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={current.imageUrl} alt={current.caption ?? title} style={{ width: "100%", maxHeight: "78vh", objectFit: "contain", background: "#fff", borderRadius: 12 }} />
|
||||
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center" }}>
|
||||
<div style={{ color: "#fff", fontWeight: 700 }}>{current.caption ?? title}</div>
|
||||
<button type="button" onClick={() => setOpen(null)} style={{ border: "none", background: "#fff", color: "#13324F", borderRadius: 999, padding: "9px 16px", fontWeight: 800, cursor: "pointer" }}>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Compression d'une image **dans le navigateur**, avant qu'elle ne parte vers
|
||||
* une server action. Pendant du module pur `image-compress.ts`, qui porte les
|
||||
* seuils et les paliers ; ici vivent le décodage, le canvas et l'encodage,
|
||||
* c'est-à-dire tout ce qui a besoin d'un DOM.
|
||||
*
|
||||
* Trois choix méritent d'être expliqués :
|
||||
*
|
||||
* - **L'orientation EXIF est appliquée au décodage** (`createImageBitmap` avec
|
||||
* `imageOrientation: "from-image"`). Une photo prise à l'horizontale porte
|
||||
* souvent sa rotation dans ses métadonnées ; dessinée telle quelle sur un
|
||||
* canvas, elle repartirait couchée, l'information perdue.
|
||||
* - **Une image avec transparence ne devient jamais un JPEG** : le logo d'un
|
||||
* adhérent se retrouverait sur fond noir. On passe alors par WebP, et par
|
||||
* PNG si le navigateur ne sait pas encoder le WebP.
|
||||
* - **Une image déjà dans le budget n'est pas touchée** : un réencodage JPEG
|
||||
* dégrade toujours un peu, même à qualité élevée.
|
||||
*/
|
||||
|
||||
import {
|
||||
FIELD_MAX_BYTES,
|
||||
MAX_SIDE,
|
||||
base64Bytes,
|
||||
compressionSteps,
|
||||
needsCompression,
|
||||
scaleToFit,
|
||||
} from "./image-compress";
|
||||
|
||||
export type PreparedImage = {
|
||||
/** Image prête à partir, toujours en data-URI (jamais une URL : cf. SSRF). */
|
||||
dataUri: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
originalBytes: number;
|
||||
recompressed: boolean;
|
||||
/** Nom du fichier d'origine, repris comme légende par défaut. */
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type PrepareOptions = {
|
||||
maxBytes?: number;
|
||||
maxSide?: number;
|
||||
};
|
||||
|
||||
/** Formats que le serveur accepte tels quels (`asImageDataUri`). */
|
||||
const PASSTHROUGH = /^image\/(png|jpeg|jpg|webp|gif)$/i;
|
||||
|
||||
function readDataUri(file: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error("Fichier illisible."));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
type Decoded = { source: CanvasImageSource; width: number; height: number; release: () => void };
|
||||
|
||||
async function decode(file: File): Promise<Decoded> {
|
||||
if (typeof createImageBitmap === "function") {
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
|
||||
return { source: bitmap, width: bitmap.width, height: bitmap.height, release: () => bitmap.close() };
|
||||
} catch {
|
||||
// Navigateur sans `imageOrientation` (ou format non décodable) : on tente
|
||||
// la voie classique avant d'abandonner.
|
||||
}
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const element = new Image();
|
||||
element.onload = () => resolve(element);
|
||||
element.onerror = () => reject(new Error("decode"));
|
||||
element.src = url;
|
||||
});
|
||||
return {
|
||||
source: image,
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
release: () => URL.revokeObjectURL(url),
|
||||
};
|
||||
} catch {
|
||||
URL.revokeObjectURL(url);
|
||||
throw new Error(
|
||||
"Ce format d'image n'est pas lisible par le navigateur (HEIC d'iPhone, par exemple). " +
|
||||
"Exportez la photo en JPEG avant de la déposer.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Une image sans transparence peut partir en JPEG, la plus économe des trois. */
|
||||
function hasAlpha(context: CanvasRenderingContext2D, width: number, height: number): boolean {
|
||||
try {
|
||||
const { data } = context.getImageData(0, 0, width, height);
|
||||
// Un pixel sur quatre suffit à repérer une transparence : un fond
|
||||
// transparent n'est jamais un pixel isolé.
|
||||
for (let index = 3; index < data.length; index += 16) {
|
||||
if (data[index] < 255) return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
// Canvas « sali » (impossible ici : tout vient d'un fichier local) : dans le
|
||||
// doute on garde la transparence.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function draw(decoded: Decoded, size: { width: number; height: number }): HTMLCanvasElement {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size.width;
|
||||
canvas.height = size.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Le navigateur n'a pas pu préparer l'image.");
|
||||
context.drawImage(decoded.source, 0, 0, size.width, size.height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function encode(canvas: HTMLCanvasElement, type: string, quality: number): string | null {
|
||||
const dataUri = canvas.toDataURL(type, quality);
|
||||
// Un type non géré fait retomber `toDataURL` sur le PNG sans prévenir.
|
||||
return dataUri.startsWith(`data:${type}`) ? dataUri : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare un fichier déposé : orientation redressée, définition ramenée à
|
||||
* `maxSide`, poids ramené sous `maxBytes` en descendant les paliers de qualité
|
||||
* seulement autant qu'il le faut.
|
||||
*
|
||||
* Lève une erreur porteuse d'un message affichable (fichier non image, format
|
||||
* non décodable). Ne lève jamais pour cause de poids : c'est précisément ce que
|
||||
* la fonction est là pour régler.
|
||||
*/
|
||||
export async function prepareImageFile(file: File, options: PrepareOptions = {}): Promise<PreparedImage> {
|
||||
const maxBytes = options.maxBytes ?? FIELD_MAX_BYTES;
|
||||
const maxSide = options.maxSide ?? MAX_SIDE;
|
||||
if (file.type && !file.type.startsWith("image/")) throw new Error("Ce fichier n'est pas une image.");
|
||||
|
||||
const decoded = await decode(file);
|
||||
try {
|
||||
const original = { bytes: file.size, width: decoded.width, height: decoded.height };
|
||||
|
||||
if (PASSTHROUGH.test(file.type) && !needsCompression(original, { maxBytes, maxSide })) {
|
||||
const dataUri = await readDataUri(file);
|
||||
return { ...original, dataUri, originalBytes: file.size, recompressed: false, name: file.name };
|
||||
}
|
||||
|
||||
// Premier tirage à définition pleine : il sert aussi à repérer une
|
||||
// transparence, donc à choisir le format de sortie.
|
||||
const full = scaleToFit(decoded.width, decoded.height, maxSide);
|
||||
const probe = draw(decoded, full);
|
||||
const context = probe.getContext("2d");
|
||||
const transparent = context ? hasAlpha(context, probe.width, probe.height) : false;
|
||||
const types = transparent ? ["image/webp", "image/png"] : ["image/jpeg"];
|
||||
|
||||
let best: { dataUri: string; bytes: number; size: { width: number; height: number } } | null = null;
|
||||
for (const step of compressionSteps(maxSide)) {
|
||||
const size = scaleToFit(decoded.width, decoded.height, step.maxSide);
|
||||
const canvas = size.width === probe.width && size.height === probe.height ? probe : draw(decoded, size);
|
||||
for (const type of types) {
|
||||
const dataUri = encode(canvas, type, step.quality);
|
||||
if (!dataUri) continue;
|
||||
const bytes = base64Bytes(dataUri);
|
||||
if (!best || bytes < best.bytes) best = { dataUri, bytes, size };
|
||||
if (bytes <= maxBytes) {
|
||||
return {
|
||||
dataUri,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
bytes,
|
||||
originalBytes: file.size,
|
||||
recompressed: true,
|
||||
name: file.name,
|
||||
};
|
||||
}
|
||||
// Le PNG ignore la qualité : inutile de redescendre l'échelle sur lui,
|
||||
// seule une définition plus petite le fera maigrir.
|
||||
if (type === "image/png") break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) throw new Error("Le navigateur n'a pas pu compresser cette image.");
|
||||
return {
|
||||
dataUri: best.dataUri,
|
||||
width: best.size.width,
|
||||
height: best.size.height,
|
||||
bytes: best.bytes,
|
||||
originalBytes: file.size,
|
||||
recompressed: true,
|
||||
name: file.name,
|
||||
};
|
||||
} finally {
|
||||
decoded.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Plan de compression des images déposées dans le backoffice.
|
||||
*
|
||||
* Module **pur** (aucun accès DOM, base ou réseau), verrouillé par
|
||||
* `tests/image-compress.test.ts`. Le travail sur le canvas — donc le seul code
|
||||
* qui a besoin d'un navigateur — vit dans `image-compress-dom.ts`.
|
||||
*
|
||||
* Pourquoi compresser côté navigateur : une image voyage jusqu'au serveur en
|
||||
* data-URI, à travers une server action plafonnée à 4 Mo, et finit stockée en
|
||||
* base. Une photo de téléphone (4 à 12 Mo) ne passe pas. Le réflexe précédent
|
||||
* était de la refuser — « Image trop lourde, compressez-la d'abord » — ce qui
|
||||
* revenait à demander à l'utilisateur de faire à la main ce que le navigateur
|
||||
* sait faire tout seul.
|
||||
*
|
||||
* La règle : on ne descend en qualité que le strict nécessaire. Une image déjà
|
||||
* dans le budget n'est **pas** réencodée (un réencodage JPEG n'est jamais
|
||||
* neutre), et le premier essai garde la pleine résolution à qualité quasi
|
||||
* maximale. Ce n'est qu'en cas d'échec qu'on baisse, d'abord la qualité, puis
|
||||
* la définition.
|
||||
*/
|
||||
|
||||
export type Size = { width: number; height: number };
|
||||
|
||||
/**
|
||||
* Côté le plus long conservé. 2048 px couvre le plein écran d'un portable
|
||||
* récent (et le grand format des réseaux) ; au-delà, l'œil ne voit rien de
|
||||
* plus, seul le poids grimpe.
|
||||
*/
|
||||
export const MAX_SIDE = 2048;
|
||||
|
||||
/** Budget d'une image de formulaire : couverture, logo, visuel de promotion. */
|
||||
export const FIELD_MAX_BYTES = 1_200_000;
|
||||
|
||||
/**
|
||||
* Budget d'une photo de galerie. Plus serré : plusieurs photos partent dans la
|
||||
* même requête, et une rencontre en compte facilement vingt.
|
||||
*/
|
||||
export const PHOTO_MAX_BYTES = 800_000;
|
||||
|
||||
/**
|
||||
* Taille d'un envoi groupé de photos. La limite des server actions est à 4 Mo
|
||||
* de corps de requête, et le base64 gonfle les octets d'un tiers : 2 Mo de
|
||||
* photos font ~2,7 Mo sur le fil, marge comprise.
|
||||
*/
|
||||
export const BATCH_MAX_BYTES = 2_000_000;
|
||||
|
||||
/** Plancher de qualité : en dessous, le grain se voit sur une photo de groupe. */
|
||||
export const MIN_QUALITY = 0.6;
|
||||
|
||||
const QUALITY_LADDER = [0.92, 0.84, 0.76, 0.68, MIN_QUALITY];
|
||||
|
||||
/** Définitions successives, en fraction du côté maximal demandé. */
|
||||
const SIDE_LADDER = [1, 0.78, 0.6, 0.45];
|
||||
|
||||
function positive(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimensions ramenées dans un carré de `maxSide`, rapport conservé. Une image
|
||||
* plus petite est renvoyée telle quelle : on ne l'agrandit jamais, cela
|
||||
* n'ajouterait que du flou et du poids.
|
||||
*/
|
||||
export function scaleToFit(width: number, height: number, maxSide: number = MAX_SIDE): Size {
|
||||
if (!positive(width) || !positive(height) || !positive(maxSide)) return { width: 0, height: 0 };
|
||||
const longest = Math.max(width, height);
|
||||
if (longest <= maxSide) return { width: Math.round(width), height: Math.round(height) };
|
||||
const ratio = maxSide / longest;
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * ratio)),
|
||||
height: Math.max(1, Math.round(height * ratio)),
|
||||
};
|
||||
}
|
||||
|
||||
export type CompressionStep = { maxSide: number; quality: number };
|
||||
|
||||
/**
|
||||
* Essais successifs, du meilleur au plus économe. On épuise la qualité à
|
||||
* définition pleine avant de réduire la définition : une photo de groupe reste
|
||||
* lisible bien plus longtemps en baissant la qualité qu'en perdant des pixels.
|
||||
*/
|
||||
export function compressionSteps(maxSide: number = MAX_SIDE): CompressionStep[] {
|
||||
if (!positive(maxSide)) return [];
|
||||
const steps: CompressionStep[] = [];
|
||||
SIDE_LADDER.forEach((fraction, index) => {
|
||||
const side = Math.max(320, Math.round(maxSide * fraction));
|
||||
// Le premier palier essaie toute l'échelle de qualité ; les suivants
|
||||
// repartent plus bas, la définition ayant déjà fait une partie du travail.
|
||||
for (const quality of QUALITY_LADDER.slice(index)) steps.push({ maxSide: side, quality });
|
||||
});
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** Poids réel d'une data-URI base64, sans la décoder. */
|
||||
export function base64Bytes(dataUri: string): number {
|
||||
const marker = dataUri.indexOf("base64,");
|
||||
if (marker < 0) return 0;
|
||||
const payload = dataUri.slice(marker + 7).replace(/\s+/g, "");
|
||||
if (payload.length === 0) return 0;
|
||||
const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
|
||||
return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);
|
||||
}
|
||||
|
||||
/** `true` pour une data-URI d'image, la seule forme acceptée par le serveur. */
|
||||
export function isImageDataUri(value: string): boolean {
|
||||
return /^data:image\/[a-z+.-]+;base64,/i.test(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Faut-il réencoder ? Non si l'image tient déjà dans le budget **et** dans la
|
||||
* définition : le fichier d'origine est alors la meilleure version possible.
|
||||
*/
|
||||
export function needsCompression(
|
||||
info: { bytes: number; width: number; height: number },
|
||||
options: { maxBytes: number; maxSide?: number } = { maxBytes: FIELD_MAX_BYTES },
|
||||
): boolean {
|
||||
const maxSide = options.maxSide ?? MAX_SIDE;
|
||||
if (!positive(info.width) || !positive(info.height)) return false;
|
||||
if (Math.max(info.width, info.height) > maxSide) return true;
|
||||
return info.bytes > options.maxBytes;
|
||||
}
|
||||
|
||||
function bytesLabel(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
||||
if (bytes < 1000) return `${Math.round(bytes)} o`;
|
||||
if (bytes < 1_000_000) return `${Math.round(bytes / 1000)} Ko`;
|
||||
const mo = bytes / 1_000_000;
|
||||
return `${(mo < 10 ? mo.toFixed(1) : String(Math.round(mo))).replace(".", ",")} Mo`;
|
||||
}
|
||||
|
||||
export type CompressionResult = {
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
originalBytes: number;
|
||||
/** `false` quand le fichier d'origine a été gardé tel quel. */
|
||||
recompressed: boolean;
|
||||
};
|
||||
|
||||
/** Phrase affichée sous l'aperçu : ce que le navigateur a fait, en clair. */
|
||||
export function compressionSummary(result: CompressionResult): string {
|
||||
const size = `${Math.round(result.width)} × ${Math.round(result.height)} px`;
|
||||
if (!result.recompressed) return `${size} · ${bytesLabel(result.bytes)} · qualité d'origine conservée`;
|
||||
return `${size} · ${bytesLabel(result.originalBytes)} → ${bytesLabel(result.bytes)} · compressée automatiquement`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groupe des envois consécutifs pour tenir sous la limite d'une requête. Un
|
||||
* élément plus gros que le budget part seul plutôt que d'être écarté : c'est au
|
||||
* serveur de le refuser, pas à la file de l'oublier en silence.
|
||||
*/
|
||||
export function chunkByBytes(sizes: number[], maxBytes: number = BATCH_MAX_BYTES): number[][] {
|
||||
const groups: number[][] = [];
|
||||
let current: number[] = [];
|
||||
let total = 0;
|
||||
sizes.forEach((size, index) => {
|
||||
const weight = Math.max(0, size);
|
||||
if (current.length > 0 && total + weight > maxBytes) {
|
||||
groups.push(current);
|
||||
current = [];
|
||||
total = 0;
|
||||
}
|
||||
current.push(index);
|
||||
total += weight;
|
||||
});
|
||||
if (current.length > 0) groups.push(current);
|
||||
return groups;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
BATCH_MAX_BYTES,
|
||||
MAX_SIDE,
|
||||
MIN_QUALITY,
|
||||
base64Bytes,
|
||||
chunkByBytes,
|
||||
compressionSteps,
|
||||
compressionSummary,
|
||||
isImageDataUri,
|
||||
needsCompression,
|
||||
scaleToFit,
|
||||
} from "../src/lib/image-compress";
|
||||
|
||||
describe("Mise à l'échelle", () => {
|
||||
it("ramène le côté le plus long sous la limite en gardant le rapport", () => {
|
||||
assert.deepEqual(scaleToFit(4032, 3024, 2048), { width: 2048, height: 1536 });
|
||||
assert.deepEqual(scaleToFit(3024, 4032, 2048), { width: 1536, height: 2048 });
|
||||
});
|
||||
|
||||
it("n'agrandit jamais une image plus petite que la limite", () => {
|
||||
assert.deepEqual(scaleToFit(800, 600, 2048), { width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it("ne rend rien pour des dimensions absurdes", () => {
|
||||
assert.deepEqual(scaleToFit(0, 600, 2048), { width: 0, height: 0 });
|
||||
assert.deepEqual(scaleToFit(Number.NaN, 600, 2048), { width: 0, height: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Paliers de compression", () => {
|
||||
const steps = compressionSteps(MAX_SIDE);
|
||||
|
||||
it("commence à la définition pleine et à la meilleure qualité", () => {
|
||||
assert.equal(steps[0].maxSide, MAX_SIDE);
|
||||
assert.ok(steps[0].quality >= 0.9);
|
||||
});
|
||||
|
||||
it("épuise la qualité avant de réduire la définition", () => {
|
||||
const firstDrop = steps.findIndex((step) => step.maxSide < MAX_SIDE);
|
||||
assert.ok(firstDrop > 1, "la définition ne doit pas tomber au deuxième essai");
|
||||
for (let index = 1; index < firstDrop; index += 1) {
|
||||
assert.ok(steps[index].quality < steps[index - 1].quality);
|
||||
}
|
||||
});
|
||||
|
||||
it("ne remonte jamais en définition et reste au-dessus du plancher de qualité", () => {
|
||||
for (let index = 1; index < steps.length; index += 1) {
|
||||
assert.ok(steps[index].maxSide <= steps[index - 1].maxSide);
|
||||
}
|
||||
for (const step of steps) {
|
||||
assert.ok(step.quality >= MIN_QUALITY && step.quality <= 1);
|
||||
assert.ok(step.maxSide >= 320);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Poids d'une data-URI", () => {
|
||||
it("retrouve le poids décodé sans décoder", () => {
|
||||
const bytes = Buffer.from("x".repeat(1000));
|
||||
const dataUri = `data:image/jpeg;base64,${bytes.toString("base64")}`;
|
||||
assert.equal(base64Bytes(dataUri), 1000);
|
||||
});
|
||||
|
||||
it("tient compte du remplissage", () => {
|
||||
for (const length of [1, 2, 3, 4, 5]) {
|
||||
const raw = Buffer.alloc(length, 7);
|
||||
assert.equal(base64Bytes(`data:image/png;base64,${raw.toString("base64")}`), length);
|
||||
}
|
||||
});
|
||||
|
||||
it("rend zéro hors data-URI", () => {
|
||||
assert.equal(base64Bytes("https://exemple.fr/photo.jpg"), 0);
|
||||
assert.equal(base64Bytes(""), 0);
|
||||
});
|
||||
|
||||
it("ne reconnaît qu'une image en data-URI", () => {
|
||||
assert.ok(isImageDataUri("data:image/jpeg;base64,AAAA"));
|
||||
assert.ok(!isImageDataUri("https://exemple.fr/photo.jpg"));
|
||||
assert.ok(!isImageDataUri("data:text/html;base64,AAAA"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Décision de recompression", () => {
|
||||
it("garde le fichier d'origine quand il tient dans le budget et la définition", () => {
|
||||
assert.ok(!needsCompression({ bytes: 400_000, width: 1600, height: 1200 }, { maxBytes: 800_000 }));
|
||||
});
|
||||
|
||||
it("recompresse une image trop lourde", () => {
|
||||
assert.ok(needsCompression({ bytes: 4_000_000, width: 1600, height: 1200 }, { maxBytes: 800_000 }));
|
||||
});
|
||||
|
||||
it("recompresse une image légère mais démesurée", () => {
|
||||
assert.ok(needsCompression({ bytes: 200_000, width: 6000, height: 4000 }, { maxBytes: 800_000 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Résumé montré à l'utilisateur", () => {
|
||||
it("dit ce qui a été gagné", () => {
|
||||
assert.equal(
|
||||
compressionSummary({ width: 2048, height: 1536, bytes: 620_000, originalBytes: 4_200_000, recompressed: true }),
|
||||
"2048 × 1536 px · 4,2 Mo → 620 Ko · compressée automatiquement",
|
||||
);
|
||||
});
|
||||
|
||||
it("dit quand rien n'a été touché", () => {
|
||||
assert.equal(
|
||||
compressionSummary({ width: 1200, height: 900, bytes: 340_000, originalBytes: 340_000, recompressed: false }),
|
||||
"1200 × 900 px · 340 Ko · qualité d'origine conservée",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Envoi groupé", () => {
|
||||
it("regroupe les photos sous la limite d'une requête", () => {
|
||||
assert.deepEqual(chunkByBytes([700_000, 700_000, 700_000, 700_000], 2_000_000), [[0, 1], [2, 3]]);
|
||||
});
|
||||
|
||||
it("laisse partir seule une photo plus lourde que le budget", () => {
|
||||
assert.deepEqual(chunkByBytes([100_000, 3_000_000, 100_000], 2_000_000), [[0], [1], [2]]);
|
||||
});
|
||||
|
||||
it("rend un groupe vide pour une liste vide et couvre chaque index une fois", () => {
|
||||
assert.deepEqual(chunkByBytes([], BATCH_MAX_BYTES), []);
|
||||
const sizes = [500_000, 900_000, 200_000, 1_900_000, 50_000];
|
||||
const flat = chunkByBytes(sizes).flat();
|
||||
assert.deepEqual(flat, [0, 1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* Invariants de l'écran « Rencontres passées ». Ils portent sur du code que les
|
||||
* tests unitaires ne peuvent pas exécuter (composants client, actions serveur),
|
||||
* d'où la lecture des sources — même approche que `tests/security.test.ts`.
|
||||
*/
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
|
||||
const actions = read("../src/app/backend/actions.ts");
|
||||
const dialog = read("../src/app/backend/rencontres-passees/PastMeetingDialog.tsx");
|
||||
const page = read("../src/app/backend/rencontres-passees/page.tsx");
|
||||
const imageField = read("../src/components/ImageField.tsx");
|
||||
const modal = read("../src/components/ModalShell.tsx");
|
||||
|
||||
test("aucune image n'est refusée pour son poids : elle est compressée", () => {
|
||||
const code = imageField.replace(/\/\/[^\n]*/g, "");
|
||||
assert.ok(!/\.size\s*>/.test(code), "ImageField refuse encore un fichier sur son poids");
|
||||
assert.ok(!/trop lourde/i.test(code), "ImageField renvoie encore « image trop lourde »");
|
||||
assert.ok(imageField.includes("prepareImageFile"), "ImageField ne compresse pas");
|
||||
assert.ok(dialog.includes("prepareImageFile"), "la fenêtre des rencontres ne compresse pas");
|
||||
});
|
||||
|
||||
test("les photos n'entrent en base qu'en data-URI", () => {
|
||||
const body = actions.slice(actions.indexOf("export async function addPastMeetingPhotos"));
|
||||
const scope = body.slice(0, body.indexOf("export async function savePastMeetingPhotos"));
|
||||
assert.ok(scope.includes("IMAGE_DATA_URI.test"), "une photo pourrait entrer sous forme d'URL (SSRF)");
|
||||
assert.ok(scope.includes("MAX_IMAGE_DATA_URI"), "aucune limite de taille côté serveur");
|
||||
});
|
||||
|
||||
test("la synchronisation des photos ne peut pas vider une galerie par accident", () => {
|
||||
const body = actions.slice(actions.indexOf("export async function savePastMeetingPhotos"));
|
||||
assert.ok(body.includes('formData.get("syncPhotos") !== "1"'), "le drapeau de garde a disparu");
|
||||
});
|
||||
|
||||
test("les refus d'enregistrement sont renvoyés, jamais levés", () => {
|
||||
const body = actions.slice(
|
||||
actions.indexOf("export async function savePastMeeting("),
|
||||
actions.indexOf("export async function deletePastMeeting("),
|
||||
);
|
||||
assert.ok(body.includes('return { error: "Donnez un titre'), "un titre vide ne rend plus d'erreur affichable");
|
||||
// Le seul `throw` admis est la violation d'accès, qui n'a pas à s'expliquer.
|
||||
const throws = [...body.matchAll(/throw new Error\("([^"]+)"\)/g)].map((match) => match[1]);
|
||||
assert.deepEqual(throws, ["Accès refusé"], `throw inattendu : ${throws.join(", ")}`);
|
||||
});
|
||||
|
||||
test("la rencontre liée est vérifiée avant l'écriture", () => {
|
||||
const body = actions.slice(actions.indexOf("export async function savePastMeeting("));
|
||||
assert.ok(body.includes("n'existe plus"), "une rencontre supprimée casserait l'enregistrement sans explication");
|
||||
});
|
||||
|
||||
test("la fenêtre ne se referme qu'une fois la page rafraîchie", () => {
|
||||
// Sans cette attente, rouvrir la fiche montrerait l'état d'avant
|
||||
// l'enregistrement : c'est ce qui fait croire qu'une rencontre liée « ne
|
||||
// veut pas » être retenue.
|
||||
assert.ok(dialog.includes("closing && !refreshing"), "la fermeture ne suit plus le rafraîchissement");
|
||||
});
|
||||
|
||||
test("la fenêtre est rendue dans un portail", () => {
|
||||
// Une carte survolée porte un `transform` : un `position: fixed` rendu à
|
||||
// l'intérieur s'ouvrirait dans la carte, pas sur la page.
|
||||
assert.ok(modal.includes("createPortal"), "la fenêtre n'est plus portée vers <body>");
|
||||
});
|
||||
|
||||
test("le récit s'écrit dans un grand champ, et la page ne le ré-affiche pas en entier", () => {
|
||||
assert.ok(/rows=\{1[0-9]\}/.test(dialog), "le champ de récit est redevenu minuscule");
|
||||
assert.ok(dialog.includes("minHeight: 260"), "la hauteur minimale du récit a disparu");
|
||||
assert.ok(page.includes("pm-card__text"), "la carte n'écrête plus le texte");
|
||||
});
|
||||
Reference in new issue
Block a user