228 lines
6.8 KiB
TypeScript
228 lines
6.8 KiB
TypeScript
"use client";
|
||
|
||
import { Upload, X } from "lucide-react";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { resizeImageToDataURL } from "@/lib/image/resize";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
export interface ProcessedImage {
|
||
id: string;
|
||
originalName: string;
|
||
previewUrl: string;
|
||
dataUrl: string;
|
||
mimeType: string;
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
interface ItemImageCaptureProps {
|
||
maxImages?: number;
|
||
className?: string;
|
||
onImagesChange?: (images: ProcessedImage[]) => void;
|
||
}
|
||
|
||
export function ItemImageCapture({
|
||
maxImages = 2,
|
||
className,
|
||
onImagesChange,
|
||
}: ItemImageCaptureProps) {
|
||
const generateId = useCallback(() => {
|
||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||
return crypto.randomUUID();
|
||
}
|
||
return `img-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
}, []);
|
||
|
||
const [images, setImages] = useState<ProcessedImage[]>([]);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const previewUrlsRef = useRef<Set<string>>(new Set());
|
||
|
||
const updateImages = useCallback(
|
||
(next: ProcessedImage[]) => {
|
||
setImages(next);
|
||
onImagesChange?.(next);
|
||
},
|
||
[onImagesChange],
|
||
);
|
||
|
||
const handleFiles = useCallback(
|
||
async (files: FileList | File[]) => {
|
||
const fileArray = Array.from(files).slice(0, maxImages - images.length);
|
||
if (fileArray.length === 0) return;
|
||
|
||
const processed: ProcessedImage[] = [];
|
||
|
||
for (const file of fileArray) {
|
||
if (!file.type.startsWith("image/")) continue;
|
||
try {
|
||
const { dataUrl, width, height } = await resizeImageToDataURL(file, {
|
||
mimeType: file.type === "image/png" ? "image/png" : "image/jpeg",
|
||
});
|
||
|
||
const previewUrl = URL.createObjectURL(file);
|
||
previewUrlsRef.current.add(previewUrl);
|
||
|
||
processed.push({
|
||
id: generateId(),
|
||
originalName: file.name,
|
||
previewUrl,
|
||
dataUrl,
|
||
mimeType: file.type === "image/png" ? "image/png" : "image/jpeg",
|
||
width,
|
||
height,
|
||
});
|
||
} catch (error) {
|
||
console.error("Falha ao processar imagem", error);
|
||
}
|
||
}
|
||
|
||
if (processed.length > 0) {
|
||
updateImages([...images, ...processed].slice(0, maxImages));
|
||
}
|
||
},
|
||
[generateId, images, maxImages, updateImages],
|
||
);
|
||
|
||
const handleDrop = useCallback(
|
||
async (event: React.DragEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
if (event.dataTransfer.files?.length) {
|
||
await handleFiles(event.dataTransfer.files);
|
||
}
|
||
},
|
||
[handleFiles],
|
||
);
|
||
|
||
const onPaste = useCallback(
|
||
async (event: React.ClipboardEvent<HTMLDivElement>) => {
|
||
const items = event.clipboardData?.items;
|
||
if (!items) return;
|
||
const files: File[] = [];
|
||
for (const item of items) {
|
||
if (item.type.startsWith("image/")) {
|
||
const file = item.getAsFile();
|
||
if (file) files.push(file);
|
||
}
|
||
}
|
||
if (files.length) {
|
||
await handleFiles(files);
|
||
}
|
||
},
|
||
[handleFiles],
|
||
);
|
||
|
||
const removeImage = useCallback(
|
||
(id: string) => {
|
||
const nextImage = images.find((image) => image.id === id);
|
||
if (nextImage) {
|
||
URL.revokeObjectURL(nextImage.previewUrl);
|
||
previewUrlsRef.current.delete(nextImage.previewUrl);
|
||
}
|
||
const next = images.filter((image) => image.id !== id);
|
||
updateImages(next);
|
||
},
|
||
[images, updateImages],
|
||
);
|
||
|
||
const canAddMore = images.length < maxImages;
|
||
|
||
const dropLabel = useMemo(() => {
|
||
if (images.length === 0) {
|
||
return "Arraste, cole (Ctrl+V) ou escolha imagens";
|
||
}
|
||
if (canAddMore) {
|
||
return "Adicione mais imagens ou cole via Ctrl+V";
|
||
}
|
||
return "Limite máximo de imagens atingido";
|
||
}, [images.length, canAddMore]);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
previewUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||
previewUrlsRef.current.clear();
|
||
},
|
||
[],
|
||
);
|
||
|
||
return (
|
||
<div className={cn("space-y-4", className)}>
|
||
<div
|
||
className={cn(
|
||
"rounded-lg border-2 border-dashed border-muted-foreground/40 bg-muted/40 p-6 text-center transition-colors",
|
||
canAddMore ? "hover:border-primary" : "opacity-70",
|
||
)}
|
||
onDragOver={(event) => {
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = "copy";
|
||
}}
|
||
onDrop={handleDrop}
|
||
onPaste={onPaste}
|
||
role="presentation"
|
||
>
|
||
<div className="flex flex-col items-center gap-3">
|
||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||
<p className="text-sm text-muted-foreground">{dropLabel}</p>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => inputRef.current?.click()}
|
||
disabled={!canAddMore}
|
||
>
|
||
Selecionar arquivo
|
||
</Button>
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={async (event) => {
|
||
if (event.target.files?.length) {
|
||
await handleFiles(event.target.files);
|
||
event.target.value = "";
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{images.length > 0 && (
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{images.map((image) => (
|
||
<div
|
||
key={image.id}
|
||
className="relative overflow-hidden rounded-md border bg-background shadow-sm"
|
||
>
|
||
<button
|
||
type="button"
|
||
className="absolute right-2 top-2 rounded-full bg-background/80 p-1 text-muted-foreground transition hover:bg-destructive hover:text-destructive-foreground"
|
||
onClick={() => removeImage(image.id)}
|
||
aria-label="Remover imagem"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-emerald-600/90 px-2 py-0.5 text-[11px] text-white">
|
||
<span className="h-1.5 w-1.5 rounded-full bg-white" />
|
||
Pronta
|
||
</span>
|
||
<img
|
||
src={image.previewUrl}
|
||
alt={image.originalName}
|
||
className="h-48 w-full object-cover"
|
||
/>
|
||
<div className="border-t p-3 text-xs text-muted-foreground">
|
||
<p className="font-medium">{image.originalName}</p>
|
||
<p>
|
||
{image.width}×{image.height}px ·{" "}
|
||
{Math.round((image.dataUrl.length * 3) / 4 / 1024)}KB
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|