changes shopping

This commit is contained in:
Evandro Fonseca Junior committed 2025-11-09 15:37:53 -03:00
1 parent 62ab5b2608
commit f4bc93bc8a
17 files changed
+2290 -1025

No files matched your search

+224
View File
@@ -0,0 +1,224 @@
"use client";
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
import { Upload, X } from "lucide-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>
<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>
);
}