"use client"; import { DragEvent, PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState, } from "react"; import { deleteProjectPhoto, getProjectDefaultAvatars, setProjectDefaultAvatar, uploadProjectPhoto, } from "@/lib/projects-client"; import { getApiUrl } from "@/lib/auth"; import type { ProjectSummary } from "@/types/projects"; import styles from "@/app/profile/page.module.css"; /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ const PASTEL_COLORS = [ "#F9D5E5", "#D5EAF7", "#D5F5E3", "#FFF3CD", "#E8D5F5", "#FFE0CC", ]; const CIRCLE_SIZE = 200; const MIN_SCALE = 0.5; const MAX_SCALE = 3; /* ------------------------------------------------------------------ */ /* Props */ /* ------------------------------------------------------------------ */ type Props = { project: ProjectSummary; onSuccess: (updated: ProjectSummary) => void; onClose: () => void; }; /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ export function ProjectPhotoModal({ project, onSuccess, onClose }: Props) { const apiUrl = getApiUrl(); const [modalTab, setModalTab] = useState<"upload" | "defaults">("upload"); const [modalMessage, setModalMessage] = useState(""); const [loading, setLoading] = useState(false); /* upload / crop */ const [file, setFile] = useState(null); const [imageSrc, setImageSrc] = useState(null); const [imgNaturalSize, setImgNaturalSize] = useState<{ w: number; h: number } | null>(null); const [baseScale, setBaseScale] = useState(1); const [crop, setCrop] = useState({ x: 0, y: 0, scale: 1 }); const [dragging, setDragging] = useState(false); const [bgColor, setBgColor] = useState(project.projectBgColor ?? PASTEL_COLORS[0]); const [showCustomPicker, setShowCustomPicker] = useState(false); const fileInputRef = useRef(null); const cropPreviewRef = useRef(null); const canvasRef = useRef(null); const dragStartRef = useRef<{ px: number; py: number; cx: number; cy: number } | null>(null); /* default avatars */ const [defaultAvatars, setDefaultAvatars] = useState([]); const [selectedDefault, setSelectedDefault] = useState(null); useEffect(() => { getProjectDefaultAvatars(project.id) .then((data) => { if (Array.isArray(data)) setDefaultAvatars(data); }) .catch(() => {}); }, [project.id]); /* ---------------------------------------------------------------- */ /* File picking */ /* ---------------------------------------------------------------- */ const pickFile = (picked: File) => { setFile(picked); const src = URL.createObjectURL(picked); setImageSrc(src); setCrop({ x: 0, y: 0, scale: 1 }); setImgNaturalSize(null); setBaseScale(1); setSelectedDefault(null); setModalTab("upload"); }; const onDragOver = (e: DragEvent) => { e.preventDefault(); setDragging(true); }; const onDragLeave = () => setDragging(false); const onDrop = (e: DragEvent) => { e.preventDefault(); setDragging(false); const dropped = e.dataTransfer.files[0]; if (dropped) pickFile(dropped); }; const onFileInputChange = (e: React.ChangeEvent) => { const picked = e.target.files?.[0]; if (picked) pickFile(picked); }; /* ---------------------------------------------------------------- */ /* Crop drag */ /* ---------------------------------------------------------------- */ const onPointerDown = (e: ReactPointerEvent) => { if (!imageSrc) return; e.preventDefault(); (e.target as HTMLElement).setPointerCapture(e.pointerId); dragStartRef.current = { px: e.clientX, py: e.clientY, cx: crop.x, cy: crop.y }; }; const onPointerMove = (e: ReactPointerEvent) => { if (!dragStartRef.current) return; const { px, py, cx, cy } = dragStartRef.current; setCrop((prev) => ({ ...prev, x: cx + (e.clientX - px), y: cy + (e.clientY - py) })); }; const onPointerUp = () => { dragStartRef.current = null; }; const onWheel = (e: React.WheelEvent) => { e.stopPropagation(); setCrop((prev) => { const delta = e.deltaY > 0 ? -0.05 : 0.05; return { ...prev, scale: Math.min(MAX_SCALE, Math.max(MIN_SCALE, prev.scale + delta)) }; }); }; /* ---------------------------------------------------------------- */ /* Render cropped image to blob */ /* ---------------------------------------------------------------- */ const renderCroppedBlob = useCallback((): Promise => { return new Promise((resolve) => { if (!imageSrc || !imgNaturalSize) { resolve(null); return; } const img = new window.Image(); img.onload = () => { const canvas = canvasRef.current; if (!canvas) { resolve(null); return; } const size = 400; canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d"); if (!ctx) { resolve(null); return; } ctx.fillStyle = bgColor; ctx.fillRect(0, 0, size, size); ctx.beginPath(); ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); ctx.clip(); ctx.fillStyle = bgColor; ctx.fillRect(0, 0, size, size); const ratio = size / CIRCLE_SIZE; const scaledW = imgNaturalSize.w * baseScale * crop.scale * ratio; const scaledH = imgNaturalSize.h * baseScale * crop.scale * ratio; const dx = (size - scaledW) / 2 + crop.x * ratio; const dy = (size - scaledH) / 2 + crop.y * ratio; ctx.drawImage(img, dx, dy, scaledW, scaledH); canvas.toBlob((blob) => resolve(blob), "image/png"); }; img.src = imageSrc; }); }, [imageSrc, imgNaturalSize, bgColor, baseScale, crop]); /* ---------------------------------------------------------------- */ /* Actions */ /* ---------------------------------------------------------------- */ const onUploadPhoto = async () => { setLoading(true); setModalMessage(""); try { const blob = await renderCroppedBlob(); if (!blob) { setModalMessage("Erreur de recadrage."); return; } const updated = await uploadProjectPhoto(project.id, blob, bgColor); onSuccess(updated); onClose(); } catch (err) { setModalMessage(err instanceof Error ? err.message : "Erreur réseau"); } finally { setLoading(false); } }; const onSelectDefaultAvatar = async () => { if (!selectedDefault) return; setLoading(true); setModalMessage(""); try { const updated = await setProjectDefaultAvatar(project.id, selectedDefault, bgColor); onSuccess(updated); onClose(); } catch (err) { setModalMessage(err instanceof Error ? err.message : "Erreur réseau"); } finally { setLoading(false); } }; const onDeletePhoto = async () => { setLoading(true); setModalMessage(""); try { const updated = await deleteProjectPhoto(project.id); onSuccess(updated); } catch (err) { setModalMessage(err instanceof Error ? err.message : "Erreur réseau"); } finally { setLoading(false); } }; /* ---------------------------------------------------------------- */ /* Derived */ /* ---------------------------------------------------------------- */ const hasCurrentPhoto = !!project.projectImageUrl; const currentImageSrc = project.projectImageUrl ? `${apiUrl}${project.projectImageUrl}` : null; const canSave = modalTab === "upload" ? !!imageSrc : !!selectedDefault; /* ---------------------------------------------------------------- */ /* Render */ /* ---------------------------------------------------------------- */ return (
e.stopPropagation()}> {/* header */}

Photo du projet

{/* tabs */}
{defaultAvatars.length > 0 && ( )}
{/* ----- tab: upload ----- */} {modalTab === "upload" && ( <> {imageSrc ? ( <>
{ const el = e.currentTarget; const bs = Math.max(CIRCLE_SIZE / el.naturalWidth, CIRCLE_SIZE / el.naturalHeight); setBaseScale(bs); setImgNaturalSize({ w: el.naturalWidth, h: el.naturalHeight }); }} style={imgNaturalSize ? { width: imgNaturalSize.w * baseScale, height: imgNaturalSize.h * baseScale, marginLeft: -(imgNaturalSize.w * baseScale) / 2, marginTop: -(imgNaturalSize.h * baseScale) / 2, transform: `translate(${crop.x}px, ${crop.y}px) scale(${crop.scale})`, } : { display: "none" }} />
setCrop((prev) => ({ ...prev, scale: Number(e.target.value) / 100 }))} /> +
) : hasCurrentPhoto ? (
Photo actuelle

Photo actuelle

) : (
fileInputRef.current?.click()} >

Glisse une image ici

ou clique pour parcourir · PNG, JPG, WEBP

)} )} {/* ----- tab: defaults ----- */} {modalTab === "defaults" && (
{defaultAvatars.map((url) => ( ))}
)} {/* color picker (masqué si photo existante sans nouvelle sélection) */} {(imageSrc || selectedDefault || !project.projectImageUrl) && (
Couleur de fond
{PASTEL_COLORS.map((c) => (
{showCustomPicker && ( setBgColor(e.target.value)} /> )}
)} {modalMessage &&

{modalMessage}

} {/* actions */} {canSave && (
)}
); }