changes shopping
This commit is contained in:
1 parent
62ab5b2608
commit
f4bc93bc8a
17 files changed
+2290
-1025
No files matched your search
@@ -17,6 +17,7 @@ interface AdminPanelTabsProps {
|
||||
} | null;
|
||||
initialTiers: any[];
|
||||
itemsData: AdminItemsResponse;
|
||||
locale: string;
|
||||
initialTab?: "overview" | "subscriptions" | "items";
|
||||
}
|
||||
|
||||
@@ -24,6 +25,7 @@ export function AdminPanelTabs({
|
||||
adminUser,
|
||||
initialTiers,
|
||||
itemsData,
|
||||
locale,
|
||||
initialTab = "overview",
|
||||
}: AdminPanelTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "subscriptions" | "items">(
|
||||
@@ -66,7 +68,7 @@ export function AdminPanelTabs({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="items">
|
||||
<AdminItemManager initialData={itemsData} />
|
||||
<AdminItemManager initialData={itemsData} locale={locale} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm";
|
||||
import { ItemAiAssistant, type ItemAiPrefill } from "./ItemAiAssistant";
|
||||
|
||||
interface AdminItemCreatePageProps {
|
||||
locale: string;
|
||||
categories: string[];
|
||||
serverOptions: string[];
|
||||
}
|
||||
|
||||
export function AdminItemCreatePage({ locale, categories, serverOptions }: AdminItemCreatePageProps) {
|
||||
const router = useRouter();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [prefillData, setPrefillData] = useState<ItemAiPrefill | null>(null);
|
||||
|
||||
const goBackToList = () => {
|
||||
router.push(`/${locale}/admin?tab=items`);
|
||||
};
|
||||
|
||||
const handleSubmit = async (payload: ItemFormPayload) => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
name: payload.name,
|
||||
category: payload.category,
|
||||
rarity: payload.rarity,
|
||||
imageUrl: payload.imageUrl,
|
||||
imageLocalPath: payload.imageLocalPath,
|
||||
level: payload.level,
|
||||
weight: payload.weight,
|
||||
slots: payload.slots,
|
||||
skill: payload.skill,
|
||||
description: payload.description,
|
||||
servers: payload.servers,
|
||||
tags: payload.tags,
|
||||
metadata: payload.metadata,
|
||||
fields: payload.fields,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/admin/items", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({ error: "Erro ao criar item" }));
|
||||
throw new Error(data?.error || data?.message || "Falha ao criar item");
|
||||
}
|
||||
|
||||
goBackToList();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : "Erro ao criar item. Tente novamente.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Novo item</h1>
|
||||
<p className="text-muted-foreground max-w-2xl">
|
||||
Utilize a seção de IA abaixo para gerar automaticamente campos sugeridos a partir de prints e descrições.
|
||||
Ajuste o que for necessário antes de publicar.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={goBackToList} className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ItemAiAssistant
|
||||
locale={locale}
|
||||
categories={categories}
|
||||
serverOptions={serverOptions}
|
||||
onGenerated={({ json, prefill }) => {
|
||||
setPrefillData({
|
||||
...prefill,
|
||||
metadata: {
|
||||
aiStructure: json ?? null,
|
||||
...prefill.metadata,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<AdminItemForm
|
||||
mode="create"
|
||||
categories={categories}
|
||||
serverOptions={serverOptions}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
onCancel={goBackToList}
|
||||
isActive
|
||||
allowAiTools={false}
|
||||
prefillData={prefillData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm";
|
||||
|
||||
interface AdminItemCreateViewProps {
|
||||
locale: string;
|
||||
categories: string[];
|
||||
serverOptions: string[];
|
||||
}
|
||||
|
||||
export function AdminItemCreateView({ locale, categories, serverOptions }: AdminItemCreateViewProps) {
|
||||
const router = useRouter();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (payload: ItemFormPayload) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const body = {
|
||||
name: payload.name,
|
||||
category: payload.category,
|
||||
rarity: payload.rarity,
|
||||
imageUrl: payload.imageUrl,
|
||||
imageLocalPath: payload.imageLocalPath,
|
||||
level: payload.level,
|
||||
weight: payload.weight,
|
||||
slots: payload.slots,
|
||||
skill: payload.skill,
|
||||
description: payload.description,
|
||||
servers: payload.servers,
|
||||
tags: payload.tags,
|
||||
metadata: payload.metadata,
|
||||
fields: payload.fields,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/admin/items", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => ({ error: "Erro ao criar item" }));
|
||||
throw new Error(errorPayload?.error || "Falha ao criar item");
|
||||
}
|
||||
|
||||
router.push(`/${locale}/admin?tab=items`);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push(`/${locale}/admin?tab=items`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="rounded-2xl border border-border/60 bg-card shadow-sm">
|
||||
<AdminItemForm
|
||||
mode="create"
|
||||
categories={categories}
|
||||
serverOptions={serverOptions}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
submitting={submitting}
|
||||
className="flex flex-col"
|
||||
contentClassName="px-6 py-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -46,9 +47,11 @@ export interface AdminItemsResponse {
|
||||
|
||||
interface AdminItemManagerProps {
|
||||
initialData: AdminItemsResponse;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function AdminItemManager({ initialData }: AdminItemManagerProps) {
|
||||
export function AdminItemManager({ initialData, locale }: AdminItemManagerProps) {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<AdminItemsResponse>(initialData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -61,7 +64,6 @@ export function AdminItemManager({ initialData }: AdminItemManagerProps) {
|
||||
const pageSize = initialData.pageSize ?? 50;
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
||||
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -120,14 +122,7 @@ export function AdminItemManager({ initialData }: AdminItemManagerProps) {
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
|
||||
}, [data.items]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
setModalMode("create");
|
||||
setSelectedItem(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (item: AdminItemRecord) => {
|
||||
setModalMode("edit");
|
||||
setSelectedItem(item);
|
||||
setModalOpen(true);
|
||||
};
|
||||
@@ -135,7 +130,7 @@ export function AdminItemManager({ initialData }: AdminItemManagerProps) {
|
||||
const handleSubmit = async (payload: ItemFormPayload) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const method = modalMode === "edit" ? "PUT" : "POST";
|
||||
const method = "PUT";
|
||||
const body: Record<string, unknown> = {
|
||||
id: payload.id,
|
||||
name: payload.name,
|
||||
@@ -194,7 +189,12 @@ export function AdminItemManager({ initialData }: AdminItemManagerProps) {
|
||||
<Button variant="outline" size="sm" onClick={fetchItems} disabled={loading}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" /> Atualizar
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreateModal}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
router.push(`/${locale}/admin/items/new`);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" /> Novo item
|
||||
</Button>
|
||||
</div>
|
||||
@@ -394,7 +394,7 @@ export function AdminItemManager({ initialData }: AdminItemManagerProps) {
|
||||
<ItemCreationModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
mode={modalMode}
|
||||
mode="edit"
|
||||
item={selectedItem ?? undefined}
|
||||
categories={data.categories}
|
||||
serverOptions={data.servers}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ItemImageCapture, ProcessedImage } from "./ItemImageCapture";
|
||||
|
||||
interface ItemAiAssistantProps {
|
||||
locale: string;
|
||||
categories: string[];
|
||||
serverOptions: string[];
|
||||
onGenerated: (result: { json: unknown; prefill: ItemAiPrefill }) => void;
|
||||
}
|
||||
|
||||
export interface ItemAiPrefill {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
rarity?: string | null;
|
||||
servers?: string[] | string;
|
||||
tags?: string[] | string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function ItemAiAssistant({
|
||||
locale,
|
||||
categories,
|
||||
serverOptions,
|
||||
onGenerated,
|
||||
}: ItemAiAssistantProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [scope, setScope] = useState<"global" | "server">("global");
|
||||
const [server, setServer] = useState<string>(serverOptions[0] ?? "");
|
||||
const [images, setImages] = useState<ProcessedImage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rawJson, setRawJson] = useState<string | null>(null);
|
||||
|
||||
const disabled = useMemo(() => !name.trim() || !description.trim(), [name, description]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (disabled || loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/admin/items/analyze", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
locale,
|
||||
name,
|
||||
description,
|
||||
scope,
|
||||
server: scope === "server" ? server : null,
|
||||
categories,
|
||||
servers: serverOptions,
|
||||
images: images.map((image) => image.dataUrl),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(data?.error || "Falha na análise com IA.");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const structure = data?.structure ?? data;
|
||||
const columns = structure?.columns;
|
||||
|
||||
setRawJson(JSON.stringify(structure, null, 2));
|
||||
|
||||
const prefill: ItemAiPrefill = {
|
||||
name: typeof columns?.name === "string" ? columns.name : undefined,
|
||||
description: typeof columns?.description === "string" ? columns.description : undefined,
|
||||
metadata: typeof columns === "object" && columns !== null ? columns : undefined,
|
||||
};
|
||||
|
||||
if (columns) {
|
||||
if (typeof columns.category === "string") {
|
||||
prefill.category = columns.category;
|
||||
}
|
||||
if (Array.isArray(columns.servers)) {
|
||||
prefill.servers = columns.servers as string[];
|
||||
}
|
||||
if (typeof columns.servers === "string") {
|
||||
prefill.servers = columns.servers;
|
||||
}
|
||||
if (Array.isArray(columns.tags)) {
|
||||
prefill.tags = columns.tags as string[];
|
||||
}
|
||||
if (typeof columns.tags === "string") {
|
||||
prefill.tags = columns.tags;
|
||||
}
|
||||
if (columns.rarity && typeof columns.rarity === "string") {
|
||||
prefill.rarity = columns.rarity;
|
||||
}
|
||||
}
|
||||
|
||||
onGenerated({ json: structure, prefill });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : "Erro ao processar análise de IA.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
categories,
|
||||
description,
|
||||
disabled,
|
||||
images,
|
||||
locale,
|
||||
loading,
|
||||
name,
|
||||
onGenerated,
|
||||
scope,
|
||||
server,
|
||||
serverOptions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
Assistente IA para registro de item
|
||||
</h2>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Cole prints e descreva o item. A IA vai sugerir a tabela, campos e metadados. Você pode ajustar os dados
|
||||
antes de salvar.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Até 2 imagens · Compressão automática · Suporte a Ctrl+V
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1.1fr,0.9fr]">
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Nome do item</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Ex.: Final Fate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Escopo</label>
|
||||
<Select value={scope} onValueChange={(value: "global" | "server") => setScope(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global</SelectItem>
|
||||
<SelectItem value="server">Específico de servidor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scope === "server" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Servidor alvo</label>
|
||||
<Select value={server} onValueChange={setServer}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o servidor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{serverOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Descrição detalhada para a IA</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="Cole aqui todo o texto do item (atributos, requisitos, efeitos, etc.)"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Prints / evidências visuais</label>
|
||||
<ItemImageCapture onImagesChange={setImages} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" onClick={handleGenerate} disabled={disabled || loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Gerar com IA
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A IA retornará um JSON com sugestões de campos. Eles serão aplicados automaticamente no formulário.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="min-h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Prévia da resposta</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-xs text-muted-foreground">
|
||||
{rawJson ? (
|
||||
<pre className="max-h-[420px] overflow-auto rounded-md border bg-muted/40 p-3">
|
||||
{rawJson}
|
||||
</pre>
|
||||
) : (
|
||||
<p>
|
||||
Execute a análise para visualizar o JSON sugerido. Ele será usado para preencher automaticamente os
|
||||
campos do formulário.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user