changes shopping

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

No files matched your search

+56
View File
@@ -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>
);
}
+1
View File
@@ -85,6 +85,7 @@ export default async function AdminPage({
adminUser={adminUser}
initialTiers={normalizedTiers}
itemsData={initialItems}
locale={locale}
initialTab={initialTab}
/>
</main>
+26 -48
View File
@@ -1,21 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Nunito_Sans } from "next/font/google";
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 { 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({
params,
@@ -98,6 +86,13 @@ export async function generateMetadata({
images: ["/og-image.png"],
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 }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const messages = await getMessages();
const baseUrl = "https://tibiatrade.evandro.dev.br";
return (
<html lang={locale} suppressHydrationWarning>
<head>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
<QueryProvider>
<NextIntlClientProvider messages={messages}>
<ThemeProvider attribute="class" defaultTheme="dark">
<Script id="structured-data-organization" type="application/ld+json" strategy="afterInteractive">
{JSON.stringify({
"@context": "https://schema.org",
"@type": "Organization",
name: "Tibia Trade",
url: baseUrl,
logo: `${baseUrl}/logo.png`,
description:
"Marketplace multi-servidor seguro para negociação de itens de Tibia",
description: "Marketplace multi-servidor seguro para negociação de itens de Tibia",
sameAs: ["https://discord.gg/tibiatrade"],
contactPoint: {
"@type": "ContactPoint",
contactType: "Customer Support",
availableLanguage: ["Portuguese", "English"],
},
}),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
})}
</Script>
<Script id="structured-data-website" type="application/ld+json" strategy="afterInteractive">
{JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
name: "Tibia Trade",
@@ -155,21 +143,11 @@ export default async function LocaleLayout({
},
"query-input": "required name=search_term_string",
},
}),
}}
/>
</head>
<body
className={`${nunitoSans.variable} ${geistMono.variable} antialiased`}
>
<QueryProvider>
<NextIntlClientProvider messages={messages}>
<ThemeProvider attribute="class" defaultTheme="dark">
{children}
</ThemeProvider>
</NextIntlClientProvider>
</QueryProvider>
</body>
</html>
})}
</Script>
<div data-locale={locale}>{children}</div>
</ThemeProvider>
</NextIntlClientProvider>
</QueryProvider>
);
}
+22
View File
@@ -17,6 +17,15 @@ export async function POST(request: NextRequest) {
scope === "server" && typeof body?.server === "string"
? body.server.trim()
: 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) {
return NextResponse.json(
@@ -33,6 +42,19 @@ export async function POST(request: NextRequest) {
description,
scope,
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(
+36
View File
@@ -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>
);
}
+3 -1
View File
@@ -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
+12 -12
View File
@@ -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}
+242
View File
@@ -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
+224
View File
@@ -0,0 +1,224 @@
"use client";
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
import { Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { resizeImageToDataURL } from "@/lib/image/resize";
import { cn } from "@/lib/utils";
export interface ProcessedImage {
id: string;
originalName: string;
previewUrl: string;
dataUrl: string;
mimeType: string;
width: number;
height: number;
}
interface ItemImageCaptureProps {
maxImages?: number;
className?: string;
onImagesChange?: (images: ProcessedImage[]) => void;
}
export function ItemImageCapture({
maxImages = 2,
className,
onImagesChange,
}: ItemImageCaptureProps) {
const generateId = useCallback(() => {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `img-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}, []);
const [images, setImages] = useState<ProcessedImage[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const previewUrlsRef = useRef<Set<string>>(new Set());
const updateImages = useCallback(
(next: ProcessedImage[]) => {
setImages(next);
onImagesChange?.(next);
},
[onImagesChange],
);
const handleFiles = useCallback(
async (files: FileList | File[]) => {
const fileArray = Array.from(files).slice(0, maxImages - images.length);
if (fileArray.length === 0) return;
const processed: ProcessedImage[] = [];
for (const file of fileArray) {
if (!file.type.startsWith("image/")) continue;
try {
const { dataUrl, width, height } = await resizeImageToDataURL(file, {
mimeType: file.type === "image/png" ? "image/png" : "image/jpeg",
});
const previewUrl = URL.createObjectURL(file);
previewUrlsRef.current.add(previewUrl);
processed.push({
id: generateId(),
originalName: file.name,
previewUrl,
dataUrl,
mimeType: file.type === "image/png" ? "image/png" : "image/jpeg",
width,
height,
});
} catch (error) {
console.error("Falha ao processar imagem", error);
}
}
if (processed.length > 0) {
updateImages([...images, ...processed].slice(0, maxImages));
}
},
[generateId, images, maxImages, updateImages],
);
const handleDrop = useCallback(
async (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (event.dataTransfer.files?.length) {
await handleFiles(event.dataTransfer.files);
}
},
[handleFiles],
);
const onPaste = useCallback(
async (event: React.ClipboardEvent<HTMLDivElement>) => {
const items = event.clipboardData?.items;
if (!items) return;
const files: File[] = [];
for (const item of items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) files.push(file);
}
}
if (files.length) {
await handleFiles(files);
}
},
[handleFiles],
);
const removeImage = useCallback(
(id: string) => {
const nextImage = images.find((image) => image.id === id);
if (nextImage) {
URL.revokeObjectURL(nextImage.previewUrl);
previewUrlsRef.current.delete(nextImage.previewUrl);
}
const next = images.filter((image) => image.id !== id);
updateImages(next);
},
[images, updateImages],
);
const canAddMore = images.length < maxImages;
const dropLabel = useMemo(() => {
if (images.length === 0) {
return "Arraste, cole (Ctrl+V) ou escolha imagens";
}
if (canAddMore) {
return "Adicione mais imagens ou cole via Ctrl+V";
}
return "Limite máximo de imagens atingido";
}, [images.length, canAddMore]);
useEffect(
() => () => {
previewUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
previewUrlsRef.current.clear();
},
[],
);
return (
<div className={cn("space-y-4", className)}>
<div
className={cn(
"rounded-lg border-2 border-dashed border-muted-foreground/40 bg-muted/40 p-6 text-center transition-colors",
canAddMore ? "hover:border-primary" : "opacity-70",
)}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDrop={handleDrop}
onPaste={onPaste}
role="presentation"
>
<div className="flex flex-col items-center gap-3">
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{dropLabel}</p>
<Button
type="button"
variant="outline"
onClick={() => inputRef.current?.click()}
disabled={!canAddMore}
>
Selecionar arquivo
</Button>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={async (event) => {
if (event.target.files?.length) {
await handleFiles(event.target.files);
event.target.value = "";
}
}}
/>
</div>
</div>
{images.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{images.map((image) => (
<div
key={image.id}
className="relative overflow-hidden rounded-md border bg-background shadow-sm"
>
<button
type="button"
className="absolute right-2 top-2 rounded-full bg-background/80 p-1 text-muted-foreground transition hover:bg-destructive hover:text-destructive-foreground"
onClick={() => removeImage(image.id)}
aria-label="Remover imagem"
>
<X className="h-4 w-4" />
</button>
<img
src={image.previewUrl}
alt={image.originalName}
className="h-48 w-full object-cover"
/>
<div className="border-t p-3 text-xs text-muted-foreground">
<p className="font-medium">{image.originalName}</p>
<p>
{image.width}×{image.height}px ·{" "}
{Math.round((image.dataUrl.length * 3) / 4 / 1024)}KB
</p>
</div>
</div>
))}
</div>
)}
</div>
);
}
+20 -2
View File
@@ -1,10 +1,14 @@
import { secureLogger } from "@/lib/secure-logger";
import { buildGeminiParts, buildItemPrompt, InlineImageData } from "@/lib/ai/item-prompt";
interface GenerateItemStructureInput {
name: string;
description: string;
scope: "global" | "server";
server?: string | null;
categories: string[];
servers: string[];
images?: InlineImageData[];
}
interface GeminiResponse {
@@ -51,7 +55,21 @@ export async function generateItemStructure(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=" +
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 {
const response = await fetch(url, {
@@ -63,7 +81,7 @@ export async function generateItemStructure(
contents: [
{
role: "user",
parts: [{ text: prompt }],
parts,
},
],
generationConfig: {
+145
View File
@@ -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;
}
+51
View File
@@ -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,
};
}
+5
View File
@@ -2,5 +2,10 @@ packages:
- .
onlyBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- bcrypt
- better-sqlite3
- esbuild
- prisma
- sharp