brackt/app/components/ui/AvatarUploader.tsx
2026-05-06 14:47:37 -07:00

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>
);
}