195 lines
5.5 KiB
TypeScript
195 lines
5.5 KiB
TypeScript
|
|
'use client';
|
|||
|
|
|
|||
|
|
import { useCallback, useState } from 'react';
|
|||
|
|
import Cropper, { type Area } from 'react-easy-crop';
|
|||
|
|
import { Loader2 } from 'lucide-react';
|
|||
|
|
|
|||
|
|
import { Button } from '@/components/ui/button';
|
|||
|
|
import {
|
|||
|
|
Dialog,
|
|||
|
|
DialogContent,
|
|||
|
|
DialogFooter,
|
|||
|
|
DialogHeader,
|
|||
|
|
DialogTitle,
|
|||
|
|
} from '@/components/ui/dialog';
|
|||
|
|
import { Label } from '@/components/ui/label';
|
|||
|
|
import { toastError } from '@/lib/api/toast-error';
|
|||
|
|
|
|||
|
|
export interface ImageCropperDialogProps {
|
|||
|
|
open: boolean;
|
|||
|
|
onOpenChange: (open: boolean) => void;
|
|||
|
|
/** The picked file (raw, pre-crop). Cropper renders an object URL. */
|
|||
|
|
file: File | null;
|
|||
|
|
/** Aspect ratio for the crop frame. 1 = square (avatars), 4 = wide
|
|||
|
|
* banners. Defaults to 1. */
|
|||
|
|
aspect?: number;
|
|||
|
|
/** Output JPEG quality 0-1. Default 0.85. */
|
|||
|
|
outputQuality?: number;
|
|||
|
|
/** Output width in pixels (height derived from aspect). Default 512. */
|
|||
|
|
outputWidth?: number;
|
|||
|
|
/** Async upload handler — receives the cropped Blob. Cropper closes on
|
|||
|
|
* success; toasts on error. */
|
|||
|
|
onUpload: (blob: Blob) => Promise<void>;
|
|||
|
|
/** Dialog title. Default "Crop image". */
|
|||
|
|
title?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Reusable crop-then-upload modal. Renders a draggable/zoomable crop
|
|||
|
|
* frame over the picked file, then writes the cropped pixels to a
|
|||
|
|
* Canvas, exports as JPEG, and hands the Blob to the caller.
|
|||
|
|
*
|
|||
|
|
* Used for: profile avatars, port logos, brochure covers — anywhere a
|
|||
|
|
* square or fixed-aspect image needs to land in storage.
|
|||
|
|
*/
|
|||
|
|
export function ImageCropperDialog({
|
|||
|
|
open,
|
|||
|
|
onOpenChange,
|
|||
|
|
file,
|
|||
|
|
aspect = 1,
|
|||
|
|
outputQuality = 0.85,
|
|||
|
|
outputWidth = 512,
|
|||
|
|
onUpload,
|
|||
|
|
title = 'Crop image',
|
|||
|
|
}: ImageCropperDialogProps) {
|
|||
|
|
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
|||
|
|
const [zoom, setZoom] = useState(1);
|
|||
|
|
const [pixels, setPixels] = useState<Area | null>(null);
|
|||
|
|
const [uploading, setUploading] = useState(false);
|
|||
|
|
|
|||
|
|
const objectUrl = file ? URL.createObjectURL(file) : null;
|
|||
|
|
|
|||
|
|
const onCropComplete = useCallback((_area: Area, areaPixels: Area) => {
|
|||
|
|
setPixels(areaPixels);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
async function handleUpload() {
|
|||
|
|
if (!objectUrl || !pixels) return;
|
|||
|
|
setUploading(true);
|
|||
|
|
try {
|
|||
|
|
const blob = await renderCrop(objectUrl, pixels, aspect, outputWidth, outputQuality);
|
|||
|
|
await onUpload(blob);
|
|||
|
|
onOpenChange(false);
|
|||
|
|
} catch (err) {
|
|||
|
|
toastError(err);
|
|||
|
|
} finally {
|
|||
|
|
setUploading(false);
|
|||
|
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleClose(o: boolean) {
|
|||
|
|
if (!o && objectUrl) URL.revokeObjectURL(objectUrl);
|
|||
|
|
onOpenChange(o);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|||
|
|
<DialogContent className="sm:max-w-lg">
|
|||
|
|
<DialogHeader>
|
|||
|
|
<DialogTitle>{title}</DialogTitle>
|
|||
|
|
</DialogHeader>
|
|||
|
|
|
|||
|
|
{objectUrl && (
|
|||
|
|
<>
|
|||
|
|
<div className="relative h-72 w-full bg-muted rounded-md overflow-hidden">
|
|||
|
|
<Cropper
|
|||
|
|
image={objectUrl}
|
|||
|
|
crop={crop}
|
|||
|
|
zoom={zoom}
|
|||
|
|
aspect={aspect}
|
|||
|
|
onCropChange={setCrop}
|
|||
|
|
onZoomChange={setZoom}
|
|||
|
|
onCropComplete={onCropComplete}
|
|||
|
|
cropShape={aspect === 1 ? 'round' : 'rect'}
|
|||
|
|
showGrid={false}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="space-y-2 pt-2">
|
|||
|
|
<Label htmlFor="cropper-zoom" className="text-xs text-muted-foreground">
|
|||
|
|
Zoom
|
|||
|
|
</Label>
|
|||
|
|
<input
|
|||
|
|
id="cropper-zoom"
|
|||
|
|
type="range"
|
|||
|
|
min={1}
|
|||
|
|
max={3}
|
|||
|
|
step={0.05}
|
|||
|
|
value={zoom}
|
|||
|
|
onChange={(e) => setZoom(Number(e.target.value))}
|
|||
|
|
className="w-full"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<DialogFooter>
|
|||
|
|
<Button variant="outline" onClick={() => handleClose(false)} disabled={uploading}>
|
|||
|
|
Cancel
|
|||
|
|
</Button>
|
|||
|
|
<Button onClick={handleUpload} disabled={uploading || !pixels}>
|
|||
|
|
{uploading ? (
|
|||
|
|
<>
|
|||
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|||
|
|
Uploading…
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
'Save'
|
|||
|
|
)}
|
|||
|
|
</Button>
|
|||
|
|
</DialogFooter>
|
|||
|
|
</DialogContent>
|
|||
|
|
</Dialog>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Crop the source image at `pixels`, resize to `outputWidth`×
|
|||
|
|
* (outputWidth/aspect), and return as a JPEG Blob. Async because
|
|||
|
|
* <img> needs an onload event before we can drawImage.
|
|||
|
|
*/
|
|||
|
|
async function renderCrop(
|
|||
|
|
src: string,
|
|||
|
|
pixels: Area,
|
|||
|
|
aspect: number,
|
|||
|
|
outputWidth: number,
|
|||
|
|
quality: number,
|
|||
|
|
): Promise<Blob> {
|
|||
|
|
const image = await loadImage(src);
|
|||
|
|
const canvas = document.createElement('canvas');
|
|||
|
|
const outputHeight = Math.round(outputWidth / aspect);
|
|||
|
|
canvas.width = outputWidth;
|
|||
|
|
canvas.height = outputHeight;
|
|||
|
|
const ctx = canvas.getContext('2d');
|
|||
|
|
if (!ctx) throw new Error('Canvas 2d context unavailable');
|
|||
|
|
ctx.drawImage(
|
|||
|
|
image,
|
|||
|
|
pixels.x,
|
|||
|
|
pixels.y,
|
|||
|
|
pixels.width,
|
|||
|
|
pixels.height,
|
|||
|
|
0,
|
|||
|
|
0,
|
|||
|
|
outputWidth,
|
|||
|
|
outputHeight,
|
|||
|
|
);
|
|||
|
|
return await new Promise<Blob>((resolve, reject) => {
|
|||
|
|
canvas.toBlob(
|
|||
|
|
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob returned null'))),
|
|||
|
|
'image/jpeg',
|
|||
|
|
quality,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function loadImage(src: string): Promise<HTMLImageElement> {
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
const img = new Image();
|
|||
|
|
img.crossOrigin = 'anonymous';
|
|||
|
|
img.onload = () => resolve(img);
|
|||
|
|
img.onerror = (e) => reject(e);
|
|||
|
|
img.src = src;
|
|||
|
|
});
|
|||
|
|
}
|