Files
mybabyguess/apps/web/src/features/predictions/components/project-photo-modal.tsx
T
2026-05-03 21:58:59 +02:00

436 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<File | null>(null);
const [imageSrc, setImageSrc] = useState<string | null>(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<string>(project.projectBgColor ?? PASTEL_COLORS[0]);
const [showCustomPicker, setShowCustomPicker] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const cropPreviewRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const dragStartRef = useRef<{ px: number; py: number; cx: number; cy: number } | null>(null);
/* default avatars */
const [defaultAvatars, setDefaultAvatars] = useState<string[]>([]);
const [selectedDefault, setSelectedDefault] = useState<string | null>(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<HTMLDivElement>) => { e.preventDefault(); setDragging(true); };
const onDragLeave = () => setDragging(false);
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); setDragging(false);
const dropped = e.dataTransfer.files[0];
if (dropped) pickFile(dropped);
};
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files?.[0]; if (picked) pickFile(picked);
};
/* ---------------------------------------------------------------- */
/* Crop drag */
/* ---------------------------------------------------------------- */
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<Blob | null> => {
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 (
<div className={styles.modalBackdrop} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<canvas ref={canvasRef} style={{ display: "none" }} />
{/* header */}
<div className={styles.modalHeader}>
<h2>Photo du projet</h2>
<button type="button" className={styles.modalClose} onClick={onClose} aria-label="Fermer"></button>
</div>
{/* tabs */}
<div className={styles.tabs}>
<button
type="button"
className={`${styles.tab} ${modalTab === "upload" ? styles.tabActive : ""}`}
onClick={() => setModalTab("upload")}
>
Ma photo
</button>
{defaultAvatars.length > 0 && (
<button
type="button"
className={`${styles.tab} ${modalTab === "defaults" ? styles.tabActive : ""}`}
onClick={() => setModalTab("defaults")}
>
Avatars
</button>
)}
</div>
{/* ----- tab: upload ----- */}
{modalTab === "upload" && (
<>
{imageSrc ? (
<>
<div className={styles.cropContainer}>
<div
ref={cropPreviewRef}
className={styles.cropCircle}
style={{ width: CIRCLE_SIZE, height: CIRCLE_SIZE, background: bgColor }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onWheel={onWheel}
>
<img
src={imageSrc}
alt=""
className={styles.cropImg}
draggable={false}
onLoad={(e) => {
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" }}
/>
</div>
</div>
<div className={styles.zoomRow}>
<span className={styles.zoomLabel}></span>
<input
type="range"
min={MIN_SCALE * 100}
max={MAX_SCALE * 100}
value={crop.scale * 100}
className={styles.zoomSlider}
onChange={(e) => setCrop((prev) => ({ ...prev, scale: Number(e.target.value) / 100 }))}
/>
<span className={styles.zoomLabel}>+</span>
</div>
<button type="button" className={styles.changeFile} onClick={() => fileInputRef.current?.click()}>
Changer l&apos;image
</button>
</>
) : hasCurrentPhoto ? (
<div className={styles.currentPhotoSection}>
<div className={styles.currentPhotoCircle} style={{ background: project.projectBgColor ?? undefined }}>
<img src={currentImageSrc!} alt="Photo actuelle" />
</div>
<p className={styles.currentPhotoHint}>Photo actuelle</p>
<div className={styles.currentPhotoActions}>
<button type="button" className={styles.changeFile} onClick={() => fileInputRef.current?.click()}>
Changer la photo
</button>
<button
type="button"
className={styles.deletePhotoBtn}
onClick={onDeletePhoto}
disabled={loading}
>
{loading ? "Suppression…" : "Supprimer la photo"}
</button>
</div>
</div>
) : (
<div
className={`${styles.dropZone} ${dragging ? styles.dropZoneActive : ""}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={() => fileInputRef.current?.click()}
>
<svg className={styles.dropIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<p className={styles.dropText}>Glisse une image ici</p>
<p className={styles.dropSub}>ou clique pour parcourir · PNG, JPG, WEBP</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp"
className={styles.hiddenInput}
onChange={onFileInputChange}
/>
</>
)}
{/* ----- tab: defaults ----- */}
{modalTab === "defaults" && (
<div className={styles.defaultGrid}>
{defaultAvatars.map((url) => (
<button
key={url}
type="button"
className={`${styles.defaultItem} ${selectedDefault === url ? styles.defaultItemActive : ""}`}
style={{ background: bgColor }}
onClick={() => { setSelectedDefault(url); setFile(null); setImageSrc(null); }}
>
<img src={`${apiUrl}${url}`} alt="" />
</button>
))}
</div>
)}
{/* color picker (masqué si photo existante sans nouvelle sélection) */}
{(imageSrc || selectedDefault || !project.projectImageUrl) && (
<div className={styles.colorSection}>
<span className={styles.colorLabel}>Couleur de fond</span>
<div className={styles.colorRow}>
{PASTEL_COLORS.map((c) => (
<button
key={c}
type="button"
className={`${styles.colorDot} ${bgColor === c ? styles.colorDotActive : ""}`}
style={{ background: c }}
onClick={() => setBgColor(c)}
/>
))}
<button
type="button"
className={`${styles.colorDot} ${styles.colorCustom} ${!PASTEL_COLORS.includes(bgColor) ? styles.colorDotActive : ""}`}
style={!PASTEL_COLORS.includes(bgColor) ? { background: bgColor } : undefined}
onClick={() => setShowCustomPicker((v) => !v)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" /><path d="M2 12h20" />
</svg>
</button>
</div>
{showCustomPicker && (
<input
type="color"
className={styles.nativeColorPicker}
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
/>
)}
</div>
)}
{modalMessage && <p className={styles.modalError}>{modalMessage}</p>}
{/* actions */}
{canSave && (
<div className={styles.modalActions}>
<button type="button" className="btn btn-soft" onClick={onClose} disabled={loading}>
Annuler
</button>
<button
type="button"
className="btn btn-primary"
onClick={modalTab === "upload" ? onUploadPhoto : onSelectDefaultAvatar}
disabled={loading}
>
{loading ? "Enregistrement…" : "Enregistrer"}
</button>
</div>
)}
</div>
</div>
);
}