From 9d31935641ed9878764a3ba70e5ce4206a929366 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:48:35 +0000 Subject: [PATCH] =?UTF-8?q?Rencontres=20pass=C3=A9es=20:=20une=20fen=C3=AA?= =?UTF-8?q?tre,=20plusieurs=20photos,=20compression=20automatique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le backoffice n'avait qu'une carte étroite par archive : un récit détaillé se tapait dans trois lignes, les photos ne pouvaient être ajoutées qu'après publication, une par une, et un fichier de plus de 900 Ko était simplement refusé — « compressez-la d'abord ». - **Fenêtre d'édition** : titre, date, lieu, rencontre liée, récit (champ de 260 px, redimensionnable), participants et photos au même endroit, création comprise. Glisser-déposer multiple, réordonnancement, légendes, suppression. - **Compression dans le navigateur** : `src/lib/image-compress.ts` (pur, testé) porte seuils et paliers, `image-compress-dom.ts` le décodage (orientation EXIF comprise) et l'encodage. On ne descend en qualité que le nécessaire, une image déjà dans le budget n'est pas réencodée et la transparence n'est jamais aplatie sur du noir. `ImageField` et le dépôt de promotion en profitent aussi : plus aucun envoi refusé pour son poids. - **Actions** : `savePastMeeting` (création + mise à jour, refus renvoyés au lieu d'être levés, rencontre liée vérifiée), `addPastMeetingPhotos` (envois groupés sous la limite des server actions) et `savePastMeetingPhotos` (ordre, légendes, suppressions, protégée par un drapeau `syncPhotos`). - **La fenêtre attend le rafraîchissement avant de se refermer** : sans cela, rouvrir la fiche affichait l'état d'avant, la rencontre liée revenant à « Aucune » alors qu'elle avait bien été enregistrée. - **Site public** : cartes de hauteur fixe quel que soit le texte ou le nombre de photos, et fenêtre de lecture avec le récit complet (sauts de ligne conservés) et la galerie entière — flèches, vignettes, clavier. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013GHFd8bXepDKhQ7kf3WvrL --- CLAUDE.md | 72 +++- src/app/association/page.tsx | 29 +- src/app/backend/actions.ts | 206 ++++++--- src/app/backend/espace/MemberSpaceForm.tsx | 42 +- .../rencontres-passees/PastMeetingDialog.tsx | 395 ++++++++++++++++++ src/app/backend/rencontres-passees/draft.ts | 50 +++ src/app/backend/rencontres-passees/page.tsx | 206 +++++---- src/app/globals.css | 303 ++++++++++++++ src/app/rencontres-passees/page.tsx | 24 +- src/components/ImageField.tsx | 47 ++- src/components/ModalShell.tsx | 75 ++++ src/components/PastMeetingCard.tsx | 148 +++++++ src/components/PastMeetingGallery.tsx | 67 --- src/lib/image-compress-dom.ts | 196 +++++++++ src/lib/image-compress.ts | 168 ++++++++ tests/image-compress.test.ts | 131 ++++++ tests/past-meetings.test.ts | 72 ++++ 17 files changed, 1940 insertions(+), 291 deletions(-) create mode 100644 src/app/backend/rencontres-passees/PastMeetingDialog.tsx create mode 100644 src/app/backend/rencontres-passees/draft.ts create mode 100644 src/components/ModalShell.tsx create mode 100644 src/components/PastMeetingCard.tsx delete mode 100644 src/components/PastMeetingGallery.tsx create mode 100644 src/lib/image-compress-dom.ts create mode 100644 src/lib/image-compress.ts create mode 100644 tests/image-compress.test.ts create mode 100644 tests/past-meetings.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6887550..bb0a2f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` : 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 diff --git a/src/app/association/page.tsx b/src/app/association/page.tsx index 8c9d2fd..bdd29a7 100644 --- a/src/app/association/page.tsx +++ b/src/app/association/page.tsx @@ -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 ( -
-
{formatDate(meeting.eventDate)}
-

{meeting.title}

- {meeting.location &&
{meeting.location}
} - {meeting.description &&

{meeting.description}

} -
- {participantCount} participant{participantCount > 1 ? "s" : ""} · {photos.length} photo{photos.length > 1 ? "s" : ""} -
- -
+ ({ id: photo.id, imageUrl: photo.imageUrl, caption: photo.caption })), + }} + /> ); })} diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index 7424ffc..ffc846a 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -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 { 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 { 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`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 { 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 ---- diff --git a/src/app/backend/espace/MemberSpaceForm.tsx b/src/app/backend/espace/MemberSpaceForm.tsx index 5b2cd9c..79fc0b2 100644 --- a/src/app/backend/espace/MemberSpaceForm.tsx +++ b/src/app/backend/espace/MemberSpaceForm.tsx @@ -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(null); + const [imgNote, setImgNote] = useState(null); const [shares, setShares] = useState([]); const [submitted, setSubmitted] = useState(false); const [pending, setPending] = useState(false); @@ -128,25 +131,30 @@ export function MemberSpaceForm({ setSubmitted(false); } - function onFile(e: React.ChangeEvent) { + /** + * 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) { 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({ + {imgNote && ( +
{imgNote}
+ )} {imgInfo && } diff --git a/src/app/backend/rencontres-passees/PastMeetingDialog.tsx b/src/app/backend/rencontres-passees/PastMeetingDialog.tsx new file mode 100644 index 0000000..9536715 --- /dev/null +++ b/src/app/backend/rencontres-passees/PastMeetingDialog.tsx @@ -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 ( + <> + + {open && setOpen(false)} />} + + ); +} + +function PastMeetingDialog({ + draft, + meetings, + onClose, +}: { + draft: ArchiveDraft; + meetings: MeetingOption[]; + onClose: () => void; +}) { + const router = useRouter(); + const fileRef = useRef(null); + const photosRef = useRef(null); + const [fields, setFields] = useState(draft); + const [photos, setPhotos] = useState(draft.photos); + const [over, setOver] = useState(false); + const [working, setWorking] = useState(null); + const [error, setError] = useState(null); + const [notes, setNotes] = useState([]); + 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(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 ( + { + if (busy) return; + onClose(); + }} + panelClassName="pm-panel pm-panel--form" + > +
+
+

+ {fields.id ? "Modifier la rencontre passée" : "Nouvelle rencontre passée"} +

+

+ Tout se remplit ici : le récit, les participants et les photos — y compris pour une rencontre qui n'est pas + encore publiée. +

+ + {fields.refused.length > 0 && ( +
+ Droit à l'image refusé : {fields.refused.join(", ")}. Vérifiez que ces personnes ne + figurent pas sur les photos publiées. +
+ )} + +
+
+ + set("title", event.target.value)} maxLength={200} /> +
+
+ + set("eventDate", event.target.value)} /> +
+
+ + set("location", event.target.value)} maxLength={240} /> +
+
+ + +
+ {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)."} +
+
+
+ +