Merge pull request #4 from evandrodevbr:main

Main
This commit is contained in:
Evandro Fonseca Junior authored and GitHub committed 2025-10-30 18:50:25 -03:00
commit 1671e4df58
2597 files changed
+5041 -169

No files matched your search

+258
View File
@@ -0,0 +1,258 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { MarketplaceFilters } from "@/components/marketplace-filters";
import { ItemCard } from "@/components/item-card";
interface Item {
id: string | number;
name: string;
category: string;
image?: string;
rarity?: string;
npcPrice?: number;
price?: { buy: number; sell: number };
priceHistory?: { highest: number; lowest: number; average: number };
level?: number;
weight?: number;
slots?: number;
skill?: string;
}
interface InitialData {
items: Item[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export default function LibraryClient({ initial }: { initial: InitialData }) {
const [items, setItems] = useState<Item[]>(initial.items);
const [total, setTotal] = useState<number>(initial.total);
const [page, setPage] = useState<number>(initial.page || 1);
const [pageSize] = useState<number>(initial.pageSize || 24);
const [totalPages, setTotalPages] = useState<number>(initial.totalPages || 1);
const [query, setQuery] = useState<string>("");
const [categories, setCategories] = useState<string[]>([]);
const [rarities, setRarities] = useState<string[]>([]);
const [sort, setSort] = useState<string>("name");
const [categoryOptions, setCategoryOptions] = useState<{ value: string; label: string }[] | undefined>(undefined);
const [vocation, setVocation] = useState<string | null>(null);
const debounceRef = useRef<number | undefined>(undefined);
const searchParams = useMemo(() => {
const p = new URLSearchParams();
if (query) p.set("q", query);
if (categories.length > 0) p.set("category", categories.join(","));
if (rarities.length > 0) p.set("rarity", rarities.join(","));
if (sort) p.set("sort", sort);
if (vocation) p.set("voc", vocation);
p.set("page", String(page));
p.set("pageSize", String(pageSize));
return p;
}, [query, categories, rarities, sort, page, pageSize, vocation]);
useEffect(() => {
// Debounce para evitar chamadas excessivas
window.clearTimeout(debounceRef.current);
debounceRef.current = window.setTimeout(async () => {
const res = await fetch(`/api/items?${searchParams.toString()}`);
if (!res.ok) return;
const data = await res.json();
setItems(data.items);
setTotal(data.total);
setTotalPages(data.totalPages);
}, 250);
return () => window.clearTimeout(debounceRef.current);
}, [searchParams]);
// Carregar categorias dinâmicas a partir das tabelas do banco
useEffect(() => {
(async () => {
try {
const res = await fetch("/api/items/categories");
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.categories)) {
const opts = (data.categories as string[]).map((c) => ({ value: c, label: c.charAt(0).toUpperCase() + c.slice(1) }));
setCategoryOptions(opts);
}
} catch {
// no-op
}
})();
}, []);
const onSearch = (q: string) => {
setPage(1);
setQuery(q);
};
const onFilter = (f: { rarity: string[]; category: string[]; priceRange?: { min: number; max: number }; vocation?: string | null }) => {
setPage(1);
setCategories(f.category || []);
setRarities(f.rarity || []);
setVocation(f.vocation ?? null);
};
const onSort = (s: string) => {
setPage(1);
setSort(s);
};
return (
<div className="space-y-8">
<MarketplaceFilters onSearch={onSearch} onFilter={onFilter} onSort={onSort} totalItems={total} showRarity={false} categoryOptionsOverride={categoryOptions} />
<section className="grid gap-6 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 grid-flow-row-dense">
{items.map((item) => {
const anyItem = item as any;
// Normalização de categoria para os ícones do ItemCard
const normalizeCategory = (c: string | undefined): string => {
const v = (c || "").toLowerCase();
const map: Record<string, string> = {
escudos: "shield",
armaduras: "armor",
capacetes: "helmet",
calcas: "legs",
botas: "boots",
amuletos: "amulet",
aneis: "ring",
runas: "rune",
ferramentas: "tool",
municao: "ammunition",
aljavas: "ammunition",
distancia: "weapon",
espadas: "weapon",
machados: "weapon",
clavas: "weapon",
punhos: "weapon",
wands: "weapon",
rods: "weapon",
spellbooks: "other",
chaves: "other",
comidas: "other",
decoracoes: "other",
liquidos: "other",
plantas: "other",
recipientes: "other",
trofeus: "other",
};
return map[v] || v || "other";
};
const categoryPt = typeof anyItem.categoria === "string" ? anyItem.categoria : undefined;
const category = normalizeCategory(item.category ?? categoryPt);
const name = item.name ?? anyItem.nome ?? String(item.id);
const image = item.image ?? anyItem.image_url ?? "";
// Campos numéricos normalizados
const level: number | undefined =
typeof item.level === "number"
? item.level
: typeof anyItem.nivel === "number"
? anyItem.nivel
: typeof anyItem.lvl === "number"
? anyItem.lvl
: undefined;
const weight: number | undefined =
typeof item.weight === "number"
? item.weight
: typeof anyItem.peso === "number"
? anyItem.peso
: typeof anyItem.peso_em_oz === "number"
? anyItem.peso_em_oz
: undefined;
const slots: number | undefined =
typeof item.slots === "number"
? item.slots
: typeof anyItem.slots === "number"
? anyItem.slots
: undefined;
const attack: number | undefined =
typeof anyItem.ataque === "number"
? anyItem.ataque
: typeof anyItem.atq === "number"
? anyItem.atq
: undefined;
const defense: number | undefined =
typeof anyItem.defesa === "number"
? anyItem.defesa
: typeof anyItem.def === "number"
? anyItem.def
: undefined;
const armor: number | undefined =
typeof anyItem.arm === "number" ? anyItem.arm : undefined;
// Skill/requisito para usar
const skill: string | undefined =
typeof item.skill === "string" && item.skill !== "Nenhum."
? item.skill
: typeof anyItem.skill_para_usar === "string" && anyItem.skill_para_usar !== "Nenhum."
? anyItem.skill_para_usar
: undefined;
const vocation: string | undefined =
typeof anyItem.voc === "string" && anyItem.voc
? anyItem.voc
: undefined;
return (
<ItemCard
key={`${String((anyItem as any).category)}::${String(item.id)}::${name}`}
item={{
id: String(item.id),
name,
image,
category: category as any,
rarity: (item.rarity as any) ?? "common",
categoryPt,
stats: {
weight,
slots,
attack,
defense,
armor,
} as any,
requirements: {
level,
skill,
vocation,
} as any,
// Campos específicos de aljavas (e reutilizáveis para outras categorias)
volume: typeof anyItem.volume === "number" ? anyItem.volume : undefined,
bonus_de_skill: typeof anyItem.bonus_de_skill === "number" ? anyItem.bonus_de_skill : undefined,
protecao: typeof anyItem.protecao === "string" ? anyItem.protecao : undefined,
dropa_de: typeof anyItem.dropa_de === "string" ? anyItem.dropa_de : undefined,
} as any}
mode="catalog"
/>
);
})}
</section>
<div className="flex items-center justify-center gap-2">
<button
className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Anterior
</button>
<span className="text-sm text-muted-foreground">Página {page} de {totalPages}</span>
<button
className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Próxima
</button>
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import Header from "@/components/Header";
import { getTranslations } from "next-intl/server";
import LibraryClient from "./LibraryClient";
interface Item {
id: string;
name: string;
category: string;
image?: string;
rarity?: string;
npcPrice?: number;
price?: { buy: number; sell: number };
priceHistory?: { highest: number; lowest: number; average: number };
stats?: any;
requirements?: any;
protections?: any;
}
async function fetchItems(params: URLSearchParams) {
const qs = params.toString();
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? ""}/api/items?${qs}`, {
// Em ambiente server, revalidação curta para manter leve
next: { revalidate: 15 },
});
if (!res.ok) {
return { items: [], total: 0, page: 1, pageSize: 24, totalPages: 1 };
}
return res.json();
}
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "underConstruction" });
return {
title: t("market.title"),
description: t("market.description"),
};
}
export default async function LibraryPage({ params, searchParams }: {
params: Promise<{ locale: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const { locale } = await params;
const sp = (await searchParams) ?? {};
const initial = new URLSearchParams();
if (typeof sp.q === "string") initial.set("q", sp.q);
if (typeof sp.sort === "string") initial.set("sort", sp.sort);
if (typeof sp.page === "string") initial.set("page", sp.page);
if (typeof sp.pageSize === "string") initial.set("pageSize", sp.pageSize);
if (typeof sp.category === "string") initial.set("category", sp.category);
if (typeof sp.rarity === "string") initial.set("rarity", sp.rarity);
const data = await fetchItems(initial);
return (
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Biblioteca de Itens</h1>
<div className="text-sm text-muted-foreground">
{data.total} {data.total === 1 ? "item" : "itens"}
</div>
</div>
<LibraryClient initial={data} />
</main>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest } from "next/server";
import fs from "node:fs";
import path from "node:path";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const relPath = searchParams.get("path") || "";
// Permitir apenas caminhos sob data/images para segurança
const baseDir = path.join(process.cwd(), "data", "images");
const resolved = path.join(process.cwd(), relPath);
if (!resolved.startsWith(baseDir)) {
return new Response("Forbidden", { status: 403 });
}
try {
const file = await fs.promises.readFile(resolved);
const ext = path.extname(resolved).toLowerCase();
const contentType =
ext === ".gif"
? "image/gif"
: ext === ".png"
? "image/png"
: ext === ".webp"
? "image/webp"
: ext === ".jpg" || ext === ".jpeg"
? "image/jpeg"
: "application/octet-stream";
return new Response(file, { status: 200, headers: { "Content-Type": contentType } });
} catch {
return new Response("Not Found", { status: 404 });
}
}
+25
View File
@@ -0,0 +1,25 @@
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 });
}
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) });
} catch (e: any) {
return Response.json({ error: e?.message || "Erro" }, { status: 500 });
}
}
+72
View File
@@ -0,0 +1,72 @@
import { NextRequest } from "next/server";
import { searchItems } from "@/lib/items-db";
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())
.filter(Boolean);
}
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 page = Number(searchParams.get("page") || "1");
const pageSize = Number(searchParams.get("pageSize") || "24");
const voc = searchParams.get("voc") || undefined;
const { items, total } = searchItems({
q,
categories,
rarities,
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
if (/^https?:\/\//i.test(img)) {
return { ...it, image: img };
}
if (img.startsWith("data/")) {
return { ...it, image: `/api/images?path=${encodeURIComponent(img)}` };
}
const categoryEn = (it.category || "other").toString();
return { ...it, image: buildPublicItemImageUrl(categoryEn, img) };
});
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
return Response.json(
{ items: itemsWithImage, page, pageSize, total, totalPages },
{ status: 200 }
);
} catch (error: any) {
console.error("[api/items] Erro:", error?.message, error?.stack);
return Response.json(
{
error: "Falha ao consultar itens",
message: error?.message || "Erro desconhecido",
},
{ status: 500 }
);
}
}