mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-23 07:08:05 +02:00
Backend: edition image d'entete + logo des adherents
- Champs upload image d'entete (coverUrl) et logo (logoUrl) dans le formulaire d'edition d'un adherent (composant ImageField, fichier -> data-URL via hidden input) - updateMember persiste desormais coverUrl + logoUrl, et revalide la fiche publique /adherents/[id] - serverActions.bodySizeLimit a 4mb (les data-URL depassent la limite par defaut de 1mb) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
6ee7e44c1b
commit
b4d94f2115
4 files changed
+98
No files matched your search
@@ -2,6 +2,10 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
experimental: {
|
||||||
|
// Les images d'entête/logo sont envoyées en data-URL via server action.
|
||||||
|
serverActions: { bodySizeLimit: "4mb" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
@@ -141,6 +141,8 @@ export async function updateMember(formData: FormData) {
|
|||||||
phone: String(formData.get("phone") ?? "").trim() || null,
|
phone: String(formData.get("phone") ?? "").trim() || null,
|
||||||
website: String(formData.get("website") ?? "").trim() || null,
|
website: String(formData.get("website") ?? "").trim() || null,
|
||||||
memberSince: formData.get("memberSince") ? Number(formData.get("memberSince")) : null,
|
memberSince: formData.get("memberSince") ? Number(formData.get("memberSince")) : null,
|
||||||
|
coverUrl: String(formData.get("coverUrl") ?? "").trim() || null,
|
||||||
|
logoUrl: String(formData.get("logoUrl") ?? "").trim() || null,
|
||||||
tags: String(formData.get("tags") ?? "").trim() || null,
|
tags: String(formData.get("tags") ?? "").trim() || null,
|
||||||
hours: String(formData.get("hours") ?? "").trim() || null,
|
hours: String(formData.get("hours") ?? "").trim() || null,
|
||||||
status: String(formData.get("status") ?? "pending") as "active" | "pending",
|
status: String(formData.get("status") ?? "pending") as "active" | "pending",
|
||||||
@@ -149,6 +151,7 @@ export async function updateMember(formData: FormData) {
|
|||||||
.where(eq(members.id, id));
|
.where(eq(members.id, id));
|
||||||
|
|
||||||
revalidatePath("/backend/adherents");
|
revalidatePath("/backend/adherents");
|
||||||
|
revalidatePath(`/adherents/${id}`);
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { auth } from "@/auth";
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { categories, members } from "@/db/schema";
|
import { categories, members } from "@/db/schema";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
|
import { ImageField } from "@/components/ImageField";
|
||||||
import { deleteMember, updateMember } from "../../actions";
|
import { deleteMember, updateMember } from "../../actions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -110,6 +111,11 @@ export default async function EditMemberPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
|
||||||
|
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={member.coverUrl ?? ""} height={130} />
|
||||||
|
<ImageField name="logoUrl" label="Logo" defaultValue={member.logoUrl ?? ""} height={130} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
|
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
|
||||||
<textarea name="description" rows={4} defaultValue={member.description ?? ""} className="field" style={{ resize: "vertical" }} />
|
<textarea name="description" rows={4} defaultValue={member.description ?? ""} className="field" style={{ resize: "vertical" }} />
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function ImageField({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
defaultValue = "",
|
||||||
|
height = 130,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
height?: number;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState(defaultValue);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (!f) 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.");
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => setValue(String(reader.result));
|
||||||
|
reader.readAsDataURL(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
setValue("");
|
||||||
|
setError(null);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="field-label">{label}</label>
|
||||||
|
<input type="hidden" name={name} value={value} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px dashed #d6c9ad",
|
||||||
|
background: value ? `center/cover no-repeat url(${value})` : "#f6efdc",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!value && <span style={{ fontSize: 13, color: "#a99c82" }}>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" }}
|
||||||
|
>
|
||||||
|
{value ? "Changer l'image" : "Choisir une image"}
|
||||||
|
</button>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clear}
|
||||||
|
style={{ border: "none", background: "none", color: "#d8472b", fontWeight: 600, fontSize: 13, cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
Retirer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={{ fontSize: 12.5, color: "#a3372e", marginTop: 6 }}>{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user