mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-21 22:28:04 +02:00
Rencontres passées : une fenêtre, plusieurs photos, compression automatique
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013GHFd8bXepDKhQ7kf3WvrL
This commit is contained in:
17 files changed
+1940
-291
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user