init import projet
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user