436 lines
17 KiB
TypeScript
436 lines
17 KiB
TypeScript
"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'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>
|
||
);
|
||
}
|