changes shopping
This commit is contained in:
1 parent
62ab5b2608
commit
f4bc93bc8a
17 files changed
+2286
-1021
No files matched your search
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import { AdminItemCreatePage } from "@/components/admin/items/AdminItemCreatePage";
|
||||||
|
import { getSession } from "@/lib/redis/session";
|
||||||
|
import { getUserById } from "@/lib/prisma/queries/users";
|
||||||
|
import { searchAdminItems } from "@/lib/prisma/queries/items";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
return {
|
||||||
|
title: "Novo item | Painel Administrativo",
|
||||||
|
description: "Cadastre um novo item unificado no catálogo administrativo.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminNewItemPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionId = cookieStore.get("sessionId")?.value;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
redirect(`/${locale}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
redirect(`/${locale}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUser = await getUserById(session.userId);
|
||||||
|
if (!adminUser || adminUser.user_id !== 1) {
|
||||||
|
redirect(`/${locale}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialItems = await searchAdminItems({ page: 1, pageSize: 1 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
<main className="container mx-auto px-4 py-10">
|
||||||
|
<AdminItemCreatePage
|
||||||
|
locale={locale}
|
||||||
|
categories={initialItems.categories ?? []}
|
||||||
|
serverOptions={initialItems.servers ?? []}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -85,6 +85,7 @@ export default async function AdminPage({
|
|||||||
adminUser={adminUser}
|
adminUser={adminUser}
|
||||||
initialTiers={normalizedTiers}
|
initialTiers={normalizedTiers}
|
||||||
itemsData={initialItems}
|
itemsData={initialItems}
|
||||||
|
locale={locale}
|
||||||
initialTab={initialTab}
|
initialTab={initialTab}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+23
-45
@@ -1,21 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono, Nunito_Sans } from "next/font/google";
|
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages } from "next-intl/server";
|
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||||
|
import Script from "next/script";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { QueryProvider } from "@/lib/providers/query-provider";
|
import { QueryProvider } from "@/lib/providers/query-provider";
|
||||||
import "../globals.css";
|
|
||||||
|
|
||||||
const nunitoSans = Nunito_Sans({
|
|
||||||
variable: "--font-nunito-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["200", "300", "400", "500", "600", "700", "800", "900"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -98,6 +86,13 @@ export async function generateMetadata({
|
|||||||
images: ["/og-image.png"],
|
images: ["/og-image.png"],
|
||||||
creator: "@tibiatrade",
|
creator: "@tibiatrade",
|
||||||
},
|
},
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: "/favicon.ico", type: "image/x-icon" },
|
||||||
|
{ url: "/icon.svg", type: "image/svg+xml" },
|
||||||
|
],
|
||||||
|
apple: "/apple-touch-icon.png",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,40 +104,33 @@ export default async function LocaleLayout({
|
|||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
|
||||||
const baseUrl = "https://tibiatrade.evandro.dev.br";
|
const baseUrl = "https://tibiatrade.evandro.dev.br";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<QueryProvider>
|
||||||
<head>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<Script id="structured-data-organization" type="application/ld+json" strategy="afterInteractive">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
{JSON.stringify({
|
||||||
<script
|
|
||||||
type="application/ld+json"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: JSON.stringify({
|
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
name: "Tibia Trade",
|
name: "Tibia Trade",
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
logo: `${baseUrl}/logo.png`,
|
logo: `${baseUrl}/logo.png`,
|
||||||
description:
|
description: "Marketplace multi-servidor seguro para negociação de itens de Tibia",
|
||||||
"Marketplace multi-servidor seguro para negociação de itens de Tibia",
|
|
||||||
sameAs: ["https://discord.gg/tibiatrade"],
|
sameAs: ["https://discord.gg/tibiatrade"],
|
||||||
contactPoint: {
|
contactPoint: {
|
||||||
"@type": "ContactPoint",
|
"@type": "ContactPoint",
|
||||||
contactType: "Customer Support",
|
contactType: "Customer Support",
|
||||||
availableLanguage: ["Portuguese", "English"],
|
availableLanguage: ["Portuguese", "English"],
|
||||||
},
|
},
|
||||||
}),
|
})}
|
||||||
}}
|
</Script>
|
||||||
/>
|
<Script id="structured-data-website" type="application/ld+json" strategy="afterInteractive">
|
||||||
<script
|
{JSON.stringify({
|
||||||
type="application/ld+json"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: JSON.stringify({
|
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebSite",
|
"@type": "WebSite",
|
||||||
name: "Tibia Trade",
|
name: "Tibia Trade",
|
||||||
@@ -155,21 +143,11 @@ export default async function LocaleLayout({
|
|||||||
},
|
},
|
||||||
"query-input": "required name=search_term_string",
|
"query-input": "required name=search_term_string",
|
||||||
},
|
},
|
||||||
}),
|
})}
|
||||||
}}
|
</Script>
|
||||||
/>
|
<div data-locale={locale}>{children}</div>
|
||||||
</head>
|
|
||||||
<body
|
|
||||||
className={`${nunitoSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
<QueryProvider>
|
|
||||||
<NextIntlClientProvider messages={messages}>
|
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
|
||||||
{children}
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,15 @@ export async function POST(request: NextRequest) {
|
|||||||
scope === "server" && typeof body?.server === "string"
|
scope === "server" && typeof body?.server === "string"
|
||||||
? body.server.trim()
|
? body.server.trim()
|
||||||
: null;
|
: null;
|
||||||
|
const categories: string[] = Array.isArray(body?.categories)
|
||||||
|
? body.categories.filter((item: unknown) => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
const servers: string[] = Array.isArray(body?.servers)
|
||||||
|
? body.servers.filter((item: unknown) => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
const images: string[] = Array.isArray(body?.images)
|
||||||
|
? body.images.filter((item: unknown) => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
|
||||||
if (!name || !description) {
|
if (!name || !description) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -33,6 +42,19 @@ export async function POST(request: NextRequest) {
|
|||||||
description,
|
description,
|
||||||
scope,
|
scope,
|
||||||
server,
|
server,
|
||||||
|
categories,
|
||||||
|
servers,
|
||||||
|
images: images
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((dataUrl) => {
|
||||||
|
const [meta, base64Part] = dataUrl.split(",");
|
||||||
|
const mimeMatch = meta?.match(/data:(.*);base64/);
|
||||||
|
return {
|
||||||
|
mimeType: mimeMatch?.[1] ?? "image/jpeg",
|
||||||
|
base64Data: base64Part ?? meta,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((image) => image.base64Data),
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { Nunito_Sans, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const nunitoSans = Nunito_Sans({
|
||||||
|
variable: "--font-nunito-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["200", "300", "400", "500", "600", "700", "800", "900"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Tibia Trade",
|
||||||
|
description: "Marketplace seguro para negociação de itens e serviços de Tibia.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#09090b",
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="pt" suppressHydrationWarning>
|
||||||
|
<body className={`${nunitoSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ interface AdminPanelTabsProps {
|
|||||||
} | null;
|
} | null;
|
||||||
initialTiers: any[];
|
initialTiers: any[];
|
||||||
itemsData: AdminItemsResponse;
|
itemsData: AdminItemsResponse;
|
||||||
|
locale: string;
|
||||||
initialTab?: "overview" | "subscriptions" | "items";
|
initialTab?: "overview" | "subscriptions" | "items";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export function AdminPanelTabs({
|
|||||||
adminUser,
|
adminUser,
|
||||||
initialTiers,
|
initialTiers,
|
||||||
itemsData,
|
itemsData,
|
||||||
|
locale,
|
||||||
initialTab = "overview",
|
initialTab = "overview",
|
||||||
}: AdminPanelTabsProps) {
|
}: AdminPanelTabsProps) {
|
||||||
const [activeTab, setActiveTab] = useState<"overview" | "subscriptions" | "items">(
|
const [activeTab, setActiveTab] = useState<"overview" | "subscriptions" | "items">(
|
||||||
@@ -66,7 +68,7 @@ export function AdminPanelTabs({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="items">
|
<TabsContent value="items">
|
||||||
<AdminItemManager initialData={itemsData} />
|
<AdminItemManager initialData={itemsData} locale={locale} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -46,9 +47,11 @@ export interface AdminItemsResponse {
|
|||||||
|
|
||||||
interface AdminItemManagerProps {
|
interface AdminItemManagerProps {
|
||||||
initialData: AdminItemsResponse;
|
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 [data, setData] = useState<AdminItemsResponse>(initialData);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -61,7 +64,6 @@ export function AdminItemManager({ initialData }: AdminItemManagerProps) {
|
|||||||
const pageSize = initialData.pageSize ?? 50;
|
const pageSize = initialData.pageSize ?? 50;
|
||||||
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
|
||||||
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(null);
|
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
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" }));
|
return Array.from(set).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
|
||||||
}, [data.items]);
|
}, [data.items]);
|
||||||
|
|
||||||
const openCreateModal = () => {
|
|
||||||
setModalMode("create");
|
|
||||||
setSelectedItem(null);
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditModal = (item: AdminItemRecord) => {
|
const openEditModal = (item: AdminItemRecord) => {
|
||||||
setModalMode("edit");
|
|
||||||
setSelectedItem(item);
|
setSelectedItem(item);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -135,7 +130,7 @@ export function AdminItemManager({ initialData }: AdminItemManagerProps) {
|
|||||||
const handleSubmit = async (payload: ItemFormPayload) => {
|
const handleSubmit = async (payload: ItemFormPayload) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const method = modalMode === "edit" ? "PUT" : "POST";
|
const method = "PUT";
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
id: payload.id,
|
id: payload.id,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
@@ -194,7 +189,12 @@ export function AdminItemManager({ initialData }: AdminItemManagerProps) {
|
|||||||
<Button variant="outline" size="sm" onClick={fetchItems} disabled={loading}>
|
<Button variant="outline" size="sm" onClick={fetchItems} disabled={loading}>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" /> Atualizar
|
<RefreshCw className="mr-2 h-4 w-4" /> Atualizar
|
||||||
</Button>
|
</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
|
<Plus className="mr-2 h-4 w-4" /> Novo item
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -394,7 +394,7 @@ export function AdminItemManager({ initialData }: AdminItemManagerProps) {
|
|||||||
<ItemCreationModal
|
<ItemCreationModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onOpenChange={setModalOpen}
|
onOpenChange={setModalOpen}
|
||||||
mode={modalMode}
|
mode="edit"
|
||||||
item={selectedItem ?? undefined}
|
item={selectedItem ?? undefined}
|
||||||
categories={data.categories}
|
categories={data.categories}
|
||||||
serverOptions={data.servers}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,109 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
AdminItemForm,
|
||||||
DialogContent,
|
type AdminItemFormProps,
|
||||||
DialogDescription,
|
type ItemFormPayload,
|
||||||
DialogFooter,
|
} from "./AdminItemForm";
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
|
|
||||||
interface CategoryColumn {
|
|
||||||
name: string;
|
|
||||||
type: "TEXT" | "INTEGER" | "REAL" | "BOOLEAN";
|
|
||||||
notNull?: boolean;
|
|
||||||
editable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CategoryTemplate {
|
|
||||||
name: string;
|
|
||||||
columns: CategoryColumn[];
|
|
||||||
imagePath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE_COLUMN_NAMES = ["id", "nome", "image_url", "image_local_path"];
|
|
||||||
|
|
||||||
function generateTempId() {
|
|
||||||
return Math.random().toString(36).slice(2, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ItemFormPayload {
|
|
||||||
id?: number;
|
|
||||||
name: string;
|
|
||||||
category: string;
|
|
||||||
rarity?: string | null;
|
|
||||||
imageUrl?: string | null;
|
|
||||||
imageLocalPath?: string | null;
|
|
||||||
level?: number | null;
|
|
||||||
weight?: number | null;
|
|
||||||
slots?: number | null;
|
|
||||||
skill?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
servers: string[];
|
|
||||||
tags: string[];
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
fields?: Record<string, string | boolean | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ItemCreationModalProps {
|
interface ItemCreationModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
item?: {
|
item?: AdminItemFormProps["item"];
|
||||||
item_id: number;
|
|
||||||
item_name: string;
|
|
||||||
category: string;
|
|
||||||
rarity: string | null;
|
|
||||||
image_url: string | null;
|
|
||||||
image_local_path: string | null;
|
|
||||||
level: number | null;
|
|
||||||
weight: number | null;
|
|
||||||
slots: number | null;
|
|
||||||
skill: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
servers: string[];
|
|
||||||
tags: string[];
|
|
||||||
metadata: Record<string, any>;
|
|
||||||
};
|
|
||||||
categories: string[];
|
categories: string[];
|
||||||
serverOptions: string[];
|
serverOptions: string[];
|
||||||
onSubmit: (payload: ItemFormPayload) => Promise<void>;
|
onSubmit: (payload: ItemFormPayload) => Promise<void>;
|
||||||
submitting?: boolean;
|
submitting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalysisResult = {
|
|
||||||
table: string;
|
|
||||||
columns: Record<string, unknown>;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
function parseCsv(input: string): string[] {
|
|
||||||
return input
|
|
||||||
.split(",")
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeCsv(list: string[]): string {
|
|
||||||
if (!Array.isArray(list) || list.length === 0) return "";
|
|
||||||
return list.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseNumberInput(value: string): number | null {
|
|
||||||
if (!value) return null;
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ItemCreationModal({
|
export function ItemCreationModal({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -114,877 +27,17 @@ export function ItemCreationModal({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
submitting,
|
submitting,
|
||||||
}: ItemCreationModalProps) {
|
}: ItemCreationModalProps) {
|
||||||
const isEdit = mode === "edit";
|
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [category, setCategory] = useState("");
|
|
||||||
const [rarity, setRarity] = useState("");
|
|
||||||
const [imageUrl, setImageUrl] = useState("");
|
|
||||||
const [imageLocalPath, setImageLocalPath] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [level, setLevel] = useState("");
|
|
||||||
const [weight, setWeight] = useState("");
|
|
||||||
const [slots, setSlots] = useState("");
|
|
||||||
const [skill, setSkill] = useState("");
|
|
||||||
const [serversCsv, setServersCsv] = useState("");
|
|
||||||
const [tagsCsv, setTagsCsv] = useState("");
|
|
||||||
|
|
||||||
const [analysis, setAnalysis] = useState<AnalysisResult>(null);
|
|
||||||
const [analysisLoading, setAnalysisLoading] = useState(false);
|
|
||||||
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
|
||||||
const [submitStatus, setSubmitStatus] = useState<string | null>(null);
|
|
||||||
const allowAiTools = !isEdit;
|
|
||||||
|
|
||||||
const [categoryTemplates, setCategoryTemplates] = useState<CategoryTemplate[]>([]);
|
|
||||||
const [categoryLoading, setCategoryLoading] = useState(false);
|
|
||||||
const [categoryError, setCategoryError] = useState<string | null>(null);
|
|
||||||
const [dynamicValues, setDynamicValues] = useState<Record<string, string | boolean | null>>({});
|
|
||||||
const [showNewCategoryModal, setShowNewCategoryModal] = useState(false);
|
|
||||||
const [showNewFieldModal, setShowNewFieldModal] = useState(false);
|
|
||||||
const [newCategoryName, setNewCategoryName] = useState("");
|
|
||||||
const [newCategoryFields, setNewCategoryFields] = useState<Array<{ id: string; name: string; type: CategoryColumn["type"]; notNull: boolean }>>([]);
|
|
||||||
const [newCategoryError, setNewCategoryError] = useState<string | null>(null);
|
|
||||||
const [newFieldName, setNewFieldName] = useState("");
|
|
||||||
const [newFieldType, setNewFieldType] = useState<CategoryColumn["type"]>("TEXT");
|
|
||||||
const [newFieldNotNull, setNewFieldNotNull] = useState(false);
|
|
||||||
const [newFieldError, setNewFieldError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchCategories = useCallback(async () => {
|
|
||||||
setCategoryLoading(true);
|
|
||||||
setCategoryError(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/categories", { credentials: "include" });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Falha ao carregar categorias");
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
setCategoryTemplates(Array.isArray(data?.categories) ? data.categories : []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
setCategoryError(error instanceof Error ? error.message : "Erro ao carregar categorias");
|
|
||||||
} finally {
|
|
||||||
setCategoryLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const dynamicInitializedRef = useRef(false);
|
|
||||||
const lastCategoryRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
fetchCategories();
|
|
||||||
}, [open, fetchCategories]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setSubmitStatus(null);
|
|
||||||
setAnalysis(null);
|
|
||||||
setAnalysisError(null);
|
|
||||||
dynamicInitializedRef.current = false;
|
|
||||||
lastCategoryRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEdit && item) {
|
|
||||||
setName(item.item_name);
|
|
||||||
setCategory(item.category.toLowerCase());
|
|
||||||
setRarity(item.rarity ?? "");
|
|
||||||
setImageUrl(item.image_url ?? "");
|
|
||||||
setImageLocalPath(item.image_local_path ?? "");
|
|
||||||
setDescription(item.description ?? "");
|
|
||||||
setLevel(item.level !== null && item.level !== undefined ? String(item.level) : "");
|
|
||||||
setWeight(item.weight !== null && item.weight !== undefined ? String(item.weight) : "");
|
|
||||||
setSlots(item.slots !== null && item.slots !== undefined ? String(item.slots) : "");
|
|
||||||
setSkill(item.skill ?? "");
|
|
||||||
setServersCsv(serializeCsv(item.servers));
|
|
||||||
setTagsCsv(serializeCsv(item.tags));
|
|
||||||
setSubmitStatus(null);
|
|
||||||
} else {
|
|
||||||
const defaultCategory = categoryTemplates[0]?.name ?? categories[0] ?? "";
|
|
||||||
setName("");
|
|
||||||
setCategory(defaultCategory.toLowerCase());
|
|
||||||
setRarity("");
|
|
||||||
setImageUrl("");
|
|
||||||
setImageLocalPath("");
|
|
||||||
setDescription("");
|
|
||||||
setLevel("");
|
|
||||||
setWeight("");
|
|
||||||
setSlots("");
|
|
||||||
setSkill("");
|
|
||||||
setServersCsv("");
|
|
||||||
setTagsCsv("");
|
|
||||||
setSubmitStatus(null);
|
|
||||||
}
|
|
||||||
}, [open, isEdit, item, categories, categoryTemplates]);
|
|
||||||
|
|
||||||
const getTemplateForCategory = useCallback(
|
|
||||||
(categoryName: string | null | undefined) =>
|
|
||||||
categoryTemplates.find((template) => template.name === (categoryName ?? "").toLowerCase()),
|
|
||||||
[categoryTemplates]
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildDynamicInitialValues = useCallback(
|
|
||||||
(template: CategoryTemplate | undefined, metadata?: Record<string, unknown>) => {
|
|
||||||
if (!template) return {} as Record<string, string | boolean | null>;
|
|
||||||
const values: Record<string, string | boolean | null> = {};
|
|
||||||
template.columns
|
|
||||||
.filter((column) => !BASE_COLUMN_NAMES.includes(column.name) && column.editable !== false)
|
|
||||||
.forEach((column) => {
|
|
||||||
const sourceValue = metadata ? metadata[column.name] : undefined;
|
|
||||||
if (sourceValue === undefined || sourceValue === null) {
|
|
||||||
values[column.name] = column.type === "BOOLEAN" ? false : "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (column.type === "BOOLEAN") {
|
|
||||||
values[column.name] = Boolean(Number(sourceValue));
|
|
||||||
} else if (column.type === "INTEGER" || column.type === "REAL") {
|
|
||||||
values[column.name] = String(sourceValue);
|
|
||||||
} else {
|
|
||||||
values[column.name] = String(sourceValue);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return values;
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
|
||||||
if (!allowAiTools) return;
|
|
||||||
if (!name.trim() || !description.trim()) {
|
|
||||||
setAnalysisError("Informe nome e descrição para gerar o JSON.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAnalysisError(null);
|
|
||||||
setAnalysis(null);
|
|
||||||
setAnalysisLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/admin/items/analyze", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: name.trim(),
|
|
||||||
description: description.trim(),
|
|
||||||
scope: serverOptions.length > 0 ? "server" : "global",
|
|
||||||
server: serverOptions[0] ?? null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Falha ao comunicar com o serviço de IA.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
setAnalysis(data.structure ?? null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
setAnalysisError(
|
|
||||||
error instanceof Error ? error.message : "Erro desconhecido ao gerar JSON."
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setAnalysisLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!name.trim()) {
|
|
||||||
setSubmitStatus("Informe o nome do item.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!category.trim()) {
|
|
||||||
setSubmitStatus("Informe a categoria do item.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitStatus(null);
|
|
||||||
try {
|
|
||||||
const metadataPayload: Record<string, unknown> = {};
|
|
||||||
if (allowAiTools && analysis) {
|
|
||||||
metadataPayload.aiStructure = analysis;
|
|
||||||
}
|
|
||||||
for (const [key, value] of Object.entries(dynamicValues)) {
|
|
||||||
metadataPayload[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
await onSubmit({
|
|
||||||
id: item?.item_id,
|
|
||||||
name: name.trim(),
|
|
||||||
category: category.trim().toLowerCase(),
|
|
||||||
rarity: rarity.trim() || null,
|
|
||||||
imageUrl: imageUrl.trim() || null,
|
|
||||||
imageLocalPath: imageLocalPath.trim() || null,
|
|
||||||
description: description.trim() || null,
|
|
||||||
level: parseNumberInput(level),
|
|
||||||
weight: parseNumberInput(weight),
|
|
||||||
slots: parseNumberInput(slots),
|
|
||||||
skill: skill.trim() || null,
|
|
||||||
servers: parseCsv(serversCsv),
|
|
||||||
tags: parseCsv(tagsCsv),
|
|
||||||
metadata: metadataPayload,
|
|
||||||
fields: dynamicValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
setSubmitStatus(isEdit ? "Alterações salvas com sucesso." : "Item criado com sucesso.");
|
|
||||||
|
|
||||||
if (!isEdit) {
|
|
||||||
setName("");
|
|
||||||
setCategory(categories[0] ?? "");
|
|
||||||
setRarity("");
|
|
||||||
setImageUrl("");
|
|
||||||
setImageLocalPath("");
|
|
||||||
setDescription("");
|
|
||||||
setLevel("");
|
|
||||||
setWeight("");
|
|
||||||
setSlots("");
|
|
||||||
setSkill("");
|
|
||||||
setServersCsv("");
|
|
||||||
setTagsCsv("");
|
|
||||||
setAnalysis(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
setSubmitStatus(
|
|
||||||
error instanceof Error ? error.message : "Erro ao salvar. Tente novamente."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDynamicValueChange = useCallback((column: CategoryColumn, value: string | boolean | null) => {
|
|
||||||
setDynamicValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[column.name]: value,
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCategoryChange = useCallback((value: string) => {
|
|
||||||
dynamicInitializedRef.current = false;
|
|
||||||
lastCategoryRef.current = null;
|
|
||||||
setCategory(value.toLowerCase());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddCategoryField = useCallback(() => {
|
|
||||||
setNewCategoryFields((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ id: generateTempId(), name: "", type: "TEXT", notNull: false },
|
|
||||||
]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleUpdateCategoryField = useCallback((id: string, key: "name" | "type" | "notNull", value: string | boolean) => {
|
|
||||||
setNewCategoryFields((prev) =>
|
|
||||||
prev.map((field) =>
|
|
||||||
field.id === id
|
|
||||||
? {
|
|
||||||
...field,
|
|
||||||
[key]: key === "notNull" ? Boolean(value) : value,
|
|
||||||
}
|
|
||||||
: field
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRemoveCategoryField = useCallback((id: string) => {
|
|
||||||
setNewCategoryFields((prev) => prev.filter((field) => field.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreateCategory = useCallback(async () => {
|
|
||||||
if (!newCategoryName.trim()) {
|
|
||||||
setNewCategoryError("Informe o nome da categoria");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setNewCategoryError(null);
|
|
||||||
const payload = {
|
|
||||||
name: newCategoryName.trim().toLowerCase(),
|
|
||||||
columns: newCategoryFields
|
|
||||||
.filter((field) => field.name.trim())
|
|
||||||
.map((field) => ({
|
|
||||||
name: field.name.trim().toLowerCase(),
|
|
||||||
type: field.type,
|
|
||||||
notNull: field.notNull,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch("/api/admin/categories", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json().catch(() => null);
|
|
||||||
throw new Error(error?.error || "Falha ao criar categoria");
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetchCategories();
|
|
||||||
handleCategoryChange(payload.name);
|
|
||||||
setShowNewCategoryModal(false);
|
|
||||||
setNewCategoryFields([]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
setNewCategoryError(error instanceof Error ? error.message : "Erro ao criar categoria");
|
|
||||||
}
|
|
||||||
}, [newCategoryName, newCategoryFields, fetchCategories, handleCategoryChange]);
|
|
||||||
|
|
||||||
const handleAddFieldToCategory = useCallback(async () => {
|
|
||||||
if (!category) {
|
|
||||||
setNewFieldError("Selecione uma categoria");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!newFieldName.trim()) {
|
|
||||||
setNewFieldError("Informe o nome do campo");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setNewFieldError(null);
|
|
||||||
const payload = {
|
|
||||||
name: newFieldName.trim().toLowerCase(),
|
|
||||||
type: newFieldType,
|
|
||||||
notNull: newFieldNotNull,
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/categories/${category}/fields`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json().catch(() => null);
|
|
||||||
throw new Error(error?.error || "Falha ao adicionar campo");
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetchCategories();
|
|
||||||
dynamicInitializedRef.current = false;
|
|
||||||
lastCategoryRef.current = null;
|
|
||||||
setShowNewFieldModal(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
setNewFieldError(error instanceof Error ? error.message : "Erro ao adicionar campo");
|
|
||||||
}
|
|
||||||
}, [category, newFieldName, newFieldType, newFieldNotNull, fetchCategories]);
|
|
||||||
|
|
||||||
const serverSuggestions = useMemo(() => {
|
|
||||||
if (serverOptions.length === 0) return "";
|
|
||||||
return serverOptions.join(", ");
|
|
||||||
}, [serverOptions]);
|
|
||||||
|
|
||||||
const activeTemplate = useMemo(() => getTemplateForCategory(category), [category, getTemplateForCategory]);
|
|
||||||
|
|
||||||
const dynamicColumns = useMemo(
|
|
||||||
() =>
|
|
||||||
activeTemplate?.columns.filter(
|
|
||||||
(column) => !BASE_COLUMN_NAMES.includes(column.name) && column.editable !== false,
|
|
||||||
) ?? [],
|
|
||||||
[activeTemplate],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const effectiveCategory = (category || item?.category || categoryTemplates[0]?.name || "").toLowerCase();
|
|
||||||
if (!effectiveCategory) return;
|
|
||||||
|
|
||||||
const template = getTemplateForCategory(effectiveCategory);
|
|
||||||
if (!template) return;
|
|
||||||
|
|
||||||
if (!category) {
|
|
||||||
setCategory(template.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadataSource = isEdit ? (item?.metadata as Record<string, unknown> | undefined) : undefined;
|
|
||||||
|
|
||||||
if (!dynamicInitializedRef.current || lastCategoryRef.current !== template.name) {
|
|
||||||
setDynamicValues(buildDynamicInitialValues(template, metadataSource));
|
|
||||||
dynamicInitializedRef.current = true;
|
|
||||||
lastCategoryRef.current = template.name;
|
|
||||||
}
|
|
||||||
}, [open, category, categoryTemplates, getTemplateForCategory, buildDynamicInitialValues, isEdit, item]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AdminItemForm
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
mode={mode}
|
||||||
<DialogContent className="max-w-4xl w-full h-[90vh] overflow-hidden">
|
item={item}
|
||||||
<div className="flex h-full flex-col">
|
categories={categories}
|
||||||
<DialogHeader className="shrink-0">
|
serverOptions={serverOptions}
|
||||||
<DialogTitle>{isEdit ? "Editar item" : "Novo item"}</DialogTitle>
|
onSubmit={onSubmit}
|
||||||
<DialogDescription>
|
submitting={submitting}
|
||||||
Preencha os dados do item unificado. Utilize a IA para gerar um JSON de referência, se desejar.
|
onCancel={() => onOpenChange(false)}
|
||||||
</DialogDescription>
|
isActive={open}
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto pr-2">
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1.15fr,0.85fr]">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="item-name">Nome</Label>
|
|
||||||
<Input
|
|
||||||
id="item-name"
|
|
||||||
placeholder="Ex.: Final Fate"
|
|
||||||
value={name}
|
|
||||||
onChange={(event) => setName(event.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Categoria</Label>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Select
|
|
||||||
value={category}
|
|
||||||
onValueChange={handleCategoryChange}
|
|
||||||
disabled={categoryLoading || isEdit}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="min-w-[200px]">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={categoryLoading ? "Carregando..." : "Selecione a categoria"}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{categoryTemplates.map((template) => (
|
|
||||||
<SelectItem key={template.name} value={template.name}>
|
|
||||||
{template.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setShowNewCategoryModal(true);
|
|
||||||
setNewCategoryError(null);
|
|
||||||
setNewCategoryName("");
|
|
||||||
setNewCategoryFields([]);
|
|
||||||
}}
|
|
||||||
disabled={categoryLoading}
|
|
||||||
>
|
|
||||||
Nova categoria
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setShowNewFieldModal(true);
|
|
||||||
setNewFieldError(null);
|
|
||||||
setNewFieldName("");
|
|
||||||
setNewFieldType("TEXT");
|
|
||||||
setNewFieldNotNull(false);
|
|
||||||
}}
|
|
||||||
disabled={!category || categoryLoading}
|
|
||||||
>
|
|
||||||
+ campo
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{categoryError && (
|
|
||||||
<p className="text-xs text-destructive">{categoryError}</p>
|
|
||||||
)}
|
|
||||||
{isEdit && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
A categoria existente do item não pode ser alterada neste fluxo.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="item-rarity">Raridade</Label>
|
|
||||||
<Input
|
|
||||||
id="item-rarity"
|
|
||||||
placeholder="Ex.: legendary"
|
|
||||||
value={rarity}
|
|
||||||
onChange={(event) => setRarity(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="item-image-url">Imagem (URL)</Label>
|
|
||||||
<Input
|
|
||||||
id="item-image-url"
|
|
||||||
placeholder="https://..."
|
|
||||||
value={imageUrl}
|
|
||||||
onChange={(event) => setImageUrl(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="item-image-local">Imagem local (caminho)</Label>
|
|
||||||
<Input
|
|
||||||
id="item-image-local"
|
|
||||||
placeholder="Opcional"
|
|
||||||
value={imageLocalPath}
|
|
||||||
onChange={(event) => setImageLocalPath(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="item-level">Level requerido</Label>
|
|
||||||
<Input
|
|
||||||
id="item-level"
|
|
||||||
placeholder="Ex.: 250"
|
|
||||||
value={level}
|
|
||||||
onChange={(event) => setLevel(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="item-weight">Peso</Label>
|
|
||||||
<Input
|
|
||||||
id="item-weight"
|
|
||||||
placeholder="Ex.: 45"
|
|
||||||
value={weight}
|
|
||||||
onChange={(event) => setWeight(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="item-slots">Slots</Label>
|
|
||||||
<Input
|
|
||||||
id="item-slots"
|
|
||||||
placeholder="Ex.: 2"
|
|
||||||
value={slots}
|
|
||||||
onChange={(event) => setSlots(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="item-skill">Skill requerido</Label>
|
|
||||||
<Input
|
|
||||||
id="item-skill"
|
|
||||||
placeholder="Ex.: Sword 115"
|
|
||||||
value={skill}
|
|
||||||
onChange={(event) => setSkill(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dynamicColumns.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm font-semibold">Campos específicos da categoria</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Defina valores adicionais conforme o modelo da categoria selecionada.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
{dynamicColumns.map((column) => {
|
|
||||||
const value = dynamicValues[column.name];
|
|
||||||
if (column.type === "BOOLEAN") {
|
|
||||||
return (
|
|
||||||
<div key={column.name} className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`dynamic-${column.name}`}
|
|
||||||
checked={Boolean(value)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handleDynamicValueChange(column, checked === true)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={`dynamic-${column.name}`} className="capitalize">
|
|
||||||
{column.name.replace(/_/g, " ")}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputType = column.type === "TEXT" ? "text" : "number";
|
|
||||||
const step = column.type === "REAL" ? "0.01" : "1";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={column.name} className="space-y-2">
|
|
||||||
<Label htmlFor={`dynamic-${column.name}`} className="capitalize">
|
|
||||||
{column.name.replace(/_/g, " ")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`dynamic-${column.name}`}
|
|
||||||
type={inputType}
|
|
||||||
step={column.type === "TEXT" ? undefined : step}
|
|
||||||
value={
|
|
||||||
typeof value === "string"
|
|
||||||
? value
|
|
||||||
: value === null || value === undefined
|
|
||||||
? ""
|
|
||||||
: String(value)
|
|
||||||
}
|
|
||||||
onChange={(event) =>
|
|
||||||
handleDynamicValueChange(column, event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="item-servers">Servidores (separados por vírgula)</Label>
|
|
||||||
<Input
|
|
||||||
id="item-servers"
|
|
||||||
placeholder="Ex.: Ferobra, Boneka"
|
|
||||||
value={serversCsv}
|
|
||||||
onChange={(event) => setServersCsv(event.target.value)}
|
|
||||||
/>
|
|
||||||
{serverSuggestions && (
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
Sugestões cadastradas: {serverSuggestions}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="item-tags">Tags adicionais</Label>
|
|
||||||
<Input
|
|
||||||
id="item-tags"
|
|
||||||
placeholder="Ex.: endgame, two-handed"
|
|
||||||
value={tagsCsv}
|
|
||||||
onChange={(event) => setTagsCsv(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="item-description">Descrição detalhada</Label>
|
|
||||||
<Textarea
|
|
||||||
id="item-description"
|
|
||||||
placeholder="Cole aqui a descrição oficial do item..."
|
|
||||||
rows={6}
|
|
||||||
value={description}
|
|
||||||
onChange={(event) => setDescription(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 pt-2">
|
|
||||||
{allowAiTools && (
|
|
||||||
<Button type="button" variant="outline" onClick={handleAnalyze} disabled={analysisLoading}>
|
|
||||||
{analysisLoading ? "Analisando..." : "Analisar com IA"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" onClick={handleSubmit} disabled={submitting}>
|
|
||||||
{submitting ? "Salvando..." : isEdit ? "Salvar alterações" : "Criar item"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{submitStatus && <p className="text-xs text-muted-foreground">{submitStatus}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{allowAiTools && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm">Resultado da IA</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
O serviço Gemini deve retornar exclusivamente um JSON válido que descreve a tabela destino e os campos correspondentes.
|
|
||||||
</p>
|
|
||||||
{analysisError && (
|
|
||||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
|
||||||
{analysisError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="max-h-64 overflow-auto rounded-md border bg-muted/40 p-3 text-[11px]">
|
|
||||||
<pre className="whitespace-pre-wrap">
|
|
||||||
{analysis ? JSON.stringify(analysis, null, 2) : "// Aguarde a análise para visualizar o JSON"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm">Resumo</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2 text-xs text-muted-foreground">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Badge variant="outline" className="uppercase">
|
|
||||||
{category || "Categoria"}
|
|
||||||
</Badge>
|
|
||||||
{rarity && (
|
|
||||||
<Badge variant="outline" className="uppercase">
|
|
||||||
{rarity}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
Utilize os campos para definir servidores específicos. Todos os itens sem servidores marcados serão considerados globais.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 pt-4">
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
|
||||||
Fechar
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={showNewCategoryModal} onOpenChange={setShowNewCategoryModal}>
|
|
||||||
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Nova categoria</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Defina nome e campos adicionais para criar uma nova categoria de itens.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="new-category-name">Nome da categoria</Label>
|
|
||||||
<Input
|
|
||||||
id="new-category-name"
|
|
||||||
placeholder="Ex.: vips"
|
|
||||||
value={newCategoryName}
|
|
||||||
onChange={(event) => setNewCategoryName(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label>Campos iniciais (opcional)</Label>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAddCategoryField}>
|
|
||||||
Adicionar campo
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{newCategoryFields.length === 0 ? (
|
|
||||||
<p className="text-xs text-muted-foreground">Nenhum campo adicional configurado.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{newCategoryFields.map((field) => (
|
|
||||||
<div key={field.id} className="grid gap-2 rounded-md border p-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor={`field-name-${field.id}`}>Nome</Label>
|
|
||||||
<Input
|
|
||||||
id={`field-name-${field.id}`}
|
|
||||||
value={field.name}
|
|
||||||
onChange={(event) =>
|
|
||||||
handleUpdateCategoryField(field.id, "name", event.target.value)
|
|
||||||
}
|
|
||||||
placeholder="Ex.: bonus_magico"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Tipo</Label>
|
|
||||||
<Select
|
|
||||||
value={field.type}
|
|
||||||
onValueChange={(value) => handleUpdateCategoryField(field.id, "type", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="TEXT">Texto</SelectItem>
|
|
||||||
<SelectItem value="INTEGER">Inteiro</SelectItem>
|
|
||||||
<SelectItem value="REAL">Decimal</SelectItem>
|
|
||||||
<SelectItem value="BOOLEAN">Booleano</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`field-required-${field.id}`}
|
|
||||||
checked={field.notNull}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handleUpdateCategoryField(field.id, "notNull", checked === true)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={`field-required-${field.id}`}>Obrigatório</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleRemoveCategoryField(field.id)}
|
|
||||||
>
|
|
||||||
Remover
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{newCategoryError && <p className="text-xs text-destructive">{newCategoryError}</p>}
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setShowNewCategoryModal(false)}>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreateCategory}>Criar categoria</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={showNewFieldModal} onOpenChange={setShowNewFieldModal}>
|
|
||||||
<DialogContent className="max-w-md max-h-[70vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Novo campo para categoria</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Adicione um novo campo à categoria selecionada. A alteração é permanente no schema.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Categoria</Label>
|
|
||||||
<Input value={category} readOnly className="bg-muted/40" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="new-field-name">Nome do campo</Label>
|
|
||||||
<Input
|
|
||||||
id="new-field-name"
|
|
||||||
value={newFieldName}
|
|
||||||
onChange={(event) => setNewFieldName(event.target.value)}
|
|
||||||
placeholder="Ex.: bonus_magico"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Tipo</Label>
|
|
||||||
<Select value={newFieldType} onValueChange={(value) => setNewFieldType(value as CategoryColumn["type"])}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="TEXT">Texto</SelectItem>
|
|
||||||
<SelectItem value="INTEGER">Inteiro</SelectItem>
|
|
||||||
<SelectItem value="REAL">Decimal</SelectItem>
|
|
||||||
<SelectItem value="BOOLEAN">Booleano</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="new-field-required"
|
|
||||||
checked={newFieldNotNull}
|
|
||||||
onCheckedChange={(checked) => setNewFieldNotNull(checked === true)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="new-field-required">Obrigatório</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{newFieldError && <p className="text-xs text-destructive">{newFieldError}</p>}
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setShowNewFieldModal(false)}>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleAddFieldToCategory}>Adicionar campo</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { secureLogger } from "@/lib/secure-logger";
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import { buildGeminiParts, buildItemPrompt, InlineImageData } from "@/lib/ai/item-prompt";
|
||||||
|
|
||||||
interface GenerateItemStructureInput {
|
interface GenerateItemStructureInput {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
scope: "global" | "server";
|
scope: "global" | "server";
|
||||||
server?: string | null;
|
server?: string | null;
|
||||||
|
categories: string[];
|
||||||
|
servers: string[];
|
||||||
|
images?: InlineImageData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GeminiResponse {
|
interface GeminiResponse {
|
||||||
@@ -51,7 +55,21 @@ export async function generateItemStructure(
|
|||||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=" +
|
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=" +
|
||||||
apiKey;
|
apiKey;
|
||||||
|
|
||||||
const prompt = buildPrompt(input);
|
const prompt = await buildItemPrompt(
|
||||||
|
{
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
scope: input.scope,
|
||||||
|
server: input.server,
|
||||||
|
categories: input.categories,
|
||||||
|
servers: input.servers,
|
||||||
|
},
|
||||||
|
input.images && input.images.length > 0
|
||||||
|
? "Duas imagens de contexto foram fornecidas como partes inline (após o prompt). Utilize-as para compreender atributos visuais."
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parts = await buildGeminiParts(prompt, input.images);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -63,7 +81,7 @@ export async function generateItemStructure(
|
|||||||
contents: [
|
contents: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
parts: [{ text: prompt }],
|
parts,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { promises as fs } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
interface PromptContext {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
scope: "global" | "server";
|
||||||
|
server?: string | null;
|
||||||
|
categories: string[];
|
||||||
|
servers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let schemaSummaryCache: string | null = null;
|
||||||
|
|
||||||
|
async function loadSchemaSummary(): Promise<string> {
|
||||||
|
if (schemaSummaryCache) {
|
||||||
|
return schemaSummaryCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaPath = path.join(process.cwd(), "prisma", "schema.prisma");
|
||||||
|
let file: string;
|
||||||
|
try {
|
||||||
|
file = await fs.readFile(schemaPath, "utf-8");
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[item-prompt] Não foi possível ler schema.prisma:", error);
|
||||||
|
schemaSummaryCache = "Schema indisponível.";
|
||||||
|
return schemaSummaryCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelRegex = /model\s+(\w+)\s*{([^}]*)}/g;
|
||||||
|
const blocks: string[] = [];
|
||||||
|
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = modelRegex.exec(file)) !== null) {
|
||||||
|
const modelName = match[1];
|
||||||
|
const body = match[2]
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line && !line.startsWith("//") && !line.startsWith("@@"))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
blocks.push(`Model ${modelName} {\n${body}\n}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
schemaSummaryCache = blocks.join("\n\n");
|
||||||
|
return schemaSummaryCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORM_FIELD_SUMMARY = `
|
||||||
|
Front-end fields (AdminItemForm):
|
||||||
|
- name: string
|
||||||
|
- category: string (utiliza categorias pré-carregadas)
|
||||||
|
- rarity: string opcional
|
||||||
|
- imageUrl / imageLocalPath: caminhos de imagem
|
||||||
|
- level, weight, slots: numéricos opcionais
|
||||||
|
- skill: string opcional
|
||||||
|
- servers: lista de servidores (separados por vírgula)
|
||||||
|
- tags: lista de tags (texto)
|
||||||
|
- description: texto longo
|
||||||
|
- dynamic metadata: campos variáveis definidos por categoria (servidores, atributos, etc)
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
export async function buildItemPrompt(context: PromptContext, extraInstructions?: string) {
|
||||||
|
const schemaSummary = await loadSchemaSummary();
|
||||||
|
|
||||||
|
const categoriesText =
|
||||||
|
context.categories.length > 0
|
||||||
|
? context.categories.map((category) => `- ${category}`).join("\n")
|
||||||
|
: "- Nenhuma categoria cadastrada";
|
||||||
|
|
||||||
|
const serversText =
|
||||||
|
context.servers.length > 0
|
||||||
|
? context.servers.map((server) => `- ${server}`).join("\n")
|
||||||
|
: "- Nenhum servidor cadastrado";
|
||||||
|
|
||||||
|
return `
|
||||||
|
Você é um assistente que prepara payloads JSON para cadastro de itens no banco de dados do Tibia Trade.
|
||||||
|
|
||||||
|
INSTRUÇÕES IMPORTANTES:
|
||||||
|
- Responda SOMENTE com JSON válido. Nenhum texto fora do JSON.
|
||||||
|
- Se não tiver certeza, retorne null nos campos desconhecidos.
|
||||||
|
- Utilize ao máximo a estrutura de tabelas e campos informada abaixo.
|
||||||
|
|
||||||
|
Contexto fornecido pelo usuário:
|
||||||
|
- Nome: ${context.name}
|
||||||
|
- Escopo: ${context.scope === "server" ? `servidor específico (${context.server ?? "não informado"})` : "global"}
|
||||||
|
- Descrição original:
|
||||||
|
"""
|
||||||
|
${context.description}
|
||||||
|
"""
|
||||||
|
- Categorias disponíveis no painel:
|
||||||
|
${categoriesText}
|
||||||
|
- Servidores disponíveis no painel:
|
||||||
|
${serversText}
|
||||||
|
|
||||||
|
Schema do banco (Prisma):
|
||||||
|
${schemaSummary}
|
||||||
|
|
||||||
|
Campos principais do formulário no front:
|
||||||
|
${FORM_FIELD_SUMMARY}
|
||||||
|
|
||||||
|
Formato obrigatório da resposta:
|
||||||
|
{
|
||||||
|
"table": "nome_da_tabela",
|
||||||
|
"columns": {
|
||||||
|
"name": "...",
|
||||||
|
"description": "...",
|
||||||
|
"rarity": "...",
|
||||||
|
"metadata": {...},
|
||||||
|
"outros_campos": ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Consistência:
|
||||||
|
- Escolha a tabela coerente com o escopo. Para global, use "items" ou equivalente.
|
||||||
|
- Inclua sempre os campos básicos: name, description.
|
||||||
|
- Aplique outros campos conforme a descrição (ex.: atributos, requisitos, bônus).
|
||||||
|
- Se houver informações que encaixem em metadados dinâmicos, use um objeto "metadata".
|
||||||
|
|
||||||
|
${extraInstructions ?? ""}
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InlineImageData {
|
||||||
|
mimeType: string;
|
||||||
|
base64Data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildGeminiParts(prompt: string, images: InlineImageData[] = []) {
|
||||||
|
const parts: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }> = [
|
||||||
|
{ text: prompt },
|
||||||
|
];
|
||||||
|
|
||||||
|
images.slice(0, 2).forEach((image) => {
|
||||||
|
parts.push({
|
||||||
|
inlineData: {
|
||||||
|
mimeType: image.mimeType,
|
||||||
|
data: image.base64Data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export interface ResizeImageOptions {
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
quality?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: Required<ResizeImageOptions> = {
|
||||||
|
maxWidth: 1280,
|
||||||
|
maxHeight: 1280,
|
||||||
|
quality: 0.9,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redimensiona uma imagem no client preservando boa qualidade.
|
||||||
|
* Retorna uma dataURL compactada para envio à IA.
|
||||||
|
*/
|
||||||
|
export async function resizeImageToDataURL(
|
||||||
|
file: File,
|
||||||
|
options: ResizeImageOptions = {},
|
||||||
|
): Promise<{ dataUrl: string; width: number; height: number }> {
|
||||||
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
|
||||||
|
let { width, height } = bitmap;
|
||||||
|
const ratio = Math.min(opts.maxWidth / width, opts.maxHeight / height, 1);
|
||||||
|
|
||||||
|
if (ratio < 1) {
|
||||||
|
width = Math.round(width * ratio);
|
||||||
|
height = Math.round(height * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("Contexto 2D não disponível para redimensionamento.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataUrl: canvas.toDataURL(opts.mimeType, opts.quality),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,5 +2,10 @@ packages:
|
|||||||
- .
|
- .
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
|
- '@prisma/client'
|
||||||
|
- '@prisma/engines'
|
||||||
|
- bcrypt
|
||||||
- better-sqlite3
|
- better-sqlite3
|
||||||
|
- esbuild
|
||||||
|
- prisma
|
||||||
- sharp
|
- sharp
|
||||||
Reference in new issue
Block a user