feat(admin): centralize subscriptions under /admin?tab=subscriptions
Replaced the dedicated /admin/subscriptions page with a redirect to the central admin panel’s subscriptions tab. Removed session checks, imports, and the AdminSubscriptionDashboard since routing and access are now handled centrally. This streamlines admin UX and keeps access control unified.
This commit is contained in:
1 parent
d8e7d5d7e6
commit
62ab5b2608
19 files changed
+3091
-357
No files matched your search
@@ -0,0 +1,94 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import Header from "@/components/Header";
|
||||
import { getSession } from "@/lib/redis/session";
|
||||
import { getAllSubscriptionTiers } from "@/lib/prisma/queries/subscription-tiers";
|
||||
import { getUserById } from "@/lib/prisma/queries/users";
|
||||
import { AdminPanelTabs } from "@/components/admin/AdminPanelTabs";
|
||||
import { searchAdminItems } from "@/lib/prisma/queries/items";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: "Painel Administrativo",
|
||||
description:
|
||||
"Gerencie assinaturas, permissões e catalogação de itens exclusivos do Tibia Trade.",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AdminPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams:
|
||||
| Promise<Record<string, string | string[]>>
|
||||
| Record<string, string | string[]>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const resolvedSearchParams =
|
||||
searchParams instanceof Promise ? await searchParams : searchParams;
|
||||
const tabParam = resolvedSearchParams?.tab;
|
||||
const initialTab =
|
||||
typeof tabParam === "string" && ["overview", "subscriptions", "items"].includes(tabParam)
|
||||
? (tabParam as "overview" | "subscriptions" | "items")
|
||||
: "overview";
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("sessionId")?.value;
|
||||
|
||||
if (!sessionId) {
|
||||
redirect(`/${locale}`);
|
||||
}
|
||||
|
||||
const session = await getSession(sessionId);
|
||||
if (!session || session.userId !== 1) {
|
||||
redirect(`/${locale}`);
|
||||
}
|
||||
|
||||
const [tiers, adminUser, initialItems] = await Promise.all([
|
||||
getAllSubscriptionTiers(true),
|
||||
getUserById(session.userId),
|
||||
searchAdminItems({ page: 1, pageSize: 50 }),
|
||||
]);
|
||||
|
||||
const normalizedTiers = tiers.map((tier) => {
|
||||
const featureList = (tier.features ?? []) as Array<Record<string, any>>;
|
||||
return {
|
||||
...tier,
|
||||
price_monthly: tier.price_monthly ? Number(tier.price_monthly) : null,
|
||||
price_yearly: tier.price_yearly ? Number(tier.price_yearly) : null,
|
||||
created_at: tier.created_at instanceof Date ? tier.created_at.toISOString() : tier.created_at,
|
||||
updated_at: tier.updated_at instanceof Date ? tier.updated_at.toISOString() : tier.updated_at,
|
||||
features: featureList.map((feature) => ({
|
||||
...feature,
|
||||
created_at:
|
||||
feature.created_at instanceof Date ? feature.created_at.toISOString() : feature.created_at ?? null,
|
||||
updated_at:
|
||||
feature.updated_at instanceof Date ? feature.updated_at.toISOString() : feature.updated_at ?? null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-10 space-y-8">
|
||||
<section className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Painel administrativo</h1>
|
||||
<p className="text-muted-foreground max-w-2xl">
|
||||
Centralize a gestão de planos, permissões e o catálogo de itens. Utilize as abas para acessar cada área temática.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<AdminPanelTabs
|
||||
adminUser={adminUser}
|
||||
initialTiers={normalizedTiers}
|
||||
itemsData={initialItems}
|
||||
initialTab={initialTab}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import Header from "@/components/Header";
|
||||
import { getSession } from "@/lib/redis/session";
|
||||
import { getAllSubscriptionTiers } from "@/lib/prisma/queries/subscription-tiers";
|
||||
import { AdminSubscriptionDashboard } from "@/components/admin/subscription-dashboard";
|
||||
import { getUserById } from "@/lib/prisma/queries/users";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: "Assinaturas e Permissões",
|
||||
description:
|
||||
"Gerencie os planos de assinatura, recursos avançados e permissões dos usuários.",
|
||||
"Redirecionando para o painel administrativo centralizado.",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AdminSubscriptionsPage({
|
||||
export default async function AdminSubscriptionsRedirect({
|
||||
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 || session.userId !== 1) {
|
||||
redirect(`/${locale}`);
|
||||
}
|
||||
|
||||
const tiers = await getAllSubscriptionTiers(true);
|
||||
const adminUser = await getUserById(session.userId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-10 space-y-8">
|
||||
<section className="space-y-2">
|
||||
<h1 className="text-3xl font-bold">Assinaturas & Permissões</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure planos, recursos e vincule usuários às assinaturas disponíveis.
|
||||
</p>
|
||||
{adminUser?.subscription_tier && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Seu plano atual:{" "}
|
||||
<span className="font-semibold uppercase">
|
||||
{adminUser.subscription_tier.name} ({adminUser.subscription_tier.slug})
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<AdminSubscriptionDashboard initialTiers={tiers} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
redirect(`/${locale}/admin?tab=subscriptions`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAdminUser } from "@/lib/auth/middleware";
|
||||
import { addColumnToCategory, type SQLiteColumnDefinition } from "@/lib/items/sqlite-manager";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
|
||||
function sanitizeColumn(column: any): SQLiteColumnDefinition {
|
||||
const name = typeof column?.name === "string" ? column.name.trim().toLowerCase() : "";
|
||||
const type = typeof column?.type === "string" ? column.type : "TEXT";
|
||||
const notNull = column?.notNull === true;
|
||||
const defaultValue = column?.defaultValue ?? undefined;
|
||||
|
||||
if (!name) {
|
||||
throw new Error("Nome da coluna é obrigatório");
|
||||
}
|
||||
|
||||
if (["id", "nome", "image_url", "image_local_path"].includes(name)) {
|
||||
throw new Error("Coluna reservada não pode ser criada");
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
type: type.toUpperCase(),
|
||||
notNull,
|
||||
defaultValue,
|
||||
editable: column?.editable !== false,
|
||||
} satisfies SQLiteColumnDefinition;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ name: string }> }) {
|
||||
const auth = await requireAdminUser(request);
|
||||
if (auth instanceof NextResponse) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const column = sanitizeColumn(body?.column ?? body);
|
||||
const created = addColumnToCategory({ category: name, column });
|
||||
return NextResponse.json({ success: true, column: created }, { status: 201 });
|
||||
} catch (error) {
|
||||
secureLogger.error("Falha ao adicionar coluna à categoria", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Erro ao adicionar coluna" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAdminUser } from "@/lib/auth/middleware";
|
||||
import {
|
||||
listCategoryTemplates,
|
||||
createCategoryTemplate,
|
||||
type SQLiteColumnDefinition,
|
||||
type CreateCategoryInput,
|
||||
} from "@/lib/items/sqlite-manager";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireAdminUser(request);
|
||||
if (auth instanceof NextResponse) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
try {
|
||||
const templates = listCategoryTemplates();
|
||||
return NextResponse.json({ success: true, categories: templates }, { status: 200 });
|
||||
} catch (error) {
|
||||
secureLogger.error("Falha ao listar categorias dinâmicas", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Não foi possível listar categorias" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeColumn(column: any): SQLiteColumnDefinition {
|
||||
const name = typeof column?.name === "string" ? column.name.trim().toLowerCase() : "";
|
||||
const type = typeof column?.type === "string" ? column.type : "TEXT";
|
||||
const notNull = column?.notNull === true;
|
||||
const defaultValue = column?.defaultValue ?? undefined;
|
||||
|
||||
if (!name) {
|
||||
throw new Error("Nome da coluna é obrigatório");
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
type: type.toUpperCase(),
|
||||
notNull,
|
||||
defaultValue,
|
||||
editable: column?.editable !== false,
|
||||
} satisfies SQLiteColumnDefinition;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireAdminUser(request);
|
||||
if (auth instanceof NextResponse) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const rawName = typeof body?.name === "string" ? body.name.trim().toLowerCase() : "";
|
||||
if (!rawName) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Nome da categoria é obrigatório" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const columnsArray: unknown[] = Array.isArray(body?.columns) ? body.columns : [];
|
||||
const columns = columnsArray.map((column) => sanitizeColumn(column)).filter((column) => {
|
||||
return !(column && ["id", "nome", "image_url", "image_local_path"].includes(column.name));
|
||||
});
|
||||
|
||||
const input: CreateCategoryInput = {
|
||||
name: rawName,
|
||||
columns,
|
||||
};
|
||||
|
||||
const category = createCategoryTemplate(input);
|
||||
return NextResponse.json({ success: true, category }, { status: 201 });
|
||||
} catch (error) {
|
||||
secureLogger.error("Falha ao criar categoria dinâmica", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Erro ao criar categoria" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAdminUser } from "@/lib/auth/middleware";
|
||||
import { generateItemStructure } from "@/lib/ai/generate-item-structure";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireAdminUser(request);
|
||||
if (auth instanceof NextResponse) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
||||
const description =
|
||||
typeof body?.description === "string" ? body.description.trim() : "";
|
||||
const scope = body?.scope === "server" ? "server" : "global";
|
||||
const server =
|
||||
scope === "server" && typeof body?.server === "string"
|
||||
? body.server.trim()
|
||||
: null;
|
||||
|
||||
if (!name || !description) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Informe nome e descrição para gerar o JSON estrutural.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await generateItemStructure({
|
||||
name,
|
||||
description,
|
||||
scope,
|
||||
server,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
structure: result.structure,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAdminUser } from "@/lib/auth/middleware";
|
||||
import {
|
||||
createAdminItem,
|
||||
searchAdminItems,
|
||||
updateAdminItem,
|
||||
getItemById,
|
||||
} from "@/lib/prisma/queries/items";
|
||||
import {
|
||||
insertCategoryItem,
|
||||
updateCategoryItem,
|
||||
listCategoryTemplates,
|
||||
} from "@/lib/items/sqlite-manager";
|
||||
|
||||
function parseNumber(value: string | null | undefined, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeDynamicFields(category: string, rawFields: Record<string, unknown> | undefined) {
|
||||
if (!rawFields || typeof rawFields !== "object") {
|
||||
return {} as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const templates = listCategoryTemplates();
|
||||
const template = templates.find((entry) => entry.name === category.toLowerCase());
|
||||
if (!template) {
|
||||
return rawFields;
|
||||
}
|
||||
|
||||
const columnMap = new Map(template.columns.map((column) => [column.name, column]));
|
||||
const normalized: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(rawFields)) {
|
||||
if (["id", "nome", "image_url", "image_local_path"].includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const column = columnMap.get(key);
|
||||
if (!column) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
normalized[key] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (column.type) {
|
||||
case "INTEGER":
|
||||
case "BOOLEAN": {
|
||||
const parsed = Number(value);
|
||||
normalized[key] = Number.isFinite(parsed) ? parsed : null;
|
||||
break;
|
||||
}
|
||||
case "REAL": {
|
||||
const parsed = Number(value);
|
||||
normalized[key] = Number.isFinite(parsed) ? parsed : null;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
normalized[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireAdminUser(request);
|
||||
if (auth instanceof NextResponse) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseNumber(searchParams.get("page"), 1);
|
||||
const pageSize = Math.min(parseNumber(searchParams.get("pageSize"), 50), 200);
|
||||
const q = searchParams.get("q") || undefined;
|
||||
const category = searchParams.get("category") || undefined;
|
||||
const rarity = searchParams.get("rarity") || undefined;
|
||||
const server = searchParams.get("server") || undefined;
|
||||
|
||||
try {
|
||||
const result = await searchAdminItems({
|
||||
q,
|
||||
category,
|
||||
rarity,
|
||||
server,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (error: any) {
|
||||
console.error("[admin/items][GET] erro", error?.message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Falha ao consultar itens",
|
||||
message: error?.message || "Erro desconhecido",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireAdminUser(request);
|
||||
if (auth instanceof NextResponse) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
||||
const category = typeof body?.category === "string" ? body.category.trim().toLowerCase() : "";
|
||||
|
||||
if (!name || !category) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Nome e categoria são obrigatórios." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const dynamicFields = normalizeDynamicFields(category, body?.fields);
|
||||
|
||||
const imageUrl = typeof body?.imageUrl === "string" ? body.imageUrl.trim() || null : null;
|
||||
const imageLocalPath =
|
||||
typeof body?.imageLocalPath === "string" ? body.imageLocalPath.trim() || null : null;
|
||||
|
||||
const sqliteData = {
|
||||
nome: name,
|
||||
image_url: imageUrl,
|
||||
image_local_path: imageLocalPath,
|
||||
...dynamicFields,
|
||||
} as Record<string, unknown>;
|
||||
|
||||
const sqliteId = insertCategoryItem({ category, data: sqliteData });
|
||||
|
||||
const metadataBase = typeof body?.metadata === "object" && body.metadata !== null ? body.metadata : {};
|
||||
const metadata = { ...metadataBase, ...dynamicFields } as Record<string, unknown>;
|
||||
|
||||
const item = await createAdminItem({
|
||||
item_name: name,
|
||||
category,
|
||||
rarity: typeof body?.rarity === "string" ? body.rarity.trim() || null : null,
|
||||
image_url: imageUrl,
|
||||
image_local_path: imageLocalPath,
|
||||
level: typeof body?.level === "number" ? body.level : null,
|
||||
weight: typeof body?.weight === "number" ? body.weight : null,
|
||||
slots: typeof body?.slots === "number" ? body.slots : null,
|
||||
skill: typeof body?.skill === "string" ? body.skill.trim() || null : null,
|
||||
servers: parseList(body?.servers),
|
||||
tags: parseList(body?.tags),
|
||||
description:
|
||||
typeof body?.description === "string" ? body.description.trim() || null : null,
|
||||
metadata,
|
||||
source_table_name: category,
|
||||
source_table_id: sqliteId,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
item,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("[admin/items][POST] erro", error?.message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Falha ao criar item",
|
||||
message: error?.message || "Erro desconhecido",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const auth = await requireAdminUser(request);
|
||||
if (auth instanceof NextResponse) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const id = Number(body?.id);
|
||||
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "ID inválido." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await getItemById(id);
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Item não encontrado." },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const category = (typeof body?.category === "string" ? body.category.trim().toLowerCase() : existing.category) ?? existing.category;
|
||||
if (category !== existing.category) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Alterar a categoria de um item não é suportado." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const dynamicFields = normalizeDynamicFields(category, body?.fields);
|
||||
|
||||
const imageUrl = typeof body?.imageUrl === "string" ? body.imageUrl.trim() : undefined;
|
||||
const imageLocalPath =
|
||||
typeof body?.imageLocalPath === "string" ? body.imageLocalPath.trim() : undefined;
|
||||
|
||||
if (existing.source_table_id && existing.source_table_name) {
|
||||
const sqliteData: Record<string, unknown> = {
|
||||
nome: typeof body?.name === "string" ? body.name.trim() : existing.item_name,
|
||||
};
|
||||
|
||||
if (imageUrl !== undefined) sqliteData.image_url = imageUrl;
|
||||
else sqliteData.image_url = existing.image_url;
|
||||
|
||||
if (imageLocalPath !== undefined) sqliteData.image_local_path = imageLocalPath;
|
||||
else sqliteData.image_local_path = existing.image_local_path;
|
||||
|
||||
for (const [key, value] of Object.entries(dynamicFields)) {
|
||||
sqliteData[key] = value;
|
||||
}
|
||||
|
||||
updateCategoryItem(existing.source_table_name, existing.source_table_id, sqliteData);
|
||||
}
|
||||
|
||||
const metadataBase = typeof body?.metadata === "object" && body.metadata !== null ? body.metadata : existing.metadata;
|
||||
const metadata = { ...metadataBase, ...dynamicFields } as Record<string, unknown>;
|
||||
|
||||
const item = await updateAdminItem(id, {
|
||||
item_name: typeof body?.name === "string" ? body.name.trim() : undefined,
|
||||
category,
|
||||
rarity: typeof body?.rarity === "string" ? body.rarity.trim() : undefined,
|
||||
image_url: imageUrl,
|
||||
image_local_path: imageLocalPath,
|
||||
level: typeof body?.level === "number" ? body.level : undefined,
|
||||
weight: typeof body?.weight === "number" ? body.weight : undefined,
|
||||
slots: typeof body?.slots === "number" ? body.slots : undefined,
|
||||
skill: typeof body?.skill === "string" ? body.skill.trim() : undefined,
|
||||
servers: parseList(body?.servers),
|
||||
tags: parseList(body?.tags),
|
||||
description:
|
||||
typeof body?.description === "string"
|
||||
? body.description.trim()
|
||||
: body?.description === null
|
||||
? null
|
||||
: undefined,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
item,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("[admin/items][PUT] erro", error?.message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Falha ao atualizar item",
|
||||
message: error?.message || "Erro desconhecido",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import path from "node:path";
|
||||
|
||||
function getDb() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Database = require("better-sqlite3");
|
||||
const dbPath = path.join(process.cwd(), "data", "tibiawiki.db");
|
||||
return new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
}
|
||||
import { prisma } from "@/lib/prisma/app";
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
try {
|
||||
const db = getDb();
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name <> 'sqlite_sequence' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
db.close();
|
||||
// Retorna nomes das tabelas (PT) para usar como categorias
|
||||
return Response.json({ categories: tables.map((t) => t.name) });
|
||||
const categories = await prisma.item.findMany({
|
||||
distinct: ["category"],
|
||||
select: { category: true },
|
||||
orderBy: { category: "asc" },
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
categories: categories
|
||||
.map((row) => row.category)
|
||||
.filter((category): category is string => Boolean(category)),
|
||||
});
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e?.message || "Erro" }, { status: 500 });
|
||||
return Response.json(
|
||||
{ error: e?.message || "Erro" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-24
@@ -1,60 +1,66 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { searchItems } from "@/lib/items-db";
|
||||
import { searchItems, type ItemSort } from "@/lib/prisma/queries/items";
|
||||
import { buildPublicItemImageUrl } from "@/lib/item-images";
|
||||
|
||||
function parseArray(param: string | null): string[] {
|
||||
if (!param) return [];
|
||||
// Suporta formatos: comma-separated (a,b) ou múltiplos parâmetros já combinados
|
||||
return param
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const allowedSorts: ItemSort[] = [
|
||||
"name",
|
||||
"level-asc",
|
||||
"level-desc",
|
||||
"weight-asc",
|
||||
"weight-desc",
|
||||
"category",
|
||||
"updated-desc",
|
||||
];
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = searchParams.get("q") || undefined;
|
||||
const categories = parseArray(searchParams.get("category"));
|
||||
const rarities = parseArray(searchParams.get("rarity"));
|
||||
const sortParam = (searchParams.get("sort") || "name") as
|
||||
| "name"
|
||||
| "level-asc"
|
||||
| "level-desc"
|
||||
| "weight-asc"
|
||||
| "weight-desc"
|
||||
| "category";
|
||||
const server = searchParams.get("server") || undefined;
|
||||
const sortParamRaw = (searchParams.get("sort") || "name").toLowerCase();
|
||||
const sortParam = (allowedSorts.find((sort) => sort === sortParamRaw) || "name") as ItemSort;
|
||||
const page = Number(searchParams.get("page") || "1");
|
||||
const pageSize = Number(searchParams.get("pageSize") || "24");
|
||||
const voc = searchParams.get("voc") || undefined;
|
||||
|
||||
const { items, total } = searchItems({
|
||||
const { items, total } = await searchItems({
|
||||
q,
|
||||
categories,
|
||||
rarities,
|
||||
category: categories[0],
|
||||
rarity: rarities[0],
|
||||
server,
|
||||
sort: sortParam,
|
||||
vocation: voc,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
// Enriquecer com URL pública de imagem quando possível
|
||||
const itemsWithImage = items.map((it: any) => {
|
||||
const img = (it.image || "").toString();
|
||||
// Preferir URL absoluta do banco para teste
|
||||
const itemsWithImage = items.map((item) => {
|
||||
const img = (item.image_url || item.image_local_path || "").toString();
|
||||
if (/^https?:\/\//i.test(img)) {
|
||||
return { ...it, image: img };
|
||||
return { ...item, image_url: img, image: img };
|
||||
}
|
||||
if (img.startsWith("data/")) {
|
||||
return { ...it, image: `/api/images?path=${encodeURIComponent(img)}` };
|
||||
return { ...item, image_url: img, image: `/api/images?path=${encodeURIComponent(img)}` };
|
||||
}
|
||||
const categoryEn = (it.category || "other").toString();
|
||||
return { ...it, image: buildPublicItemImageUrl(categoryEn, img) };
|
||||
const category = item.category || "other";
|
||||
return {
|
||||
...item,
|
||||
image_url: item.image_url,
|
||||
image: buildPublicItemImageUrl(category, img),
|
||||
};
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||||
return Response.json(
|
||||
{ items: itemsWithImage, page, pageSize, total, totalPages },
|
||||
{ items: itemsWithImage, page, pageSize, total, totalPages, sort: sortParam },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function Header() {
|
||||
const navLinks = [
|
||||
...links,
|
||||
...(isAuthenticated && user?.userId === 1
|
||||
? [{ href: `/${locale}/admin/subscriptions`, label: "Admin" }]
|
||||
? [{ href: `/${locale}/admin`, label: "Admin" }]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminSubscriptionTab } from "@/components/admin/AdminSubscriptionTab";
|
||||
import { AdminItemManager, type AdminItemsResponse } from "@/components/admin/items/AdminItemManager";
|
||||
|
||||
interface AdminPanelTabsProps {
|
||||
adminUser: {
|
||||
subscription_tier?: {
|
||||
name: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
username?: string;
|
||||
email?: string;
|
||||
} | null;
|
||||
initialTiers: any[];
|
||||
itemsData: AdminItemsResponse;
|
||||
initialTab?: "overview" | "subscriptions" | "items";
|
||||
}
|
||||
|
||||
export function AdminPanelTabs({
|
||||
adminUser,
|
||||
initialTiers,
|
||||
itemsData,
|
||||
initialTab = "overview",
|
||||
}: AdminPanelTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "subscriptions" | "items">(
|
||||
initialTab
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as typeof activeTab)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Visão Geral</TabsTrigger>
|
||||
<TabsTrigger value="subscriptions">Assinaturas & Permissões</TabsTrigger>
|
||||
<TabsTrigger value="items">Gerenciamento de Itens</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bem-vindo, {adminUser?.username ?? "Administrador"}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Utilize as abas acima para administrar planos de assinatura, permissões
|
||||
e a curadoria de itens do marketplace. Esta área é exclusiva para
|
||||
operadores autorizados.
|
||||
</p>
|
||||
<p>
|
||||
Para solicitações críticas, lembre-se de registrar mudanças e validar
|
||||
com a equipe responsável antes de disponibilizar aos usuários finais.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subscriptions">
|
||||
<AdminSubscriptionTab initialTiers={initialTiers} adminUser={adminUser} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="items">
|
||||
<AdminItemManager initialData={itemsData} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
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";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { RefreshCw, Plus, Pencil } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { ItemCreationModal, type ItemFormPayload } from "@/components/admin/items/ItemCreationModal";
|
||||
|
||||
export interface AdminItemRecord {
|
||||
item_id: number;
|
||||
source_table_id: number;
|
||||
source_table_name: string;
|
||||
item_name: string;
|
||||
category: string;
|
||||
image_local_path: string | null;
|
||||
image_url: string | null;
|
||||
rarity: string | null;
|
||||
level: number | null;
|
||||
weight: number | null;
|
||||
slots: number | null;
|
||||
skill: string | null;
|
||||
metadata: Record<string, any>;
|
||||
created_at: string | Date;
|
||||
updated_at: string | Date;
|
||||
servers: string[];
|
||||
tags: string[];
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminItemsResponse {
|
||||
items: AdminItemRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
servers: string[];
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
interface AdminItemManagerProps {
|
||||
initialData: AdminItemsResponse;
|
||||
}
|
||||
|
||||
export function AdminItemManager({ initialData }: AdminItemManagerProps) {
|
||||
const [data, setData] = useState<AdminItemsResponse>(initialData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [serverFilter, setServerFilter] = useState<string>("all");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
||||
const [rarityFilter, setRarityFilter] = useState<string>("all");
|
||||
const [page, setPage] = useState<number>(initialData.page ?? 1);
|
||||
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);
|
||||
|
||||
const searchDebounce = useRef<number | undefined>(undefined);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (search.trim()) params.set("q", search.trim());
|
||||
if (serverFilter !== "all") params.set("server", serverFilter);
|
||||
if (categoryFilter !== "all") params.set("category", categoryFilter);
|
||||
if (rarityFilter !== "all") params.set("rarity", rarityFilter);
|
||||
params.set("page", String(page));
|
||||
params.set("pageSize", String(pageSize));
|
||||
|
||||
const res = await fetch(`/api/admin/items?${params.toString()}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Não foi possível carregar os itens.");
|
||||
}
|
||||
const json = (await res.json()) as AdminItemsResponse;
|
||||
setData(json);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : "Falha ao carregar itens.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, serverFilter, categoryFilter, rarityFilter, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchDebounce.current !== undefined) {
|
||||
window.clearTimeout(searchDebounce.current);
|
||||
}
|
||||
searchDebounce.current = window.setTimeout(() => {
|
||||
fetchItems();
|
||||
}, 350);
|
||||
return () => {
|
||||
if (searchDebounce.current !== undefined) {
|
||||
window.clearTimeout(searchDebounce.current);
|
||||
searchDebounce.current = undefined;
|
||||
}
|
||||
};
|
||||
}, [fetchItems]);
|
||||
|
||||
const rarityOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
data.items.forEach((item) => {
|
||||
if (item.rarity) {
|
||||
set.add(item.rarity);
|
||||
}
|
||||
});
|
||||
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);
|
||||
};
|
||||
|
||||
const handleSubmit = async (payload: ItemFormPayload) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const method = modalMode === "edit" ? "PUT" : "POST";
|
||||
const body: Record<string, unknown> = {
|
||||
id: payload.id,
|
||||
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 res = await fetch("/api/admin/items", {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ error: "Erro desconhecido" }));
|
||||
throw new Error(error?.error || error?.message || "Falha ao salvar item");
|
||||
}
|
||||
|
||||
await fetchItems();
|
||||
setModalOpen(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : "Erro ao salvar item.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const itemsForDisplay = data.items;
|
||||
const serverTabs = ["all", ...data.servers];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Todos os itens cadastrados</CardTitle>
|
||||
<CardDescription>
|
||||
Visualize e edite os registros unificados importados do database do Tibia Wiki.
|
||||
Itens específicos de servidores podem ser marcados através das tags.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchItems} disabled={loading}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" /> Atualizar
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreateModal}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Novo item
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Input
|
||||
placeholder="Buscar por nome..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<Select value={categoryFilter} onValueChange={(value) => { setCategoryFilter(value); setPage(1); }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as categorias</SelectItem>
|
||||
{data.categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={rarityFilter} onValueChange={(value) => { setRarityFilter(value); setPage(1); }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Raridade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as raridades</SelectItem>
|
||||
{rarityOptions.map((rarity) => (
|
||||
<SelectItem key={rarity} value={rarity}>
|
||||
{rarity}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-sm text-muted-foreground flex items-center">
|
||||
{loading ? "Carregando..." : `${data.total} itens encontrados`}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/40 bg-destructive/10">
|
||||
<CardContent className="py-3 text-sm text-destructive">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={serverFilter}
|
||||
onValueChange={(value) => {
|
||||
setServerFilter(value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<TabsList className="flex-wrap">
|
||||
{serverTabs.map((server) => (
|
||||
<TabsTrigger key={server} value={server} className="capitalize">
|
||||
{server === "all" ? "Todos" : server}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{serverTabs.map((server) => (
|
||||
<TabsContent key={server} value={server} className="space-y-4">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full table-auto border-collapse min-w-[960px]">
|
||||
<thead>
|
||||
<tr className="bg-muted/70 text-muted-foreground text-xs uppercase tracking-wide">
|
||||
<th className="border border-border px-3 py-2 text-left">Imagem</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Nome</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Categoria</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Raridade</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Origem</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Servidores</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Atualizado em</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{itemsForDisplay.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="border border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum item encontrado com os filtros atuais.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
itemsForDisplay.map((item) => (
|
||||
<tr key={item.item_id} className="odd:bg-background even:bg-muted/40 hover:bg-primary/5 transition-colors">
|
||||
<td className="border border-border px-3 py-2 align-middle">
|
||||
{item.image_url ? (
|
||||
<div className="relative h-12 w-12 overflow-hidden rounded border">
|
||||
<Image
|
||||
src={item.image_url}
|
||||
alt={item.item_name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Sem imagem</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 align-top">
|
||||
<div className="font-medium">{item.item_name}</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">
|
||||
#{item.item_id}
|
||||
</div>
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 align-top text-sm">{item.category}</td>
|
||||
<td className="border border-border px-3 py-2 align-top text-sm">
|
||||
{item.rarity ? (
|
||||
<Badge variant="outline" className="uppercase text-[10px]">
|
||||
{item.rarity}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 align-top text-[11px] text-muted-foreground">
|
||||
<div>{item.source_table_name}</div>
|
||||
<div>ID origem: {item.source_table_id}</div>
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 align-top">
|
||||
{item.servers.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.servers.map((serverTag) => (
|
||||
<Badge key={`${item.item_id}-${serverTag}`} variant="secondary" className="text-[10px] uppercase">
|
||||
{serverTag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">Global</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 align-top text-xs text-muted-foreground">
|
||||
{format(new Date(item.updated_at), "dd/MM/yyyy HH:mm", { locale: ptBR })}
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 align-top">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => openEditModal(item)}
|
||||
aria-label="Editar item"
|
||||
>
|
||||
<Pencil className="h-4 w-4" /> Editar
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Página {page} • Total de {Math.max(1, Math.ceil(data.total / pageSize))}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((prev) => (prev >= Math.ceil(data.total / pageSize) ? prev : prev + 1))}
|
||||
disabled={page >= Math.ceil(data.total / pageSize) || loading}
|
||||
>
|
||||
Próxima
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<ItemCreationModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
mode={modalMode}
|
||||
item={selectedItem ?? undefined}
|
||||
categories={data.categories}
|
||||
serverOptions={data.servers}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
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 {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: "create" | "edit";
|
||||
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[];
|
||||
serverOptions: string[];
|
||||
onSubmit: (payload: ItemFormPayload) => Promise<void>;
|
||||
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({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
item,
|
||||
categories,
|
||||
serverOptions,
|
||||
onSubmit,
|
||||
submitting,
|
||||
}: 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 (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl w-full h-[90vh] overflow-hidden">
|
||||
<div className="flex h-full flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{isEdit ? "Editar item" : "Novo item"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Preencha os dados do item unificado. Utilize a IA para gerar um JSON de referência, se desejar.
|
||||
</DialogDescription>
|
||||
</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,146 @@
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
|
||||
interface GenerateItemStructureInput {
|
||||
name: string;
|
||||
description: string;
|
||||
scope: "global" | "server";
|
||||
server?: string | null;
|
||||
}
|
||||
|
||||
interface GeminiResponse {
|
||||
structure: {
|
||||
table: string;
|
||||
columns: Record<string, unknown>;
|
||||
};
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
const FALLBACK_STRUCTURE = {
|
||||
table: "items_all",
|
||||
columns: {
|
||||
name: "Final Fate",
|
||||
description:
|
||||
"Item lendário utilizado no slot de tocha. Oferece bônus massivos em skills mágicas, velocidade e efeitos de leech.",
|
||||
attributes: {
|
||||
shielding: 20,
|
||||
death_magic: 120,
|
||||
mana_leech: { value: 30, chance: 100 },
|
||||
life_leech: { value: 25, chance: 100 },
|
||||
critical_hit: { damage: 25, chance: 75 },
|
||||
speed: 1000,
|
||||
protection_physical: 40,
|
||||
damage_reflection: 80,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function generateItemStructure(
|
||||
input: GenerateItemStructureInput
|
||||
): Promise<GeminiResponse> {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
secureLogger.warn("⚠️ GEMINI_API_KEY ausente – utilizando fallback estático.");
|
||||
return {
|
||||
structure: FALLBACK_STRUCTURE,
|
||||
raw: FALLBACK_STRUCTURE,
|
||||
};
|
||||
}
|
||||
|
||||
const url =
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=" +
|
||||
apiKey;
|
||||
|
||||
const prompt = buildPrompt(input);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gemini retornou status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const text =
|
||||
data?.candidates?.[0]?.content?.parts?.[0]?.text ??
|
||||
data?.candidates?.[0]?.output_text;
|
||||
|
||||
if (!text) {
|
||||
throw new Error("Resposta do Gemini vazia ou inválida.");
|
||||
}
|
||||
|
||||
const cleaned = text
|
||||
.replace(/```json/gi, "")
|
||||
.replace(/```/g, "")
|
||||
.trim();
|
||||
|
||||
const parsed = JSON.parse(cleaned);
|
||||
|
||||
return {
|
||||
structure: parsed,
|
||||
raw: data,
|
||||
};
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Falha ao gerar estrutura via Gemini", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
});
|
||||
|
||||
return {
|
||||
structure: FALLBACK_STRUCTURE,
|
||||
raw: FALLBACK_STRUCTURE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrompt({ name, description, scope, server }: GenerateItemStructureInput) {
|
||||
return `
|
||||
Você é um assistente que prepara carga útil em JSON para inserção de itens em banco de dados.
|
||||
|
||||
Requisitos obrigatórios:
|
||||
- Responda SOMENTE com JSON válido, sem comentários, sem markdown, sem texto adicional.
|
||||
- Formato esperado:
|
||||
{
|
||||
"table": "nome_da_tabela",
|
||||
"columns": {
|
||||
"...": "..."
|
||||
}
|
||||
}
|
||||
- Nunca inclua explicações fora do JSON.
|
||||
|
||||
Contexto do item:
|
||||
- Nome: ${name}
|
||||
- Escopo: ${scope === "server" ? `específico do servidor ${server ?? "não informado"}` : "global"}
|
||||
- Descrição original (mantenha estrutura e extrair atributos quando possível):
|
||||
"""
|
||||
${description}
|
||||
"""
|
||||
|
||||
Instruções:
|
||||
- Escolha uma tabela coerente com o escopo (ex.: "items_all" para global ou "items_${(server ?? "custom")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")}" para servidor).
|
||||
- Dentro de "columns", inicie com "name" e "description".
|
||||
- Modele atributos numéricos em objetos ou subtabelas conforme necessário (ex.: {"attributes": {...}}).
|
||||
- Quando não tiver certeza de um campo, deixe nulo.
|
||||
- Nunca invente dados além do que estiver na descrição.
|
||||
- Garante que o JSON seja minuciosamente válido.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma/app";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
|
||||
export interface ImportStats {
|
||||
processedTables: number;
|
||||
processedItems: number;
|
||||
inserted: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
const PT_TO_EN_CATEGORY_MAP: Record<string, string> = {
|
||||
espadas: "weapon",
|
||||
armaduras: "armor",
|
||||
capacetes: "helmet",
|
||||
calcas: "legs",
|
||||
botas: "boots",
|
||||
escudos: "shield",
|
||||
amuletos: "amulet",
|
||||
aneis: "ring",
|
||||
runas: "rune",
|
||||
ferramentas: "tool",
|
||||
municao: "ammunition",
|
||||
};
|
||||
|
||||
const SQLITE_EXTRA_FIELDS = [
|
||||
"voc",
|
||||
"volume",
|
||||
"bonus_de_skill",
|
||||
"protecao",
|
||||
"dropa_de",
|
||||
"npc_price",
|
||||
"dropped_by",
|
||||
"tipo",
|
||||
"classe",
|
||||
"atributos",
|
||||
"descricao",
|
||||
"descricao_curta",
|
||||
"descricao_longa",
|
||||
"description",
|
||||
] as const;
|
||||
|
||||
const SQL_CHUNK_SIZE = 250;
|
||||
|
||||
let seedPromise: Promise<ImportStats | null> | null = null;
|
||||
let seedLogged = false;
|
||||
|
||||
function loadBetterSqlite(): any | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require("better-sqlite3");
|
||||
} catch (error) {
|
||||
secureLogger.warn("better-sqlite3 não está disponível. Importação automática será ignorada.", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function openSQLiteDatabase(Database: any) {
|
||||
const dbPath = path.join(process.cwd(), "data", "tibiawiki.db");
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
secureLogger.warn("Base SQLite não encontrada em data/tibiawiki.db. Importação ignorada.");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
} catch (error) {
|
||||
secureLogger.error("Falha ao abrir base SQLite", error instanceof Error ? error.message : error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toNullableInt(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toNullableDecimal(value: unknown): Prisma.Decimal | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return new Prisma.Decimal(parsed);
|
||||
}
|
||||
|
||||
function collectMetadata(row: Record<string, unknown>): Record<string, unknown> {
|
||||
const metadata: Record<string, unknown> = {};
|
||||
|
||||
for (const field of SQLITE_EXTRA_FIELDS) {
|
||||
if (row[field] !== undefined && row[field] !== null && row[field] !== "") {
|
||||
metadata[field] = row[field];
|
||||
}
|
||||
}
|
||||
|
||||
const description =
|
||||
(row.descricao as unknown as string | undefined) ??
|
||||
(row.descricao_longa as unknown as string | undefined) ??
|
||||
(row.description as unknown as string | undefined);
|
||||
|
||||
if (description) {
|
||||
metadata.description = description;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function normalizeCategory(tableName: string): string {
|
||||
return PT_TO_EN_CATEGORY_MAP[tableName] ?? tableName;
|
||||
}
|
||||
|
||||
export async function importItemsFromSQLite(): Promise<ImportStats> {
|
||||
const Database = loadBetterSqlite();
|
||||
if (!Database) {
|
||||
return { processedTables: 0, processedItems: 0, inserted: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const db = openSQLiteDatabase(Database);
|
||||
if (!db) {
|
||||
return { processedTables: 0, processedItems: 0, inserted: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
|
||||
let processedTables = 0;
|
||||
let processedItems = 0;
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const batch: Prisma.ItemCreateManyInput[] = [];
|
||||
|
||||
for (const { name: tableName } of tables) {
|
||||
const pragma = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
const colNames = new Set(pragma.map((col) => col.name.toLowerCase()));
|
||||
|
||||
const hasId = colNames.has("id");
|
||||
const hasNome = colNames.has("nome") || colNames.has("name");
|
||||
|
||||
if (!hasId || !hasNome) {
|
||||
continue;
|
||||
}
|
||||
|
||||
processedTables += 1;
|
||||
const rows = db.prepare(`SELECT * FROM ${tableName}`).all() as Record<string, unknown>[];
|
||||
|
||||
for (const row of rows) {
|
||||
processedItems += 1;
|
||||
|
||||
const rawId = row.id ?? row.item_id;
|
||||
const numericId = toNullableInt(rawId);
|
||||
if (!numericId) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameField = (row.nome ?? row.name ?? "") as string;
|
||||
const itemName = typeof nameField === "string" ? nameField.trim() : "";
|
||||
if (!itemName) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageUrl = (row.image_url ?? row.image ?? null) as string | null;
|
||||
const imageLocalPath = (row.image_local_path ?? row.image_local ?? null) as string | null;
|
||||
const rarity =
|
||||
(row.rarity ?? row.raridade ?? row.rareza ?? null) !== null
|
||||
? String(row.rarity ?? row.raridade ?? row.rareza)
|
||||
: null;
|
||||
const level = toNullableInt(row.lvl ?? row.nivel ?? row.level);
|
||||
const weight = toNullableDecimal(row.peso ?? row.peso_em_oz ?? row.weight);
|
||||
const slots = toNullableInt(row.slots ?? row.slot);
|
||||
const skill =
|
||||
row.skill_para_usar ?? row.skill_required ?? row.skill ?? null
|
||||
? String(row.skill_para_usar ?? row.skill_required ?? row.skill)
|
||||
: null;
|
||||
|
||||
const metadata = collectMetadata(row);
|
||||
const metadataPayload = metadata as Prisma.InputJsonValue;
|
||||
|
||||
batch.push({
|
||||
source_table_id: numericId,
|
||||
source_table_name: tableName,
|
||||
item_name: itemName,
|
||||
category: normalizeCategory(tableName),
|
||||
image_local_path: imageLocalPath,
|
||||
image_url: imageUrl,
|
||||
rarity,
|
||||
level,
|
||||
weight,
|
||||
slots,
|
||||
skill,
|
||||
metadata: metadataPayload,
|
||||
});
|
||||
|
||||
if (batch.length >= SQL_CHUNK_SIZE) {
|
||||
const { count } = await prisma.item.createMany({
|
||||
data: batch,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
inserted += count;
|
||||
skipped += batch.length - count;
|
||||
batch.length = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
const { count } = await prisma.item.createMany({
|
||||
data: batch,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
inserted += count;
|
||||
skipped += batch.length - count;
|
||||
}
|
||||
|
||||
return { processedTables, processedItems, inserted, skipped };
|
||||
} finally {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureItemsSeeded(): Promise<ImportStats | null> {
|
||||
if (seedPromise) {
|
||||
return seedPromise;
|
||||
}
|
||||
|
||||
seedPromise = (async () => {
|
||||
const currentCount = await prisma.item.count();
|
||||
if (!seedLogged) {
|
||||
seedLogged = true;
|
||||
secureLogger.info("Verificação da tabela items concluída", { existing: currentCount });
|
||||
}
|
||||
|
||||
if (currentCount > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
secureLogger.info("Tabela items vazia. Iniciando importação automática a partir do SQLite.");
|
||||
try {
|
||||
const stats = await importItemsFromSQLite();
|
||||
secureLogger.info("Importação de itens concluída", stats);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
secureLogger.error("Falha na importação automática de itens", error);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
return await seedPromise;
|
||||
} finally {
|
||||
seedPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type SQLiteColumnType = "TEXT" | "INTEGER" | "REAL" | "BOOLEAN";
|
||||
|
||||
export interface SQLiteColumnDefinition {
|
||||
name: string;
|
||||
type: SQLiteColumnType;
|
||||
notNull?: boolean;
|
||||
defaultValue?: string | number | null;
|
||||
editable?: boolean;
|
||||
primaryKey?: boolean;
|
||||
}
|
||||
|
||||
export interface CategoryTemplate {
|
||||
name: string;
|
||||
columns: SQLiteColumnDefinition[];
|
||||
imagePath: string;
|
||||
}
|
||||
|
||||
const BASE_COLUMNS: SQLiteColumnDefinition[] = [
|
||||
{ name: "id", type: "INTEGER", primaryKey: true, notNull: true, editable: false },
|
||||
{ name: "nome", type: "TEXT", notNull: true, editable: true },
|
||||
{ name: "image_url", type: "TEXT", editable: true },
|
||||
{ name: "image_local_path", type: "TEXT", editable: true },
|
||||
];
|
||||
|
||||
const SQLITE_DB_PATH = path.join(process.cwd(), "data", "tibiawiki.db");
|
||||
const IMAGES_ROOT = path.join(process.cwd(), "data", "images");
|
||||
|
||||
let cachedDatabase: any | null = null;
|
||||
|
||||
function loadBetterSqlite(): any {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Database = require("better-sqlite3");
|
||||
return Database;
|
||||
}
|
||||
|
||||
function getDatabase(): any {
|
||||
if (cachedDatabase) {
|
||||
return cachedDatabase;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(SQLITE_DB_PATH)) {
|
||||
throw new Error("Banco SQLite tibiawiki.db não encontrado em data/");
|
||||
}
|
||||
|
||||
const Database = loadBetterSqlite();
|
||||
cachedDatabase = new Database(SQLITE_DB_PATH, { fileMustExist: true });
|
||||
return cachedDatabase;
|
||||
}
|
||||
|
||||
function normalizeColumnType(type: SQLiteColumnType | string): SQLiteColumnType {
|
||||
const upper = type.toUpperCase();
|
||||
switch (upper) {
|
||||
case "INTEGER":
|
||||
case "INT":
|
||||
return "INTEGER";
|
||||
case "REAL":
|
||||
case "FLOAT":
|
||||
case "DOUBLE":
|
||||
return "REAL";
|
||||
case "BOOLEAN":
|
||||
return "BOOLEAN";
|
||||
default:
|
||||
return "TEXT";
|
||||
}
|
||||
}
|
||||
|
||||
function sqliteTypeFromColumn(column: SQLiteColumnDefinition): string {
|
||||
const type = normalizeColumnType(column.type);
|
||||
if (type === "BOOLEAN") {
|
||||
return "INTEGER";
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
function ensureImageDirectory(category: string) {
|
||||
const dir = path.join(IMAGES_ROOT, category);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function mapPragmaColumns(columns: Array<{ name: string; type: string; notnull: number; pk: number }>): SQLiteColumnDefinition[] {
|
||||
const mapped = columns.map((column) => {
|
||||
const baseMatch = BASE_COLUMNS.find((base) => base.name === column.name);
|
||||
return {
|
||||
name: column.name,
|
||||
type: normalizeColumnType(column.type as SQLiteColumnType),
|
||||
notNull: column.notnull === 1,
|
||||
primaryKey: column.pk === 1,
|
||||
editable: baseMatch ? baseMatch.editable : column.pk !== 1,
|
||||
} satisfies SQLiteColumnDefinition;
|
||||
});
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export function listCategoryTemplates(): CategoryTemplate[] {
|
||||
const db = getDatabase();
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
return tables.map((table) => {
|
||||
const pragma = db.prepare(`PRAGMA table_info(${table.name})`).all() as Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
pk: number;
|
||||
}>;
|
||||
|
||||
const columns = mapPragmaColumns(pragma);
|
||||
ensureImageDirectory(table.name);
|
||||
|
||||
return {
|
||||
name: table.name,
|
||||
columns,
|
||||
imagePath: path.join("data", "images", table.name),
|
||||
} satisfies CategoryTemplate;
|
||||
});
|
||||
}
|
||||
|
||||
function validateCategoryName(name: string) {
|
||||
if (!/^[a-z0-9_]+$/i.test(name)) {
|
||||
throw new Error("Nome da categoria deve conter apenas letras, números ou sublinhado.");
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateCategoryInput {
|
||||
name: string;
|
||||
columns?: SQLiteColumnDefinition[];
|
||||
}
|
||||
|
||||
export function createCategoryTemplate(input: CreateCategoryInput): CategoryTemplate {
|
||||
const db = getDatabase();
|
||||
const name = input.name.trim().toLowerCase();
|
||||
validateCategoryName(name);
|
||||
|
||||
const existing = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
|
||||
.get(name) as { name: string } | undefined;
|
||||
|
||||
if (existing) {
|
||||
throw new Error("Categoria já existe.");
|
||||
}
|
||||
|
||||
const userColumns = (input.columns ?? []).map((column) => ({
|
||||
...column,
|
||||
type: normalizeColumnType(column.type),
|
||||
}));
|
||||
|
||||
const columnsSql = [
|
||||
...BASE_COLUMNS.map((column) => {
|
||||
const pieces = [column.name, sqliteTypeFromColumn(column)];
|
||||
if (column.primaryKey) pieces.push("PRIMARY KEY AUTOINCREMENT");
|
||||
if (column.notNull) pieces.push("NOT NULL");
|
||||
return pieces.join(" ");
|
||||
}),
|
||||
...userColumns.map((column) => {
|
||||
const pieces = [column.name, sqliteTypeFromColumn(column)];
|
||||
if (column.notNull) pieces.push("NOT NULL");
|
||||
if (column.defaultValue !== undefined && column.defaultValue !== null) {
|
||||
pieces.push(`DEFAULT ${JSON.stringify(column.defaultValue)}`);
|
||||
}
|
||||
return pieces.join(" ");
|
||||
}),
|
||||
];
|
||||
|
||||
const createSql = `CREATE TABLE ${name} (${columnsSql.join(", ")})`;
|
||||
db.prepare(createSql).run();
|
||||
ensureImageDirectory(name);
|
||||
|
||||
return {
|
||||
name,
|
||||
columns: [...BASE_COLUMNS, ...userColumns],
|
||||
imagePath: path.join("data", "images", name),
|
||||
} satisfies CategoryTemplate;
|
||||
}
|
||||
|
||||
export interface AddColumnInput {
|
||||
category: string;
|
||||
column: SQLiteColumnDefinition;
|
||||
}
|
||||
|
||||
export function addColumnToCategory(input: AddColumnInput): SQLiteColumnDefinition {
|
||||
const db = getDatabase();
|
||||
const name = input.category.trim().toLowerCase();
|
||||
validateCategoryName(name);
|
||||
|
||||
const column = {
|
||||
...input.column,
|
||||
type: normalizeColumnType(input.column.type),
|
||||
} satisfies SQLiteColumnDefinition;
|
||||
|
||||
const columnSqlParts = [column.name, sqliteTypeFromColumn(column)];
|
||||
if (column.notNull) columnSqlParts.push("NOT NULL");
|
||||
if (column.defaultValue !== undefined && column.defaultValue !== null) {
|
||||
columnSqlParts.push(`DEFAULT ${JSON.stringify(column.defaultValue)}`);
|
||||
}
|
||||
|
||||
const sql = `ALTER TABLE ${name} ADD COLUMN ${columnSqlParts.join(" ")}`;
|
||||
db.prepare(sql).run();
|
||||
|
||||
return column;
|
||||
}
|
||||
|
||||
export interface UpsertItemInput {
|
||||
category: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function insertCategoryItem(input: UpsertItemInput): number {
|
||||
const db = getDatabase();
|
||||
const category = input.category.trim().toLowerCase();
|
||||
validateCategoryName(category);
|
||||
|
||||
const data = { ...input.data };
|
||||
delete data.id;
|
||||
|
||||
const keys = Object.keys(data);
|
||||
const placeholders = keys.map(() => "?").join(", ");
|
||||
const sql = `INSERT INTO ${category} (${keys.join(", ")}) VALUES (${placeholders})`;
|
||||
|
||||
const values = keys.map((key) => {
|
||||
const value = data[key];
|
||||
if (typeof value === "boolean") {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
const result = db.prepare(sql).run(...values);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function updateCategoryItem(category: string, id: number, data: Record<string, unknown>) {
|
||||
const db = getDatabase();
|
||||
const normalized = category.trim().toLowerCase();
|
||||
validateCategoryName(normalized);
|
||||
|
||||
const payload = { ...data };
|
||||
delete payload.id;
|
||||
|
||||
const keys = Object.keys(payload);
|
||||
const assignments = keys.map((key) => `${key} = ?`).join(", ");
|
||||
const sql = `UPDATE ${normalized} SET ${assignments} WHERE id = ?`;
|
||||
const values = keys.map((key) => {
|
||||
const value = payload[key];
|
||||
if (typeof value === "boolean") return value ? 1 : 0;
|
||||
return value;
|
||||
});
|
||||
|
||||
db.prepare(sql).run(...values, id);
|
||||
}
|
||||
|
||||
export function getCategoryItem(category: string, id: number): Record<string, unknown> | null {
|
||||
const db = getDatabase();
|
||||
const normalized = category.trim().toLowerCase();
|
||||
validateCategoryName(normalized);
|
||||
|
||||
const row = db.prepare(`SELECT * FROM ${normalized} WHERE id = ?`).get(id);
|
||||
if (!row) return null;
|
||||
|
||||
const result: Record<string, unknown> = { ...row };
|
||||
for (const [key, value] of Object.entries(result)) {
|
||||
if (typeof value === "number") {
|
||||
const template = BASE_COLUMNS.find((column) => column.name === key);
|
||||
if (!template) {
|
||||
// Heuristic: columns stored as BOOLEAN use 0/1
|
||||
// If column type is INTEGER but values only 0/1, mantain numeric to allow consumer decide.
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+293
-69
@@ -3,8 +3,18 @@
|
||||
* Busca itens da tabela unificada items (PostgreSQL)
|
||||
*/
|
||||
|
||||
import { prisma } from "../app";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../app";
|
||||
import { ensureItemsSeeded } from "../../items/import-from-sqlite";
|
||||
|
||||
export type ItemSort =
|
||||
| "name"
|
||||
| "level-asc"
|
||||
| "level-desc"
|
||||
| "weight-asc"
|
||||
| "weight-desc"
|
||||
| "category"
|
||||
| "updated-desc";
|
||||
|
||||
export interface Item {
|
||||
item_id: number;
|
||||
@@ -19,41 +29,178 @@ export interface Item {
|
||||
weight: number | null;
|
||||
slots: number | null;
|
||||
skill: string | null;
|
||||
metadata: any;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
metadata: Record<string, any>;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
}
|
||||
|
||||
export interface ItemWithMeta extends Item {
|
||||
servers: string[];
|
||||
tags: string[];
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface SearchItemsParams {
|
||||
q?: string;
|
||||
category?: string;
|
||||
rarity?: string;
|
||||
server?: string;
|
||||
sort?: ItemSort;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca um item por ID unificado
|
||||
*/
|
||||
export async function getItemById(itemId: number): Promise<Item | null> {
|
||||
const item = await prisma.item.findUnique({
|
||||
where: { item_id: itemId },
|
||||
});
|
||||
export interface AdminSearchParams extends SearchItemsParams {
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
export interface AdminItemsResult {
|
||||
items: ItemWithMeta[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
servers: string[];
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export interface ItemUpsertInput {
|
||||
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;
|
||||
servers?: string[];
|
||||
tags?: string[];
|
||||
description?: string | null;
|
||||
source_table_name?: string;
|
||||
source_table_id?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
function buildOrder(sort: ItemSort): Prisma.ItemOrderByWithRelationInput[] {
|
||||
switch (sort) {
|
||||
case "level-asc":
|
||||
return [{ level: "asc" }, { item_name: "asc" }];
|
||||
case "level-desc":
|
||||
return [{ level: "desc" }, { item_name: "asc" }];
|
||||
case "weight-asc":
|
||||
return [{ weight: "asc" }, { item_name: "asc" }];
|
||||
case "weight-desc":
|
||||
return [{ weight: "desc" }, { item_name: "asc" }];
|
||||
case "category":
|
||||
return [{ category: "asc" }, { item_name: "asc" }];
|
||||
case "name":
|
||||
return [{ item_name: "asc" }];
|
||||
case "updated-desc":
|
||||
default:
|
||||
return [{ updated_at: "desc" }];
|
||||
}
|
||||
}
|
||||
|
||||
function parseMetadata(metadata: Prisma.JsonValue | null | undefined): Record<string, any> {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return {};
|
||||
}
|
||||
return metadata as Record<string, any>;
|
||||
}
|
||||
|
||||
function extractServers(meta: Record<string, any>): string[] {
|
||||
const servers = meta?.servers;
|
||||
if (Array.isArray(servers)) {
|
||||
return servers
|
||||
.map((server) => (typeof server === "string" ? server.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof servers === "string") {
|
||||
return servers
|
||||
.split(",")
|
||||
.map((server) => server.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractTags(meta: Record<string, any>): string[] {
|
||||
const tags = meta?.tags;
|
||||
if (Array.isArray(tags)) {
|
||||
return tags
|
||||
.map((tag) => (typeof tag === "string" ? tag.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof tags === "string") {
|
||||
return tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildItem(raw: any): Item {
|
||||
const createdAt = raw.created_at instanceof Date ? raw.created_at.toISOString() : raw.created_at;
|
||||
const updatedAt = raw.updated_at instanceof Date ? raw.updated_at.toISOString() : raw.updated_at;
|
||||
|
||||
return {
|
||||
...item,
|
||||
weight: item.weight ? Number(item.weight) : null,
|
||||
metadata: item.metadata || {},
|
||||
...raw,
|
||||
weight: raw.weight ? Number(raw.weight) : null,
|
||||
metadata: parseMetadata(raw.metadata),
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
} as Item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca um item por nome (exato ou similar)
|
||||
*/
|
||||
function buildItemWithMeta(raw: any): ItemWithMeta {
|
||||
const base = buildItem(raw);
|
||||
const servers = extractServers(base.metadata);
|
||||
const tags = extractTags(base.metadata);
|
||||
const description = typeof base.metadata.description === "string" ? base.metadata.description : null;
|
||||
|
||||
return {
|
||||
...base,
|
||||
servers,
|
||||
tags,
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
function toDecimal(value: number | null | undefined): Prisma.Decimal | null {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
return new Prisma.Decimal(value);
|
||||
}
|
||||
|
||||
function buildMetadata(
|
||||
existing: Record<string, any>,
|
||||
overrides: { servers?: string[]; tags?: string[]; description?: string | null; metadata?: Record<string, any> }
|
||||
): Record<string, any> {
|
||||
const merged = { ...existing, ...(overrides.metadata || {}) };
|
||||
|
||||
if (overrides.servers) {
|
||||
merged.servers = overrides.servers;
|
||||
}
|
||||
if (overrides.tags) {
|
||||
merged.tags = overrides.tags;
|
||||
}
|
||||
if (overrides.description !== undefined) {
|
||||
merged.description = overrides.description;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function getItemById(itemId: number): Promise<Item | null> {
|
||||
const item = await prisma.item.findUnique({ where: { item_id: itemId } });
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return buildItem(item);
|
||||
}
|
||||
|
||||
export async function getItemByName(itemName: string): Promise<Item | null> {
|
||||
const item = await prisma.item.findFirst({
|
||||
where: {
|
||||
@@ -63,32 +210,42 @@ export async function getItemByName(itemName: string): Promise<Item | null> {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
weight: item.weight ? Number(item.weight) : null,
|
||||
metadata: item.metadata || {},
|
||||
} as Item;
|
||||
return buildItem(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca itens com filtros
|
||||
*/
|
||||
export async function searchItems(
|
||||
params: SearchItemsParams = {}
|
||||
): Promise<{ items: Item[]; total: number }> {
|
||||
export async function getItemBySource(sourceTableName: string, sourceTableId: number): Promise<Item | null> {
|
||||
const item = await prisma.item.findUnique({
|
||||
where: {
|
||||
source_table_name_source_table_id: {
|
||||
source_table_name: sourceTableName,
|
||||
source_table_id: sourceTableId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return buildItem(item);
|
||||
}
|
||||
|
||||
export async function searchAdminItems(params: AdminSearchParams = {}): Promise<AdminItemsResult> {
|
||||
const {
|
||||
q = "",
|
||||
category,
|
||||
rarity,
|
||||
server,
|
||||
page = 1,
|
||||
pageSize = 24,
|
||||
pageSize = 50,
|
||||
} = params;
|
||||
|
||||
await ensureItemsSeeded();
|
||||
|
||||
const safePage = Math.max(1, page);
|
||||
const safePageSize = Math.min(Math.max(1, pageSize), 200);
|
||||
|
||||
const where: Prisma.ItemWhereInput = {};
|
||||
|
||||
if (q) {
|
||||
@@ -98,58 +255,125 @@ export async function searchItems(
|
||||
};
|
||||
}
|
||||
|
||||
if (category) {
|
||||
if (category && category !== "all") {
|
||||
where.category = category;
|
||||
}
|
||||
|
||||
if (rarity) {
|
||||
if (rarity && rarity !== "all") {
|
||||
where.rarity = rarity;
|
||||
}
|
||||
|
||||
const total = await prisma.item.count({ where });
|
||||
if (server && server !== "all") {
|
||||
const existingAnd = Array.isArray(where.AND)
|
||||
? [...where.AND]
|
||||
: where.AND
|
||||
? [where.AND]
|
||||
: [];
|
||||
|
||||
const items = await prisma.item.findMany({
|
||||
where,
|
||||
orderBy: { item_name: "asc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
existingAnd.push({
|
||||
metadata: {
|
||||
path: ["servers"],
|
||||
array_contains: [server],
|
||||
},
|
||||
});
|
||||
|
||||
where.AND = existingAnd;
|
||||
}
|
||||
|
||||
const [items, total, categoryRows] = await Promise.all([
|
||||
prisma.item.findMany({
|
||||
where,
|
||||
orderBy: buildOrder(params.sort || "updated-desc"),
|
||||
skip: (safePage - 1) * safePageSize,
|
||||
take: safePageSize,
|
||||
}),
|
||||
prisma.item.count({ where }),
|
||||
prisma.item.findMany({
|
||||
distinct: ["category"],
|
||||
select: { category: true },
|
||||
orderBy: { category: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const mapped = items.map(buildItemWithMeta);
|
||||
|
||||
const serversSet = new Set<string>();
|
||||
mapped.forEach((item) => {
|
||||
item.servers.forEach((srv) => serversSet.add(srv));
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((item: any) => ({
|
||||
...item,
|
||||
weight: item.weight ? Number(item.weight) : null,
|
||||
metadata: item.metadata || {},
|
||||
})) as Item[],
|
||||
items: mapped,
|
||||
total,
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
servers: Array.from(serversSet).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
|
||||
categories: categoryRows
|
||||
.map((row) => row.category)
|
||||
.filter((c): c is string => Boolean(c))
|
||||
.sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca item por source_table_name e source_table_id
|
||||
* (útil para migração ou compatibilidade com IDs antigos)
|
||||
*/
|
||||
export async function getItemBySource(
|
||||
sourceTableName: string,
|
||||
sourceTableId: number
|
||||
): Promise<Item | null> {
|
||||
const item = await prisma.item.findUnique({
|
||||
where: {
|
||||
source_table_name_source_table_id: {
|
||||
source_table_name: sourceTableName,
|
||||
source_table_id: sourceTableId,
|
||||
},
|
||||
},
|
||||
export async function searchItems(params: SearchItemsParams = {}): Promise<{ items: ItemWithMeta[]; total: number }> {
|
||||
const { items, total } = await searchAdminItems(params);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
export async function createAdminItem(input: ItemUpsertInput): Promise<ItemWithMeta> {
|
||||
const now = Date.now();
|
||||
const data: Prisma.ItemCreateInput = {
|
||||
source_table_id: input.source_table_id ?? now,
|
||||
source_table_name: input.source_table_name ?? "custom",
|
||||
item_name: input.item_name,
|
||||
category: input.category,
|
||||
image_local_path: input.image_local_path ?? null,
|
||||
image_url: input.image_url ?? null,
|
||||
rarity: input.rarity ?? null,
|
||||
level: input.level ?? null,
|
||||
weight: toDecimal(input.weight),
|
||||
slots: input.slots ?? null,
|
||||
skill: input.skill ?? null,
|
||||
metadata: buildMetadata({}, {
|
||||
servers: input.servers ?? [],
|
||||
tags: input.tags ?? [],
|
||||
description: input.description ?? null,
|
||||
metadata: input.metadata ?? {},
|
||||
}),
|
||||
};
|
||||
|
||||
const created = await prisma.item.create({ data });
|
||||
return buildItemWithMeta(created);
|
||||
}
|
||||
|
||||
export async function updateAdminItem(itemId: number, input: ItemUpsertInput): Promise<ItemWithMeta> {
|
||||
const existing = await prisma.item.findUnique({ where: { item_id: itemId } });
|
||||
if (!existing) {
|
||||
throw new Error("Item não encontrado");
|
||||
}
|
||||
|
||||
const existingMeta = parseMetadata(existing.metadata);
|
||||
const metadata = buildMetadata(existingMeta, {
|
||||
servers: input.servers ?? extractServers(existingMeta),
|
||||
tags: input.tags ?? extractTags(existingMeta),
|
||||
description: input.description !== undefined ? input.description : existingMeta.description,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
const data: Prisma.ItemUpdateInput = {
|
||||
item_name: input.item_name ?? existing.item_name,
|
||||
category: input.category ?? existing.category,
|
||||
image_local_path: input.image_local_path !== undefined ? input.image_local_path : existing.image_local_path,
|
||||
image_url: input.image_url !== undefined ? input.image_url : existing.image_url,
|
||||
rarity: input.rarity !== undefined ? input.rarity : existing.rarity,
|
||||
level: input.level !== undefined ? input.level : existing.level,
|
||||
weight: input.weight !== undefined ? toDecimal(input.weight) : existing.weight,
|
||||
slots: input.slots !== undefined ? input.slots : existing.slots,
|
||||
skill: input.skill !== undefined ? input.skill : existing.skill,
|
||||
metadata,
|
||||
};
|
||||
|
||||
return {
|
||||
...item,
|
||||
weight: item.weight ? Number(item.weight) : null,
|
||||
metadata: item.metadata || {},
|
||||
} as Item;
|
||||
const updated = await prisma.item.update({ where: { item_id: itemId }, data });
|
||||
return buildItemWithMeta(updated);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cobe": "^0.6.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.540.0",
|
||||
"mongoose": "^8.19.3",
|
||||
|
||||
Generated
+8
@@ -80,6 +80,9 @@ importers:
|
||||
cobe:
|
||||
specifier: ^0.6.5
|
||||
version: 0.6.5
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
@@ -1468,6 +1471,9 @@ packages:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -3488,6 +3494,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
@@ -9,212 +9,20 @@
|
||||
|
||||
import { config } from "dotenv";
|
||||
import { resolve } from "path";
|
||||
import path from "node:path";
|
||||
|
||||
// Carregar variáveis de ambiente
|
||||
config({ path: resolve(process.cwd(), ".env.local") });
|
||||
config({ path: resolve(process.cwd(), ".env") });
|
||||
|
||||
import { getPool, query } from "../lib/db/index";
|
||||
import { enToPtCategoryMap } from "../lib/item-images";
|
||||
|
||||
// Mapeamento de tabelas PT para categorias EN
|
||||
const ptToEnCategoryMap: Record<string, string> = {
|
||||
espadas: "weapon",
|
||||
armaduras: "armor",
|
||||
capacetes: "helmet",
|
||||
calcas: "legs",
|
||||
botas: "boots",
|
||||
escudos: "shield",
|
||||
amuletos: "amulet",
|
||||
aneis: "ring",
|
||||
runas: "rune",
|
||||
ferramentas: "tool",
|
||||
municao: "ammunition",
|
||||
};
|
||||
|
||||
function getDatabase() {
|
||||
let Database: any;
|
||||
try {
|
||||
Database = require("better-sqlite3");
|
||||
} catch (err) {
|
||||
console.error("better-sqlite3 não está instalado");
|
||||
return null;
|
||||
}
|
||||
|
||||
const dbPath = path.join(process.cwd(), "data", "tibiawiki.db");
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
return db;
|
||||
}
|
||||
|
||||
interface ItemRow {
|
||||
id: number;
|
||||
nome: string;
|
||||
image_url?: string;
|
||||
image_local_path?: string;
|
||||
rarity?: string;
|
||||
lvl?: number;
|
||||
peso?: number;
|
||||
peso_em_oz?: number;
|
||||
slots?: number;
|
||||
skill_para_usar?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
import { importItemsFromSQLite } from "@/lib/items/import-from-sqlite";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
|
||||
async function populateItemsTable() {
|
||||
console.log("🚀 Iniciando população da tabela items...\n");
|
||||
config({ path: resolve(process.cwd(), ".env.local") });
|
||||
config({ path: resolve(process.cwd(), ".env") });
|
||||
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
console.error("⌠Não foi possÃvel conectar ao SQLite");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
// Obter todas as tabelas
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
|
||||
console.log(`📋 Encontradas ${tables.length} tabelas\n`);
|
||||
|
||||
let totalInserted = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const table of tables) {
|
||||
const tableName = table.name;
|
||||
const categoryEn = ptToEnCategoryMap[tableName] || "other";
|
||||
|
||||
console.log(`📦 Processando tabela: ${tableName} (categoria: ${categoryEn})...`);
|
||||
|
||||
try {
|
||||
// Verificar se a tabela tem as colunas necessárias
|
||||
const columns = db
|
||||
.prepare(`PRAGMA table_info(${tableName})`)
|
||||
.all() as { name: string; type: string }[];
|
||||
|
||||
const colNames = new Set(columns.map((c) => c.name.toLowerCase()));
|
||||
|
||||
if (!colNames.has("id") || !colNames.has("nome")) {
|
||||
console.log(` âš ï¸ Tabela ${tableName} não tem colunas id/nome, pulando...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Buscar todos os itens da tabela
|
||||
const items = db.prepare(`SELECT * FROM ${tableName}`).all() as ItemRow[];
|
||||
|
||||
console.log(` 📊 Encontrados ${items.length} itens`);
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
// Verificar se já existe (baseado em source_table_name + source_table_id)
|
||||
const existing = await query<{ item_id: number }>(
|
||||
`SELECT item_id FROM items
|
||||
WHERE source_table_name = $1 AND source_table_id = $2`,
|
||||
[tableName, item.id]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
totalSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Preparar dados do item
|
||||
const itemName = item.nome || "";
|
||||
const imageUrl = item.image_url || null;
|
||||
const imageLocalPath = item.image_local_path || null;
|
||||
const rarity = item.rarity || null;
|
||||
const level = item.lvl || null;
|
||||
|
||||
// Peso (pode ser em peso ou peso_em_oz)
|
||||
const weight = item.peso || item.peso_em_oz || null;
|
||||
|
||||
const slots = item.slots || null;
|
||||
const skill = item.skill_para_usar || null;
|
||||
|
||||
// Metadata com campos extras
|
||||
const metadata: Record<string, any> = {};
|
||||
|
||||
// Adicionar campos extras ao metadata
|
||||
const extraFields = [
|
||||
"voc",
|
||||
"volume",
|
||||
"bonus_de_skill",
|
||||
"protecao",
|
||||
"dropa_de",
|
||||
];
|
||||
|
||||
for (const field of extraFields) {
|
||||
if (colNames.has(field) && item[field] !== undefined && item[field] !== null) {
|
||||
metadata[field] = item[field];
|
||||
}
|
||||
}
|
||||
|
||||
// Inserir na tabela items
|
||||
await query(
|
||||
`INSERT INTO items (
|
||||
source_table_id,
|
||||
source_table_name,
|
||||
item_name,
|
||||
category,
|
||||
image_local_path,
|
||||
image_url,
|
||||
rarity,
|
||||
level,
|
||||
weight,
|
||||
slots,
|
||||
skill,
|
||||
metadata
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
[
|
||||
item.id,
|
||||
tableName,
|
||||
itemName,
|
||||
categoryEn,
|
||||
imageLocalPath,
|
||||
imageUrl,
|
||||
rarity,
|
||||
level,
|
||||
weight,
|
||||
slots,
|
||||
skill,
|
||||
JSON.stringify(metadata),
|
||||
]
|
||||
);
|
||||
|
||||
totalInserted++;
|
||||
} catch (error: any) {
|
||||
if (error.code === "23505") {
|
||||
// Unique constraint violation - já existe
|
||||
totalSkipped++;
|
||||
} else {
|
||||
console.error(` ⌠Erro ao inserir item ${item.nome}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ✅ Tabela ${tableName} processada\n`);
|
||||
} catch (error: any) {
|
||||
console.error(` ⌠Erro ao processar tabela ${tableName}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n📊 Resumo:");
|
||||
console.log(` ✅ Itens inseridos: ${totalInserted}`);
|
||||
console.log(` âï¸ Itens ignorados (já existentes): ${totalSkipped}`);
|
||||
console.log(` 📦 Total processado: ${totalInserted + totalSkipped}\n`);
|
||||
|
||||
console.log("✨ População concluÃda com sucesso!");
|
||||
} finally {
|
||||
db.close();
|
||||
const pool = getPool();
|
||||
await pool.end();
|
||||
}
|
||||
secureLogger.info("Iniciando importação manual de itens (script standalone).");
|
||||
const stats = await importItemsFromSQLite();
|
||||
secureLogger.info("Importação manual finalizada.", stats);
|
||||
}
|
||||
|
||||
// Executar
|
||||
populateItemsTable().catch((error) => {
|
||||
console.error("⌠Erro fatal:", error);
|
||||
secureLogger.error("Erro ao executar importação manual de itens", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user