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)."} +
+
+
+ +