init import projet

This commit is contained in:
2026-05-03 21:58:59 +02:00
parent f3756fdf8d
commit 8d3df9bbbb
179 changed files with 37694 additions and 132 deletions
@@ -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&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>
);
}