a807c4c85e
Remplace le laurier dessiné à la main par le logo fourni (badge circulaire « L'Agora ») comme identité visuelle principale : favicon (icon.png), icône iOS (apple-icon.png, remplace icon.svg), marque du header, page login, page signup, et grand format en en-tête du classement. - components/agora-logo.tsx : composant partagé, `unoptimized` sur next/image — l'optimiseur d'images de Next.js aplatit systématiquement la transparence de ce fichier (PNG ou WebP) lors du redimensionnement à la volée, donc on sert l'asset statique tel quel. - Asset retravaillé (public/logo-agora.webp) : le fichier fourni n'avait pas de vraie transparence (damier dessiné en dur, y compris dans les zones "vides" de l'illustration) et le cercle était mal centré dans son canevas carré (anneau doré rogné en haut). Recadré, recentré avec une marge uniforme, fond extérieur en transparence réelle, fond intérieur aplati en blanc crème (#F4ECD8, le token "marble" existant) pour se fondre dans les cartes marbre de l'app. - Header : bandeau renommé « LE TRIBUNAL » → « L'AGORA » (à gauche) ; les icônes du menu déroulant restent inchangées (icônes distinctes par page, nécessaires pour s'y retrouver). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
154 lines
4.7 KiB
TypeScript
154 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState } from "react";
|
|
import Cropper, { type Area } from "react-easy-crop";
|
|
import { cropImageToBlob } from "@/lib/image";
|
|
import { IconClose } from "@/components/icons";
|
|
import { AgoraLogo } from "@/components/agora-logo";
|
|
|
|
export function AvatarPicker({
|
|
hasExistingPhoto = false,
|
|
onCropped,
|
|
onClear,
|
|
}: {
|
|
hasExistingPhoto?: boolean;
|
|
onCropped: (blob: Blob) => void;
|
|
onClear?: () => void;
|
|
}) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [rawImageSrc, setRawImageSrc] = useState<string | null>(null);
|
|
const [pendingFileName, setPendingFileName] = useState<string | null>(null);
|
|
const [fileName, setFileName] = useState<string | null>(null);
|
|
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
|
const [zoom, setZoom] = useState(1);
|
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = "";
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
setRawImageSrc(reader.result as string);
|
|
setPendingFileName(file.name);
|
|
setCrop({ x: 0, y: 0 });
|
|
setZoom(1);
|
|
setError(null);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
async function handleValidate() {
|
|
if (!rawImageSrc || !croppedAreaPixels) return;
|
|
try {
|
|
const blob = await cropImageToBlob(rawImageSrc, croppedAreaPixels);
|
|
onCropped(blob);
|
|
setFileName(pendingFileName);
|
|
setRawImageSrc(null);
|
|
setPendingFileName(null);
|
|
} catch {
|
|
setError("Impossible de traiter cette image, réessaie.");
|
|
}
|
|
}
|
|
|
|
function handleClearSelection() {
|
|
setFileName(null);
|
|
onClear?.();
|
|
}
|
|
|
|
function truncate(name: string): string {
|
|
return name.length > 28 ? `${name.slice(0, 12)}…${name.slice(-10)}` : name;
|
|
}
|
|
|
|
if (rawImageSrc) {
|
|
return (
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="relative h-64 w-64 max-w-full overflow-hidden rounded-md bg-ink-2/10">
|
|
<Cropper
|
|
image={rawImageSrc}
|
|
crop={crop}
|
|
zoom={zoom}
|
|
aspect={1}
|
|
cropShape="round"
|
|
showGrid={false}
|
|
onCropChange={setCrop}
|
|
onZoomChange={setZoom}
|
|
onCropComplete={(_, pixels) => setCroppedAreaPixels(pixels)}
|
|
/>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={1}
|
|
max={3}
|
|
step={0.05}
|
|
value={zoom}
|
|
onChange={(event) => setZoom(Number(event.target.value))}
|
|
className="w-48"
|
|
aria-label="Zoom"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setRawImageSrc(null);
|
|
setPendingFileName(null);
|
|
}}
|
|
className="rounded-md border border-gold/40 px-3 py-1.5 text-sm text-text-marble hover:bg-gold/10"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleValidate}
|
|
className="rounded-md bg-ink-2 px-3 py-1.5 text-sm font-medium text-gold-bright hover:bg-ink"
|
|
>
|
|
Valider le cadrage
|
|
</button>
|
|
</div>
|
|
{error && <p className="text-xs text-oxblood">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col items-center gap-2">
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleFileChange}
|
|
className="sr-only"
|
|
aria-label="Choisir un fichier image"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => inputRef.current?.click()}
|
|
className="flex items-center gap-2 rounded-md bg-ink-2 px-3 py-1.5 text-sm font-medium text-gold-bright hover:bg-ink"
|
|
>
|
|
<AgoraLogo className="h-4 w-4" />
|
|
{fileName || hasExistingPhoto ? "Changer ma photo" : "Choisir une photo"}
|
|
</button>
|
|
|
|
{fileName ? (
|
|
<div className="flex items-center gap-1.5 text-xs text-text-mut">
|
|
<span>{truncate(fileName)}</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleClearSelection}
|
|
aria-label="Retirer la photo sélectionnée"
|
|
className="rounded-full p-0.5 text-text-mut hover:bg-oxblood/10 hover:text-oxblood"
|
|
>
|
|
<IconClose className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<span className="text-xs text-text-mut">Aucune photo sélectionnée</span>
|
|
)}
|
|
|
|
{error && <p className="text-xs text-oxblood">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|