262 lines
7.9 KiB
TypeScript
262 lines
7.9 KiB
TypeScript
import { useRef, useState } from "react";
|
|
import {
|
|
ReactCrop,
|
|
centerCrop,
|
|
convertToPixelCrop,
|
|
makeAspectCrop,
|
|
type Crop,
|
|
type PixelCrop,
|
|
} from "react-image-crop";
|
|
import "react-image-crop/dist/ReactCrop.css";
|
|
import { ImageIcon, Trash2, Upload } from "lucide-react";
|
|
import { Button } from "~/components/ui/button";
|
|
import { cn } from "~/lib/utils";
|
|
|
|
interface AvatarUploaderProps {
|
|
uploadUrl: string;
|
|
teamId?: string;
|
|
savedPhotoUrl?: string | null;
|
|
removePhotoIntent?: string;
|
|
}
|
|
|
|
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
|
|
|
function centerSquareCrop(width: number, height: number): Crop {
|
|
return centerCrop(
|
|
makeAspectCrop({ unit: "%", width: 85 }, 1, width, height),
|
|
width,
|
|
height
|
|
);
|
|
}
|
|
|
|
async function cropImageToBlob(image: HTMLImageElement, crop: PixelCrop): Promise<Blob> {
|
|
const canvas = document.createElement("canvas");
|
|
const scaleX = image.naturalWidth / image.width;
|
|
const scaleY = image.naturalHeight / image.height;
|
|
canvas.width = 256;
|
|
canvas.height = 256;
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
if (!ctx) {
|
|
throw new Error("Canvas is not available");
|
|
}
|
|
|
|
ctx.drawImage(
|
|
image,
|
|
crop.x * scaleX,
|
|
crop.y * scaleY,
|
|
crop.width * scaleX,
|
|
crop.height * scaleY,
|
|
0,
|
|
0,
|
|
256,
|
|
256
|
|
);
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
canvas.toBlob((blob) => {
|
|
if (blob) resolve(blob);
|
|
else reject(new Error("Could not prepare image"));
|
|
}, "image/jpeg", 0.9);
|
|
});
|
|
}
|
|
|
|
export function AvatarUploader({
|
|
uploadUrl,
|
|
teamId,
|
|
savedPhotoUrl,
|
|
removePhotoIntent = "remove-avatar-photo",
|
|
}: AvatarUploaderProps) {
|
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
|
const [shouldRemoveSavedPhoto, setShouldRemoveSavedPhoto] = useState(false);
|
|
const [fileName, setFileName] = useState("avatar.jpg");
|
|
const [crop, setCrop] = useState<Crop>();
|
|
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
|
|
const [status, setStatus] = useState<"idle" | "submitting" | "pending" | "error">("idle");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
function prepareFile(file: File | undefined) {
|
|
setError(null);
|
|
setStatus("idle");
|
|
setShouldRemoveSavedPhoto(false);
|
|
|
|
if (!file) return;
|
|
if (!ACCEPTED_TYPES.includes(file.type)) {
|
|
setError("Choose a JPG, PNG, WebP, or GIF image.");
|
|
return;
|
|
}
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
setError("Choose an image under 5 MB.");
|
|
return;
|
|
}
|
|
|
|
setFileName(file.name);
|
|
const reader = new FileReader();
|
|
reader.addEventListener("load", () => setImageSrc(String(reader.result)));
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
prepareFile(event.target.files?.[0]);
|
|
event.target.value = "";
|
|
}
|
|
|
|
function clearImage() {
|
|
setImageSrc(null);
|
|
setFileName("avatar.jpg");
|
|
setCrop(undefined);
|
|
setCompletedCrop(undefined);
|
|
setError(null);
|
|
setStatus("idle");
|
|
}
|
|
|
|
function handleRemoveSavedPhoto() {
|
|
setShouldRemoveSavedPhoto(true);
|
|
clearImage();
|
|
}
|
|
|
|
function handleDrop(event: React.DragEvent<HTMLButtonElement>) {
|
|
event.preventDefault();
|
|
setIsDragging(false);
|
|
prepareFile(event.dataTransfer.files[0]);
|
|
}
|
|
|
|
async function handleSubmit() {
|
|
const image = imageRef.current;
|
|
const cropToUse =
|
|
completedCrop ??
|
|
(image && crop ? convertToPixelCrop(crop, image.width, image.height) : undefined);
|
|
|
|
if (!image || !cropToUse) {
|
|
setError("Choose and crop an image first.");
|
|
return;
|
|
}
|
|
|
|
setStatus("submitting");
|
|
setError(null);
|
|
|
|
try {
|
|
const blob = await cropImageToBlob(image, cropToUse);
|
|
const formData = new FormData();
|
|
formData.append("file", blob, fileName.replace(/\.[^.]+$/, ".jpg"));
|
|
if (teamId) formData.append("teamId", teamId);
|
|
|
|
const response = await fetch(uploadUrl, { method: "POST", body: formData });
|
|
const result = await response.json().catch(() => ({}));
|
|
|
|
if (!response.ok) {
|
|
throw new Error(typeof result.error === "string" ? result.error : "Upload failed.");
|
|
}
|
|
|
|
setStatus("pending");
|
|
} catch (err) {
|
|
setStatus("error");
|
|
setError(err instanceof Error ? err.message : "Upload failed.");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={ACCEPTED_TYPES.join(",")}
|
|
onChange={handleFileChange}
|
|
className="sr-only"
|
|
/>
|
|
|
|
{savedPhotoUrl && !shouldRemoveSavedPhoto && !imageSrc && (
|
|
<div className="space-y-3">
|
|
<div className="overflow-hidden rounded-md border border-border bg-background">
|
|
<img src={savedPhotoUrl} alt="Current avatar" className="max-h-[360px] w-full object-cover" />
|
|
</div>
|
|
<Button type="button" variant="outline" onClick={handleRemoveSavedPhoto} className="w-full">
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
Remove photo
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{(!savedPhotoUrl || shouldRemoveSavedPhoto) && !imageSrc && (
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
onDragOver={(event) => {
|
|
event.preventDefault();
|
|
setIsDragging(true);
|
|
}}
|
|
onDragEnter={(event) => {
|
|
event.preventDefault();
|
|
setIsDragging(true);
|
|
}}
|
|
onDragLeave={() => setIsDragging(false)}
|
|
onDrop={handleDrop}
|
|
className={cn(
|
|
"flex min-h-40 w-full flex-col items-center justify-center rounded-lg border border-dashed bg-card/40 px-6 py-8 text-center transition-colors",
|
|
isDragging ? "border-[#adf661] bg-[#adf661]/10" : "border-border hover:border-muted-foreground hover:bg-accent/40"
|
|
)}
|
|
>
|
|
<ImageIcon className="mb-3 h-9 w-9 text-muted-foreground" />
|
|
<span className="text-sm font-medium text-muted-foreground">Drop a picture</span>
|
|
<span className="text-xs text-muted-foreground/80">or click to browse</span>
|
|
</button>
|
|
)}
|
|
|
|
{imageSrc && (
|
|
<>
|
|
<div className="max-w-sm overflow-hidden rounded-md border border-border bg-background">
|
|
<ReactCrop
|
|
crop={crop}
|
|
onChange={(_, percentCrop) => setCrop(percentCrop)}
|
|
onComplete={(pixelCrop) => setCompletedCrop(pixelCrop)}
|
|
aspect={1}
|
|
>
|
|
<img
|
|
ref={imageRef}
|
|
src={imageSrc}
|
|
alt=""
|
|
onLoad={(event) => {
|
|
const { width, height } = event.currentTarget;
|
|
setCrop(centerSquareCrop(width, height));
|
|
}}
|
|
className="max-h-[360px]"
|
|
/>
|
|
</ReactCrop>
|
|
</div>
|
|
|
|
<Button type="button" variant="outline" onClick={clearImage} className="w-full">
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
Remove picture
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{status === "pending" && (
|
|
<p className="text-sm text-[#adf661]">Photo submitted for review. It will appear after approval.</p>
|
|
)}
|
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
|
|
{shouldRemoveSavedPhoto && !imageSrc ? (
|
|
<form method="post">
|
|
<input type="hidden" name="intent" value={removePhotoIntent} />
|
|
<Button type="submit" className="w-full">
|
|
Revert to flag avatar
|
|
</Button>
|
|
</form>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
disabled={!imageSrc || status === "submitting"}
|
|
className="w-full"
|
|
>
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
{status === "submitting" ? "Submitting..." : "Submit photo"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|