feat(item-card): add 'market'/'catalog' mode prop with defensive price checks
Introduces optional `mode` prop ("market"/"catalog") to conditionally render components like rarity badges. Adds defensive checks for missing price data and priceHistory properties to prevent runtime errors. New icons `Badge` and `Boxes` imported from lucide-react.
This commit is contained in:
1 parent
29ba5aece0
commit
44d7f8ce81
21 files changed
+1181
-714
No files matched your search
@@ -0,0 +1,150 @@
|
||||
"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 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);
|
||||
p.set("page", String(page));
|
||||
p.set("pageSize", String(pageSize));
|
||||
return p;
|
||||
}, [query, categories, rarities, sort, page, pageSize]);
|
||||
|
||||
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 } }) => {
|
||||
setPage(1);
|
||||
setCategories(f.category || []);
|
||||
setRarities(f.rarity || []);
|
||||
};
|
||||
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">
|
||||
{items.map((item) => (
|
||||
<ItemCard
|
||||
key={`${String(item.category || "")}::${String(item.id)}`}
|
||||
item={{
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
image: item.image ?? "",
|
||||
category: (item.category as any) ?? "other",
|
||||
rarity: (item.rarity as any) ?? "common",
|
||||
stats: {
|
||||
weight: typeof item.weight === "number" ? item.weight : undefined,
|
||||
slots: typeof item.slots === "number" ? item.slots : undefined,
|
||||
} as any,
|
||||
requirements: {
|
||||
level: typeof item.level === "number" ? item.level : undefined,
|
||||
skill: typeof item.skill === "string" && item.skill !== "Nenhum." ? item.skill : undefined,
|
||||
} as any,
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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"
|
||||
| "price-asc"
|
||||
| "price-desc"
|
||||
| "rarity"
|
||||
| "recent";
|
||||
const page = Number(searchParams.get("page") || "1");
|
||||
const pageSize = Number(searchParams.get("pageSize") || "24");
|
||||
|
||||
const { items, total } = searchItems({
|
||||
q,
|
||||
categories,
|
||||
rarities,
|
||||
sort: sortParam,
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+174
-133
@@ -25,6 +25,8 @@ import {
|
||||
Zap as Energy,
|
||||
Skull,
|
||||
Cross,
|
||||
Badge as BadgeIcon,
|
||||
Boxes,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
@@ -35,9 +37,10 @@ import {
|
||||
|
||||
interface ItemCardProps {
|
||||
item: TibiaItem;
|
||||
mode?: "market" | "catalog";
|
||||
}
|
||||
|
||||
export function ItemCard({ item }: ItemCardProps) {
|
||||
export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
const t = useTranslations("home.item");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -68,17 +71,27 @@ export function ItemCard({ item }: ItemCardProps) {
|
||||
|
||||
const CategoryIcon = categoryIcons[item.category];
|
||||
|
||||
// Calcular diferença percentual entre compra e venda
|
||||
const priceDifference =
|
||||
((item.price.buy - item.price.sell) / item.price.sell) * 100;
|
||||
const isHotDeal = priceDifference > 20; // Hot deal se diferença > 20%
|
||||
// Calcular diferença percentual entre compra e venda (somente quando houver preço)
|
||||
const hasPrice =
|
||||
item &&
|
||||
(item as any).price &&
|
||||
typeof (item as any).price.buy === "number" &&
|
||||
typeof (item as any).price.sell === "number" &&
|
||||
(item as any).price.sell !== 0;
|
||||
const priceDifference = hasPrice
|
||||
? (((item as any).price.buy - (item as any).price.sell) /
|
||||
(item as any).price.sell) * 100
|
||||
: 0;
|
||||
const isHotDeal = hasPrice && priceDifference > 20; // Hot deal se diferença > 20%
|
||||
|
||||
// Determinar tendência de preço
|
||||
const getPriceTrend = () => {
|
||||
if (item.priceHistory.highest > item.priceHistory.average * 1.1)
|
||||
return "up";
|
||||
if (item.priceHistory.lowest < item.priceHistory.average * 0.9)
|
||||
return "down";
|
||||
const ph = (item as any).priceHistory;
|
||||
if (!ph || typeof ph.highest !== "number" || typeof ph.lowest !== "number" || typeof ph.average !== "number") {
|
||||
return "stable";
|
||||
}
|
||||
if (ph.highest > ph.average * 1.1) return "up";
|
||||
if (ph.lowest < ph.average * 0.9) return "down";
|
||||
return "stable";
|
||||
};
|
||||
|
||||
@@ -101,18 +114,20 @@ export function ItemCard({ item }: ItemCardProps) {
|
||||
<MagicCard className="p-0 overflow-hidden h-full flex flex-col rounded-xl border border-border/50 hover:border-primary/30 transition-all duration-300 hover:scale-[1.02] hover:shadow-xl">
|
||||
{/* Header com ícone e badge de raridade */}
|
||||
<div className="relative aspect-square bg-linear-to-br from-background to-muted p-6">
|
||||
{/* Badge de raridade com glow */}
|
||||
<Badge
|
||||
className={`${
|
||||
rarityColors[item.rarity]
|
||||
} text-white absolute top-3 left-3 z-20 shadow-lg ${getRarityGlow(
|
||||
item.rarity
|
||||
)} ${item.rarity === "legendary" ? "animate-pulse" : ""} ${
|
||||
item.rarity === "epic" ? "animate-pulse" : ""
|
||||
}`}
|
||||
>
|
||||
{item.rarity}
|
||||
</Badge>
|
||||
{/* Badge de raridade com glow (apenas em modo market) */}
|
||||
{mode === "market" && (
|
||||
<Badge
|
||||
className={`${
|
||||
rarityColors[item.rarity]
|
||||
} text-white absolute top-3 left-3 z-20 shadow-lg ${getRarityGlow(
|
||||
item.rarity
|
||||
)} ${item.rarity === "legendary" ? "animate-pulse" : ""} ${
|
||||
item.rarity === "epic" ? "animate-pulse" : ""
|
||||
}`}
|
||||
>
|
||||
{item.rarity}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Botão de favorito */}
|
||||
<Button
|
||||
@@ -130,22 +145,34 @@ export function ItemCard({ item }: ItemCardProps) {
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* Hot Deal Badge */}
|
||||
{isHotDeal && (
|
||||
{/* Hot Deal Badge (apenas em modo market) */}
|
||||
{mode === "market" && isHotDeal && (
|
||||
<Badge className="absolute top-3 left-1/2 transform -translate-x-1/2 z-20 bg-red-500 text-white shadow-lg animate-pulse">
|
||||
🔥 Hot Deal
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Ícone do item */}
|
||||
{/* Imagem real do item (se disponível) ou ícone por categoria */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm">
|
||||
{typeof CategoryIcon === "string" ? (
|
||||
CategoryIcon
|
||||
) : (
|
||||
<CategoryIcon className="w-10 h-10" />
|
||||
)}
|
||||
</div>
|
||||
{item.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-20 h-20 object-contain drop-shadow-sm"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm">
|
||||
{typeof CategoryIcon === "string" ? (
|
||||
CategoryIcon
|
||||
) : (
|
||||
<CategoryIcon className="w-10 h-10" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -220,10 +247,17 @@ export function ItemCard({ item }: ItemCardProps) {
|
||||
|
||||
{/* Requisitos */}
|
||||
{item.requirements && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.requirements.level && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
📊 Level {item.requirements.level}+
|
||||
<div className="flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-1 rounded">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>Lvl {item.requirements.level}+</span>
|
||||
</div>
|
||||
)}
|
||||
{(item as any).requirements?.skill && (
|
||||
<div className="flex items-center gap-1 text-xs bg-indigo-500/10 text-indigo-600 px-2 py-1 rounded">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>{(item as any).requirements.skill}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.requirements.vocation && (
|
||||
@@ -240,113 +274,120 @@ export function ItemCard({ item }: ItemCardProps) {
|
||||
<span>⚖️ {item.stats.weight} oz</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(item as any).stats?.slots && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Boxes className="h-3 w-3" />
|
||||
<span>Slots {(item as any).stats?.slots}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divisor visual */}
|
||||
<div className="border-t border-border/50" />
|
||||
{mode === "market" && item.price && (
|
||||
<>
|
||||
{/* Divisor visual */}
|
||||
<div className="border-t border-border/50" />
|
||||
|
||||
{/* Preços com ícones e formatação melhorada */}
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Coins className="h-3 w-3" />
|
||||
<span>{t("buyPrice")}</span>
|
||||
{/* Preços com ícones e formatação melhorada */}
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Coins className="h-3 w-3" />
|
||||
<span>{t("buyPrice")}</span>
|
||||
</div>
|
||||
<PriceDisplay
|
||||
value={item.price.buy}
|
||||
className="text-green-600 dark:text-green-400"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Coins className="h-3 w-3" />
|
||||
<span>{t("sellPrice")}</span>
|
||||
</div>
|
||||
<PriceDisplay
|
||||
value={item.price.sell}
|
||||
className="text-red-600 dark:text-red-400"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<PriceDisplay
|
||||
value={item.price.buy}
|
||||
className="text-green-600 dark:text-green-400"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Coins className="h-3 w-3" />
|
||||
<span>{t("sellPrice")}</span>
|
||||
</div>
|
||||
<PriceDisplay
|
||||
value={item.price.sell}
|
||||
className="text-red-600 dark:text-red-400"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diferença percentual */}
|
||||
<div className="text-center">
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
priceDifference > 0 ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
{/* Diferença percentual */}
|
||||
<div className="text-center">
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
priceDifference > 0 ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{priceDifference > 0 ? "+" : ""}
|
||||
{priceDifference.toFixed(1)}% diferença
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "market" && item.priceHistory && (
|
||||
<>
|
||||
{/* Divisor visual */}
|
||||
<div className="border-t border-border/50" />
|
||||
|
||||
{/* Histórico de preço com sparkline */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("priceHistory")}
|
||||
</span>
|
||||
<div className={`flex items-center gap-1 text-xs ${trendColor}`}>
|
||||
<TrendIcon className="h-3 w-3" />
|
||||
<span className="capitalize">{trend}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="flex items-center justify-center gap-1 text-green-600">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
<span className="font-medium">{t("highest")}</span>
|
||||
</div>
|
||||
<div className="font-bold">🪙 {item.priceHistory.highest.toLocaleString()} gp</div>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<div className="flex items-center justify-center gap-1 text-red-600">
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
<span className="font-medium">{t("lowest")}</span>
|
||||
</div>
|
||||
<div className="font-bold">🪙 {item.priceHistory.lowest.toLocaleString()} gp</div>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<div className="flex items-center justify-center gap-1 text-muted-foreground">
|
||||
<Minus className="h-3 w-3" />
|
||||
<span className="font-medium">{t("average")}</span>
|
||||
</div>
|
||||
<div className="font-bold">🪙 {item.priceHistory.average.toLocaleString()} gp</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "market" && (
|
||||
<div className="pt-4 space-y-2">
|
||||
<Button
|
||||
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
|
||||
variant="outline"
|
||||
>
|
||||
{priceDifference > 0 ? "+" : ""}
|
||||
{priceDifference.toFixed(1)}% diferença
|
||||
</span>
|
||||
{t("viewDetails")}
|
||||
</Button>
|
||||
<Button className="w-full bg-green-600 hover:bg-green-700 text-white" size="sm">
|
||||
{t("buyNow")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divisor visual */}
|
||||
<div className="border-t border-border/50" />
|
||||
|
||||
{/* Histórico de preço com sparkline */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("priceHistory")}
|
||||
</span>
|
||||
<div className={`flex items-center gap-1 text-xs ${trendColor}`}>
|
||||
<TrendIcon className="h-3 w-3" />
|
||||
<span className="capitalize">{trend}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="flex items-center justify-center gap-1 text-green-600">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
<span className="font-medium">{t("highest")}</span>
|
||||
</div>
|
||||
<div className="font-bold">
|
||||
🪙 {item.priceHistory.highest.toLocaleString()} gp
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<div className="flex items-center justify-center gap-1 text-red-600">
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
<span className="font-medium">{t("lowest")}</span>
|
||||
</div>
|
||||
<div className="font-bold">
|
||||
🪙 {item.priceHistory.lowest.toLocaleString()} gp
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<div className="flex items-center justify-center gap-1 text-muted-foreground">
|
||||
<Minus className="h-3 w-3" />
|
||||
<span className="font-medium">{t("average")}</span>
|
||||
</div>
|
||||
<div className="font-bold">
|
||||
🪙 {item.priceHistory.average.toLocaleString()} gp
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botões de ação */}
|
||||
<div className="pt-4 space-y-2">
|
||||
<Button
|
||||
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
|
||||
variant="outline"
|
||||
>
|
||||
{t("viewDetails")}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{t("buyNow")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MagicCard>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,8 @@ interface MarketplaceFiltersProps {
|
||||
onFilter: (filters: FilterState) => void;
|
||||
onSort: (sortBy: string) => void;
|
||||
totalItems: number;
|
||||
showRarity?: boolean;
|
||||
categoryOptionsOverride?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
@@ -42,6 +44,8 @@ export function MarketplaceFilters({
|
||||
onFilter,
|
||||
onSort,
|
||||
totalItems,
|
||||
showRarity = true,
|
||||
categoryOptionsOverride,
|
||||
}: MarketplaceFiltersProps) {
|
||||
const t = useTranslations("home");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -60,7 +64,7 @@ export function MarketplaceFilters({
|
||||
{ value: "legendary", label: "Lendário", color: "bg-orange-500" },
|
||||
];
|
||||
|
||||
const categoryOptions = [
|
||||
const categoryOptions = categoryOptionsOverride ?? [
|
||||
{ value: "weapon", label: "Arma" },
|
||||
{ value: "armor", label: "Armadura" },
|
||||
{ value: "amulet", label: "Amuleto" },
|
||||
@@ -142,7 +146,8 @@ export function MarketplaceFilters({
|
||||
|
||||
{/* Filtros e ordenação */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Filtro por raridade */}
|
||||
{/* Filtro por raridade (opcional) */}
|
||||
{showRarity && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
@@ -173,6 +178,7 @@ export function MarketplaceFilters({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Filtro por categoria */}
|
||||
<DropdownMenu>
|
||||
|
||||
Binary file not shown.
+9
-15
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Script principal para executar o spider do TibiaWiki
|
||||
Script principal para executar o scraper Selenium do TibiaWiki
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
@@ -7,25 +7,19 @@ import os
|
||||
# Adiciona o diretório raiz ao path do Python
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from scrapy.crawler import CrawlerProcess
|
||||
from scrapy.utils.project import get_project_settings
|
||||
from tibiawiki.spiders.selenium_spider import SeleniumSpider
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Executa o spider tibiawiki_items
|
||||
Executa o SeleniumSpider para extrair dados do TibiaWiki
|
||||
"""
|
||||
# Configurações do projeto
|
||||
settings = get_project_settings()
|
||||
|
||||
# Processo do crawler
|
||||
process = CrawlerProcess(settings)
|
||||
|
||||
# Adiciona spider para ser executado
|
||||
process.crawl('tibiawiki_items')
|
||||
|
||||
# Inicia crawling
|
||||
process.start()
|
||||
spider = SeleniumSpider(
|
||||
db_path='data/tibiawiki.db',
|
||||
images_path='data/images',
|
||||
headless=True,
|
||||
)
|
||||
spider.run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""
|
||||
Definição dos Items para scraping do TibiaWiki
|
||||
"""
|
||||
import scrapy
|
||||
|
||||
|
||||
class TibiaItem(scrapy.Item):
|
||||
"""Item representando um item do TibiaWiki"""
|
||||
|
||||
# Campos obrigatórios
|
||||
nome = scrapy.Field()
|
||||
categoria = scrapy.Field()
|
||||
|
||||
# Campos numéricos (podem estar vazios dependendo da categoria)
|
||||
nivel = scrapy.Field() # Nível mínimo
|
||||
peso = scrapy.Field() # Peso em oz.
|
||||
|
||||
# Campos de equipamento
|
||||
defesa = scrapy.Field() # Armaduras, escudos, calças
|
||||
ataque = scrapy.Field() # Armas
|
||||
alcance = scrapy.Field() # Armas de distância
|
||||
slots = scrapy.Field() # Slots necessários
|
||||
skill_para_usar = scrapy.Field() # Skill necessária
|
||||
|
||||
# Campos de valores
|
||||
valor_npc = scrapy.Field() # Valor de venda ao NPC
|
||||
|
||||
# Campos descritivos
|
||||
descricao = scrapy.Field()
|
||||
|
||||
# Campos de imagem
|
||||
image_url = scrapy.Field() # URL da imagem original
|
||||
image_urls = scrapy.Field() # Para ImagesPipeline do Scrapy
|
||||
images = scrapy.Field() # Para ImagesPipeline do Scrapy
|
||||
image_local_path = scrapy.Field() # Caminho local da imagem salva
|
||||
|
||||
# Campos adicionais (podem aparecer em categorias específicas)
|
||||
atk_velocidade = scrapy.Field() # Velocidade de ataque
|
||||
durabilidade = scrapy.Field() # Durabilidade
|
||||
dano = scrapy.Field() # Dano (para armas)
|
||||
cura = scrapy.Field() # Cura (para poções)
|
||||
mana = scrapy.Field() # Mana (para poções)
|
||||
vocacao = scrapy.Field() # Vocações que podem usar
|
||||
capacidade = scrapy.Field() # Capacidade de itens recipientes
|
||||
tempo = scrapy.Field() # Tempo de efeito
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
"""
|
||||
Pipelines para processar os itens extraídos
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
import scrapy
|
||||
from itemadapter import ItemAdapter
|
||||
from scrapy.pipelines.images import ImagesPipeline
|
||||
from scrapy.exceptions import DropItem
|
||||
from tibiawiki.utils import normalize_name
|
||||
|
||||
|
||||
class SQLitePipeline:
|
||||
"""
|
||||
Pipeline que salva os itens em um banco SQLite
|
||||
Uma tabela por categoria de itens
|
||||
"""
|
||||
|
||||
def __init__(self, db_path):
|
||||
self.db_path = db_path
|
||||
self.conn = None
|
||||
self.cursor = None
|
||||
self.created_tables = set()
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
db_path = crawler.settings.get('SQLITE_DB_PATH', 'data/tibiawiki.db')
|
||||
return cls(db_path)
|
||||
|
||||
def open_spider(self, spider):
|
||||
"""Abre conexão com o banco quando o spider iniciar"""
|
||||
# Cria diretório se não existir
|
||||
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
||||
|
||||
self.conn = sqlite3.connect(self.db_path)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.cursor = self.conn.cursor()
|
||||
|
||||
spider.logger.info(f"Conectado ao banco SQLite: {self.db_path}")
|
||||
|
||||
def close_spider(self, spider):
|
||||
"""Fecha conexão com o banco quando o spider terminar"""
|
||||
if self.conn:
|
||||
self.conn.commit()
|
||||
self.conn.close()
|
||||
spider.logger.info("Conexão com SQLite fechada")
|
||||
|
||||
def process_item(self, item, spider):
|
||||
"""Processa cada item e salva no banco"""
|
||||
adapter = ItemAdapter(item)
|
||||
category = adapter.get('categoria')
|
||||
|
||||
if not category:
|
||||
raise DropItem("Item sem categoria")
|
||||
|
||||
# Normaliza nome da tabela
|
||||
table_name = normalize_name(category)
|
||||
|
||||
# Cria tabela se não existir
|
||||
self._create_table_if_not_exists(table_name, adapter)
|
||||
|
||||
# Prepara dados para inserção
|
||||
data = dict(adapter)
|
||||
|
||||
# Remove campo 'images' que é usado apenas pelo ImagesPipeline
|
||||
data.pop('images', None)
|
||||
data.pop('image_urls', None)
|
||||
|
||||
# Prepara valores para SQL
|
||||
columns = list(data.keys())
|
||||
values = list(data.values())
|
||||
placeholders = ','.join(['?'] * len(values))
|
||||
|
||||
# Query de UPSERT (inserir ou atualizar)
|
||||
columns_str = ','.join(columns)
|
||||
update_str = ','.join([f"{col}=excluded.{col}" for col in columns])
|
||||
|
||||
sql = f"""
|
||||
INSERT INTO {table_name} ({columns_str})
|
||||
VALUES ({placeholders})
|
||||
ON CONFLICT(nome) DO UPDATE SET {update_str}
|
||||
"""
|
||||
|
||||
self.cursor.execute(sql, values)
|
||||
self.conn.commit()
|
||||
|
||||
spider.logger.debug(f"Salvo item: {data.get('nome')} em tabela {table_name}")
|
||||
|
||||
return item
|
||||
|
||||
def _create_table_if_not_exists(self, table_name, adapter):
|
||||
"""Cria tabela para categoria se não existir"""
|
||||
if table_name in self.created_tables:
|
||||
return
|
||||
|
||||
# Prepara definições de colunas
|
||||
columns_def = []
|
||||
|
||||
# Adiciona todas as colunas possíveis baseadas nos campos do item
|
||||
for field_name, field_value in adapter.items():
|
||||
sql_type = self._get_sql_type(field_value)
|
||||
constraint = ""
|
||||
|
||||
# Nome é chave única
|
||||
if field_name == 'nome':
|
||||
constraint = " UNIQUE NOT NULL"
|
||||
|
||||
columns_def.append(f"{field_name} {sql_type}{constraint}")
|
||||
|
||||
# Cria tabela
|
||||
columns_sql = ','.join(columns_def)
|
||||
|
||||
sql = f"""
|
||||
CREATE TABLE IF NOT EXISTS {table_name} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
{columns_sql}
|
||||
)
|
||||
"""
|
||||
|
||||
self.cursor.execute(sql)
|
||||
self.created_tables.add(table_name)
|
||||
|
||||
def _get_sql_type(self, value):
|
||||
"""Retorna o tipo SQL apropriado para um valor"""
|
||||
if value is None:
|
||||
return "TEXT"
|
||||
|
||||
if isinstance(value, int):
|
||||
return "INTEGER"
|
||||
elif isinstance(value, float):
|
||||
return "REAL"
|
||||
else:
|
||||
return "TEXT"
|
||||
|
||||
|
||||
class CustomImagesPipeline(ImagesPipeline):
|
||||
"""
|
||||
Pipeline customizado para baixar e organizar imagens
|
||||
Organiza imagens por categoria em pastas separadas
|
||||
"""
|
||||
|
||||
def file_path(self, request, response=None, info=None, *, item=None):
|
||||
"""
|
||||
Define o caminho onde a imagem será salva
|
||||
Formato: {categoria_normalizada}/nome_do_item.jpg
|
||||
"""
|
||||
# Obtém nome e categoria do item
|
||||
adapter = ItemAdapter(item) if item else {}
|
||||
categoria = adapter.get('categoria', 'uncategorized')
|
||||
nome = adapter.get('nome', 'unknown')
|
||||
|
||||
# Normaliza nomes
|
||||
categoria_norm = normalize_name(categoria)
|
||||
nome_norm = normalize_name(nome)
|
||||
|
||||
# Extrai extensão original
|
||||
original_url = request.url
|
||||
if '/' in original_url:
|
||||
original_filename = original_url.split('/')[-1]
|
||||
else:
|
||||
original_filename = original_url
|
||||
|
||||
# Extrai extensão
|
||||
if '.' in original_filename:
|
||||
ext = original_filename.split('.')[-1].lower()
|
||||
# Normaliza extensão para jpg/jpeg
|
||||
if ext in ['jpeg', 'jpg']:
|
||||
ext = 'jpg'
|
||||
else:
|
||||
ext = 'jpg'
|
||||
|
||||
# Cria caminho: categoria/nome.ext
|
||||
path = f"{categoria_norm}/{nome_norm}.{ext}"
|
||||
|
||||
return path
|
||||
|
||||
def get_media_requests(self, item, info):
|
||||
"""
|
||||
Faz download das imagens baseado no campo image_urls
|
||||
"""
|
||||
adapter = ItemAdapter(item)
|
||||
image_urls = adapter.get('image_urls', [])
|
||||
|
||||
if not image_urls:
|
||||
return []
|
||||
|
||||
for image_url in image_urls:
|
||||
yield scrapy.Request(image_url)
|
||||
|
||||
def item_completed(self, results, item, info):
|
||||
"""
|
||||
Atualiza campo image_local_path quando imagem é baixada
|
||||
"""
|
||||
if not results:
|
||||
return item
|
||||
|
||||
# results é uma lista de tuplas (success, image_info_or_failure)
|
||||
for success, result in results:
|
||||
if success:
|
||||
adapter = ItemAdapter(item)
|
||||
adapter['image_local_path'] = result['path']
|
||||
|
||||
info.spider.logger.debug(f"Imagem salva: {result['path']}")
|
||||
|
||||
return item
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# Scrapy settings for tibiawiki project
|
||||
#
|
||||
# For simplicity, this file contains only settings considered important or
|
||||
# commonly used. You can find more settings consulting the documentation:
|
||||
#
|
||||
# https://docs.scrapy.org/en/latest/topics/settings.html
|
||||
# https://docs.scrapy.org/en/latest/topics/downloader-middleware.html
|
||||
# https://docs.scrapy.org/en/latest/topics/spider-middleware.html
|
||||
|
||||
BOT_NAME = 'tibiawiki'
|
||||
|
||||
SPIDER_MODULES = ['tibiawiki.spiders']
|
||||
NEWSPIDER_MODULE = 'tibiawiki.spiders'
|
||||
|
||||
|
||||
# Obey robots.txt rules
|
||||
ROBOTSTXT_OBEY = True
|
||||
|
||||
# Configure delays for requests for more polite scraping
|
||||
DOWNLOAD_DELAY = 1.5
|
||||
RANDOMIZE_DOWNLOAD_DELAY = True
|
||||
|
||||
# The download delay setting will honor only one of:
|
||||
CONCURRENT_REQUESTS_PER_DOMAIN = 2
|
||||
CONCURRENT_REQUESTS = 2
|
||||
|
||||
# Enable autothrottle
|
||||
AUTOTHROTTLE_ENABLED = True
|
||||
AUTOTHROTTLE_START_DELAY = 1
|
||||
AUTOTHROTTLE_MAX_DELAY = 3
|
||||
AUTOTHROTTLE_TARGET_CONCURRENCY = 2.0
|
||||
|
||||
# User agent
|
||||
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
|
||||
# Configure item pipelines
|
||||
ITEM_PIPELINES = {
|
||||
'tibiawiki.pipelines.SQLitePipeline': 300,
|
||||
'tibiawiki.pipelines.CustomImagesPipeline': 400,
|
||||
}
|
||||
|
||||
# Images settings
|
||||
IMAGES_STORE = 'data/images'
|
||||
IMAGES_EXPIRES = 90
|
||||
|
||||
# SQLite database path
|
||||
SQLITE_DB_PATH = 'data/tibiawiki.db'
|
||||
|
||||
# Enable caching
|
||||
HTTPCACHE_ENABLED = False
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL = 'INFO'
|
||||
|
||||
# Feeds exports (opcional - para exportar JSON/CSV)
|
||||
# FEEDS = {
|
||||
# 'data/items.json': {'format': 'json'},
|
||||
# 'data/items.csv': {'format': 'csv'},
|
||||
# }
|
||||
|
||||
# Disable cookies
|
||||
COOKIES_ENABLED = False
|
||||
|
||||
# Request settings
|
||||
DOWNLOAD_TIMEOUT = 30
|
||||
|
||||
# Retry settings
|
||||
RETRY_ENABLED = True
|
||||
RETRY_TIMES = 3
|
||||
RETRY_HTTP_CODES = [500, 502, 503, 504, 522, 524, 408, 429]
|
||||
|
||||
# Telnet console
|
||||
TELNETCONSOLE_ENABLED = False
|
||||
|
||||
Binary file not shown.
@@ -1,217 +0,0 @@
|
||||
"""
|
||||
Spider principal para extrair dados de itens do TibiaWiki.br
|
||||
"""
|
||||
import scrapy
|
||||
from scrapy.http import Request
|
||||
from tibiawiki.items import TibiaItem
|
||||
from tibiawiki.utils import normalize_name, parse_int, parse_float, clean_text
|
||||
|
||||
|
||||
class ItemsSpider(scrapy.Spider):
|
||||
"""
|
||||
Spider que extrai informações de todas as categorias de itens do TibiaWiki
|
||||
"""
|
||||
name = 'tibiawiki_items'
|
||||
|
||||
start_urls = ['https://www.tibiawiki.com.br/wiki/Itens']
|
||||
|
||||
custom_settings = {
|
||||
'CONCURRENT_REQUESTS': 2,
|
||||
'DOWNLOAD_DELAY': 2,
|
||||
'RANDOMIZE_DOWNLOAD_DELAY': True,
|
||||
'AUTOTHROTTLE_ENABLED': True,
|
||||
'AUTOTHROTTLE_START_DELAY': 1,
|
||||
'AUTOTHROTTLE_MAX_DELAY': 3,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.categories_found = set()
|
||||
|
||||
def parse(self, response):
|
||||
"""
|
||||
Parseia a página principal de itens e extrai links para categorias
|
||||
"""
|
||||
self.logger.info(f"Parseando página principal: {response.url}")
|
||||
|
||||
# Procura por links de categorias
|
||||
# Os links geralmente estão em <a> dentro de listas ou tabelas
|
||||
category_links = []
|
||||
|
||||
# Método 1: Procurar links com imagens de itens (ícones das categorias)
|
||||
images_with_links = response.css('a:has(img)')
|
||||
|
||||
for link in images_with_links:
|
||||
href = link.attrib.get('href', '')
|
||||
text = link.css('::text').get('').strip()
|
||||
|
||||
# Verifica se é um link para categoria de itens
|
||||
if '/wiki/' in href and href not in category_links:
|
||||
category_links.append(response.urljoin(href))
|
||||
|
||||
# Método 2: Procurar por palavras-chave comuns de categorias
|
||||
all_links = response.css('a::attr(href)').getall()
|
||||
|
||||
category_keywords = [
|
||||
'Capacetes', 'Armaduras', 'Escudos', 'Calças', 'Spellbooks',
|
||||
'Botas', 'Aljavas', 'Machados', 'Clavas', 'Espadas', 'Rods',
|
||||
'Wands', 'Distância', 'Munição', 'Punhos', 'Livros', 'Decorações',
|
||||
'Troféus', 'Comidas', 'Líquidos', 'Plantas', 'Amuletos', 'Colares',
|
||||
'Anéis', 'Chaves', 'Ferramentas', 'Recipientes', 'Runas', 'Pergaminhos'
|
||||
]
|
||||
|
||||
for link in all_links:
|
||||
for keyword in category_keywords:
|
||||
if keyword in link and link not in category_links:
|
||||
category_links.append(response.urljoin(link))
|
||||
break
|
||||
|
||||
self.logger.info(f"Encontradas {len(category_links)} categorias")
|
||||
|
||||
# Faz request para cada categoria
|
||||
for link in category_links:
|
||||
yield Request(
|
||||
url=link,
|
||||
callback=self.parse_category,
|
||||
dont_filter=False,
|
||||
meta={'category_url': link}
|
||||
)
|
||||
|
||||
def parse_category(self, response):
|
||||
"""
|
||||
Parseia uma página de categoria específica e extrai dados dos itens
|
||||
"""
|
||||
# Extrai nome da categoria da URL
|
||||
category_name = response.url.split('/')[-1].replace('_', ' ')
|
||||
self.logger.info(f"Parseando categoria: {category_name}")
|
||||
|
||||
# Verifica se já foi processada
|
||||
if category_name in self.categories_found:
|
||||
return
|
||||
|
||||
self.categories_found.add(category_name)
|
||||
|
||||
# Procura por tabelas com itens
|
||||
# Tables geralmente têm class 'wikitable' no MediaWiki
|
||||
tables = response.css('table.wikitable')
|
||||
|
||||
if not tables:
|
||||
# Se não encontrar wikitable, tenta qualquer table
|
||||
tables = response.css('table')
|
||||
|
||||
for table in tables:
|
||||
# Extrai cabeçalhos da tabela
|
||||
headers = []
|
||||
header_row = table.css('tr').get(0)
|
||||
|
||||
if header_row:
|
||||
# Tenta pegar <th> ou primeira linha de <td>
|
||||
ths = table.css('tr:first-child th, tr:first-child td')
|
||||
for th in ths:
|
||||
header_text = clean_text(''.join(th.css('::text').getall()))
|
||||
headers.append(header_text)
|
||||
|
||||
self.logger.debug(f"Colunas encontradas: {headers}")
|
||||
|
||||
# Processa cada linha da tabela (pula o cabeçalho)
|
||||
rows = table.css('tr')[1:] # Pula primeira linha (cabeçalho)
|
||||
|
||||
for row in rows:
|
||||
item = TibiaItem()
|
||||
item['categoria'] = category_name
|
||||
|
||||
# Extrai células
|
||||
cells = row.css('td')
|
||||
|
||||
if not cells:
|
||||
continue
|
||||
|
||||
# Mapeia cada célula para seu cabeçalho correspondente
|
||||
for idx, cell in enumerate(cells):
|
||||
if idx >= len(headers):
|
||||
break
|
||||
|
||||
header = headers[idx]
|
||||
cell_text = clean_text(''.join(cell.css('::text').getall()))
|
||||
|
||||
# Extrai imagem se houver
|
||||
img = cell.css('img::attr(src)').get()
|
||||
if img:
|
||||
image_url = response.urljoin(img)
|
||||
item['image_url'] = image_url
|
||||
item['image_urls'] = [image_url]
|
||||
|
||||
# Mapeia colunas para campos do item
|
||||
self._map_column_to_field(item, header, cell_text)
|
||||
|
||||
# Só yield item se tiver nome
|
||||
if item.get('nome'):
|
||||
yield item
|
||||
|
||||
def _map_column_to_field(self, item, column_name, value):
|
||||
"""
|
||||
Mapeia nomes de colunas para campos do item
|
||||
|
||||
Args:
|
||||
item: Item scrapy a ser preenchido
|
||||
column_name: Nome da coluna da tabela
|
||||
value: Valor da célula
|
||||
"""
|
||||
# Normaliza nome da coluna para comparação
|
||||
column_lower = column_name.lower()
|
||||
|
||||
# Mapeamento de campos comuns
|
||||
if 'nome' in column_lower or 'item' in column_lower:
|
||||
item['nome'] = value
|
||||
|
||||
elif 'nível' in column_lower or 'lvl' in column_lower:
|
||||
item['nivel'] = parse_int(value)
|
||||
|
||||
elif 'peso' in column_lower:
|
||||
item['peso'] = parse_float(value)
|
||||
|
||||
elif 'defesa' in column_lower or 'def' in column_lower:
|
||||
item['defesa'] = parse_int(value)
|
||||
|
||||
elif 'ataque' in column_lower or 'atk' in column_lower:
|
||||
item['ataque'] = parse_int(value)
|
||||
|
||||
elif 'alcance' in column_lower:
|
||||
item['alcance'] = parse_int(value)
|
||||
|
||||
elif 'slots' in column_lower:
|
||||
item['slots'] = parse_int(value)
|
||||
|
||||
elif 'skill' in column_lower:
|
||||
item['skill_para_usar'] = value
|
||||
|
||||
elif 'valor' in column_lower or 'price' in column_lower:
|
||||
item['valor_npc'] = parse_int(value)
|
||||
|
||||
elif 'descrição' in column_lower or 'descricao' in column_lower:
|
||||
item['descricao'] = value
|
||||
|
||||
elif 'velocidade' in column_lower:
|
||||
item['atk_velocidade'] = parse_int(value)
|
||||
|
||||
elif 'durabilidade' in column_lower:
|
||||
item['durabilidade'] = parse_int(value)
|
||||
|
||||
elif 'dano' in column_lower:
|
||||
item['dano'] = value
|
||||
|
||||
elif 'cura' in column_lower:
|
||||
item['cura'] = parse_int(value)
|
||||
|
||||
elif 'mana' in column_lower:
|
||||
item['mana'] = parse_int(value)
|
||||
|
||||
elif 'vocação' in column_lower or 'voc' in column_lower:
|
||||
item['vocacao'] = value
|
||||
|
||||
elif 'capacidade' in column_lower:
|
||||
item['capacidade'] = parse_int(value)
|
||||
|
||||
elif 'tempo' in column_lower:
|
||||
item['tempo'] = value
|
||||
|
||||
@@ -55,6 +55,18 @@ class SeleniumSpider:
|
||||
chrome_options.add_argument('--disable-gpu')
|
||||
chrome_options.add_argument('--window-size=1920,1080')
|
||||
|
||||
# Desabilita imagens para acelerar carregamento
|
||||
chrome_prefs = {
|
||||
'profile.managed_default_content_settings.images': 2
|
||||
}
|
||||
chrome_options.add_experimental_option('prefs', chrome_prefs)
|
||||
|
||||
# Estratégia de carregamento mais rápida
|
||||
try:
|
||||
chrome_options.page_load_strategy = 'eager'
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# User agent
|
||||
chrome_options.add_argument('user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
|
||||
|
||||
@@ -92,20 +104,17 @@ class SeleniumSpider:
|
||||
self.driver.quit()
|
||||
logger.info("Driver fechado")
|
||||
|
||||
def get_page(self, url, wait_time=3):
|
||||
def get_page(self, url, wait_time=0):
|
||||
"""
|
||||
Carrega uma página e aguarda carregamento
|
||||
"""
|
||||
logger.info(f"Carregando página: {url}")
|
||||
self.driver.get(url)
|
||||
|
||||
# Aguarda um tempo para a página carregar
|
||||
time.sleep(wait_time)
|
||||
|
||||
# Aguarda elementos principais carregarem
|
||||
try:
|
||||
WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.TAG_NAME, "body"))
|
||||
EC.presence_of_element_located((By.TAG_NAME, "table"))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Timeout esperando elementos: {e}")
|
||||
@@ -113,7 +122,7 @@ class SeleniumSpider:
|
||||
def get_categories(self):
|
||||
"""Extrai links das categorias da página principal"""
|
||||
url = "https://www.tibiawiki.com.br/wiki/Itens"
|
||||
self.get_page(url, wait_time=4)
|
||||
self.get_page(url, wait_time=0)
|
||||
|
||||
# Obtém HTML após renderização
|
||||
html = self.driver.page_source
|
||||
@@ -153,7 +162,7 @@ class SeleniumSpider:
|
||||
logger.info(f"Processando categoria: {category_name}")
|
||||
logger.info(f"{'='*80}")
|
||||
|
||||
self.get_page(url, wait_time=3)
|
||||
self.get_page(url, wait_time=0)
|
||||
|
||||
# Obtém HTML após renderização
|
||||
html = self.driver.page_source
|
||||
@@ -194,24 +203,105 @@ class SeleniumSpider:
|
||||
if not cells:
|
||||
continue
|
||||
|
||||
# Destacar visualmente a linha atual no navegador
|
||||
try:
|
||||
self.driver.execute_script(
|
||||
"""
|
||||
(function(tableIndex, rowIndex){
|
||||
try {
|
||||
if (!window.__scrapeStyleInjected) {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = '.__scrapeHighlight{outline:2px solid #e11d48;background:rgba(225,29,72,0.08);}';
|
||||
document.head.appendChild(style);
|
||||
window.__scrapeStyleInjected = true;
|
||||
}
|
||||
document.querySelectorAll('tr.__scrapeHighlight').forEach(el=>el.classList.remove('__scrapeHighlight'));
|
||||
let tables = document.querySelectorAll('table.wikitable');
|
||||
if (!tables || tables.length===0) tables = document.querySelectorAll('table');
|
||||
const t = tables[tableIndex];
|
||||
if (!t) return;
|
||||
const trs = t.querySelectorAll('tr');
|
||||
const r = trs[rowIndex+1]; // +1 para ignorar cabeçalho
|
||||
if (!r) return;
|
||||
r.classList.add('__scrapeHighlight');
|
||||
r.scrollIntoView({block:'center'});
|
||||
} catch(e) {}
|
||||
})(arguments[0], arguments[1]);
|
||||
""",
|
||||
table_idx, row_idx
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Processa cada célula
|
||||
for cell_idx, cell in enumerate(cells):
|
||||
if cell_idx >= len(headers):
|
||||
break
|
||||
|
||||
header = headers[cell_idx]
|
||||
header_clean = clean_text(header)
|
||||
header_key = header_clean.casefold()
|
||||
cell_text = clean_text(cell.get_text(strip=True))
|
||||
|
||||
# Extrai imagem
|
||||
img = cell.find('img')
|
||||
if img and img.get('src'):
|
||||
img_src = img.get('src')
|
||||
if not img_src.startswith('http'):
|
||||
img_src = urljoin(url, img_src)
|
||||
item_data['image_url'] = img_src
|
||||
|
||||
# Mapeia coluna para campo
|
||||
self._map_column_to_field(item_data, header, cell_text)
|
||||
# Extrai possíveis imagens da célula
|
||||
imgs = cell.find_all('img')
|
||||
img_srcs = []
|
||||
for im in imgs:
|
||||
src = im.get('src') if im else None
|
||||
if not src:
|
||||
continue
|
||||
if not src.startswith('http'):
|
||||
src = urljoin(url, src)
|
||||
img_srcs.append(src)
|
||||
|
||||
# 1) Coluna sem título: considerar como ícone do item; ignorar Tick/Cross
|
||||
if not header_clean:
|
||||
for img_src in img_srcs:
|
||||
filename = img_src.split('/')[-1].lower()
|
||||
if 'tick' in filename or 'cross' in filename:
|
||||
continue
|
||||
if 'image_url' not in item_data:
|
||||
item_data['image_url'] = img_src
|
||||
break
|
||||
# nada mais a fazer nesta célula
|
||||
continue
|
||||
|
||||
# 2) Coluna "agrupável": mapear Tick/Cross para 1/0 e não baixar imagem
|
||||
if 'agrup' in header_key:
|
||||
assigned = False
|
||||
for img_src in img_srcs:
|
||||
filename = img_src.split('/')[-1].lower()
|
||||
if 'tick' in filename:
|
||||
item_data['agrupavel'] = 1
|
||||
assigned = True
|
||||
break
|
||||
if 'cross' in filename:
|
||||
item_data['agrupavel'] = 0
|
||||
assigned = True
|
||||
break
|
||||
if not assigned:
|
||||
text_key = cell_text.casefold()
|
||||
if text_key == 'sim':
|
||||
item_data['agrupavel'] = 1
|
||||
assigned = True
|
||||
elif text_key in ('não', 'nao'):
|
||||
item_data['agrupavel'] = 0
|
||||
assigned = True
|
||||
# nunca definir image_url a partir desta coluna
|
||||
continue
|
||||
|
||||
# 3) Demais colunas: mapear campos; só usar imagem se não for Tick/Cross
|
||||
# Preferir texto para campos conhecidos
|
||||
self._map_column_to_field(item_data, header_clean, cell_text)
|
||||
|
||||
# Considerar imagem apenas se ainda não temos image_url e não for Tick/Cross
|
||||
if 'image_url' not in item_data and img_srcs:
|
||||
for img_src in img_srcs:
|
||||
filename = img_src.split('/')[-1].lower()
|
||||
if 'tick' in filename or 'cross' in filename:
|
||||
continue
|
||||
item_data['image_url'] = img_src
|
||||
break
|
||||
|
||||
# Salva item se tiver nome
|
||||
if item_data.get('nome'):
|
||||
@@ -312,6 +402,11 @@ class SeleniumSpider:
|
||||
if not image_url:
|
||||
return
|
||||
|
||||
# Nunca baixar ícones Tick/Cross
|
||||
basename = image_url.split('/')[-1].lower()
|
||||
if 'tick' in basename or 'cross' in basename:
|
||||
return
|
||||
|
||||
categoria = item_data.get('categoria', 'uncategorized')
|
||||
nome = item_data.get('nome', 'unknown')
|
||||
|
||||
@@ -392,8 +487,8 @@ class SeleniumSpider:
|
||||
try:
|
||||
self.scrape_category(category_name, url)
|
||||
|
||||
# Delay entre categorias
|
||||
time.sleep(2)
|
||||
# Delay mínimo entre categorias (reduzido)
|
||||
time.sleep(0.2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erro ao processar {category_name}: {e}")
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import path from "node:path";
|
||||
|
||||
// Mapeamento de diretórios PT (filesystem) -> categoria EN (TibiaItem.category)
|
||||
// Ajuste/cubra mais categorias conforme necessário ao longo do uso real
|
||||
export 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",
|
||||
// Outras pastas mapeiam para "other"
|
||||
};
|
||||
|
||||
// Mapeamento inverso EN -> PT para resolver caminho das imagens
|
||||
export const enToPtCategoryMap: Record<string, string> = Object.entries(
|
||||
ptToEnCategoryMap
|
||||
).reduce((acc, [pt, en]) => {
|
||||
acc[en] = pt;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
function normalizeFilenameSegment(value: string): string {
|
||||
// Normaliza para casar melhor com nomes de arquivo: remove acentos e espaços extras
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/\s+/g, "_")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói um caminho relativo para a imagem do item dentro de data/images.
|
||||
* Retorna um caminho relativo utilizável em fs ou para um handler que sirva arquivos estáticos.
|
||||
*/
|
||||
export function buildItemImagePath(categoryEn: string, fileName: string): string {
|
||||
const ptFolder = enToPtCategoryMap[categoryEn] ?? "outros";
|
||||
// Mantém o nome original caso já esteja correto; fallback para nome normalizado
|
||||
const safeFileName = fileName || "";
|
||||
const normalized = normalizeFilenameSegment(
|
||||
safeFileName.replace(/\.(gif|png|jpg|jpeg|webp)$/i, "")
|
||||
);
|
||||
// Tenta .gif como padrão (ajuste se necessário)
|
||||
const finalFile = safeFileName || `${normalized}.gif`;
|
||||
return path.posix.join("data", "images", ptFolder, finalFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a URL pública para a imagem, caso exista um handler ou static serving configurado.
|
||||
* Por padrão, apontamos para um API proxy que servirá o arquivo do filesystem.
|
||||
*/
|
||||
export function buildPublicItemImageUrl(categoryEn: string, fileName: string): string {
|
||||
const relativePath = buildItemImagePath(categoryEn, fileName);
|
||||
// Ex.: /api/images?path=data/images/espadas/sword.gif
|
||||
return `/api/images?path=${encodeURIComponent(relativePath)}`;
|
||||
}
|
||||
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import path from "node:path";
|
||||
import { enToPtCategoryMap } from "./item-images";
|
||||
|
||||
export interface SearchParams {
|
||||
q?: string;
|
||||
categories?: string[];
|
||||
rarities?: string[];
|
||||
sort?: "name" | "price-asc" | "price-desc" | "rarity" | "recent";
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface SearchResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
let schemaLogged = false;
|
||||
|
||||
function getDatabase() {
|
||||
// Carrega better-sqlite3 apenas em runtime para não quebrar o build se não instalado
|
||||
let Database: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
Database = require("better-sqlite3");
|
||||
} catch (err) {
|
||||
// Retorna null para permitir fallback silencioso (API responde vazio ao invés de 500)
|
||||
return null as any;
|
||||
}
|
||||
|
||||
const dbPath = path.join(process.cwd(), "data", "tibiawiki.db");
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
return db;
|
||||
}
|
||||
|
||||
// Tenta deduzir ordenação segura a partir do parâmetro de sort
|
||||
function getOrderBy(sort?: SearchParams["sort"]): string {
|
||||
switch (sort) {
|
||||
case "price-asc":
|
||||
return "npc_price ASC, name ASC";
|
||||
case "price-desc":
|
||||
return "npc_price DESC, name ASC";
|
||||
case "rarity":
|
||||
// Requerer que a tabela possua coluna 'rarity'; caso não exista, recai para nome
|
||||
return "rarity ASC, name ASC";
|
||||
case "recent":
|
||||
// Caso exista created_at/updated_at; fallback para name
|
||||
return "updated_at DESC, name ASC";
|
||||
case "name":
|
||||
default:
|
||||
return "name ASC";
|
||||
}
|
||||
}
|
||||
|
||||
// Nota: O schema exato do tibiawiki.db pode variar. Partimos do pressuposto mínimo:
|
||||
// Tabela 'items' com colunas: id, name, category, image, rarity, npc_price
|
||||
// Ajustes poderão ser feitos após validação do schema real.
|
||||
export function searchItems<T = any>(params: SearchParams): SearchResult<T> {
|
||||
const {
|
||||
q = "",
|
||||
categories = [],
|
||||
rarities = [],
|
||||
sort = "name",
|
||||
page = 1,
|
||||
pageSize = 24,
|
||||
} = params;
|
||||
|
||||
const safePage = Math.max(1, page);
|
||||
const safePageSize = Math.min(Math.max(1, pageSize), 50);
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
// Fallback quando better-sqlite3 não está disponível no ambiente
|
||||
return { items: [], total: 0 } as SearchResult<T>;
|
||||
}
|
||||
try {
|
||||
// Log básico do schema na primeira chamada
|
||||
if (!schemaLogged) {
|
||||
try {
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
console.info("[items-db] Tabelas:", tables.map((t) => t.name));
|
||||
for (const t of tables.slice(0, 5)) {
|
||||
try {
|
||||
const cols = db.prepare(`PRAGMA table_info(${t.name})`).all() as any[];
|
||||
console.info(`[items-db] Colunas ${t.name}:`, cols.map((c) => `${c.name}:${c.type}`));
|
||||
const sample = db.prepare(`SELECT * FROM ${t.name} LIMIT 3`).all();
|
||||
console.info(`[items-db] Amostra ${t.name}:`, sample);
|
||||
} catch (inner) {
|
||||
console.warn(`[items-db] Falha ao inspecionar ${t.name}:`, (inner as any)?.message);
|
||||
}
|
||||
}
|
||||
} catch (intErr) {
|
||||
console.warn("[items-db] Falha ao ler schema:", (intErr as any)?.message);
|
||||
} finally {
|
||||
schemaLogged = true;
|
||||
}
|
||||
}
|
||||
|
||||
const where: string[] = [];
|
||||
const bindings: any[] = [];
|
||||
|
||||
if (q) {
|
||||
where.push("LOWER(name) LIKE ?");
|
||||
bindings.push(`%${q.toLowerCase()}%`);
|
||||
}
|
||||
if (categories.length > 0) {
|
||||
const placeholders = categories.map(() => "?").join(",");
|
||||
where.push(`LOWER(category) IN (${placeholders})`);
|
||||
bindings.push(...categories.map((c) => c.toLowerCase()));
|
||||
}
|
||||
if (rarities.length > 0) {
|
||||
const placeholders = rarities.map(() => "?").join(",");
|
||||
where.push(`LOWER(rarity) IN (${placeholders})`);
|
||||
bindings.push(...rarities.map((r) => r.toLowerCase()));
|
||||
}
|
||||
|
||||
const orderBy = getOrderBy(sort);
|
||||
|
||||
// Seleciona de múltiplas tabelas (PT) unificando shape padronizado
|
||||
const allTables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name <> 'sqlite_sequence'")
|
||||
.all() as { name: string }[];
|
||||
// Filtra por categorias solicitadas (EN → PT); caso vazio, usa todas
|
||||
const allowedPt = new Set(
|
||||
(categories.length > 0
|
||||
? categories.map((en) => enToPtCategoryMap[en] || en)
|
||||
: allTables.map((t) => t.name))
|
||||
);
|
||||
const candidateTables = allTables
|
||||
.map((t) => t.name)
|
||||
.filter((name) => allowedPt.has(name));
|
||||
|
||||
const selects: string[] = [];
|
||||
const unionBindings: any[] = [];
|
||||
for (const t of candidateTables) {
|
||||
try {
|
||||
const cols = db.prepare(`PRAGMA table_info(${t})`).all() as any[];
|
||||
const colNames = new Set(cols.map((c) => (c?.name || "").toLowerCase()));
|
||||
// Requer colunas mínimas
|
||||
if (!colNames.has("id") || !colNames.has("nome") || !colNames.has("image_url")) continue;
|
||||
|
||||
const tableWhere: string[] = [];
|
||||
if (q && colNames.has("nome")) {
|
||||
tableWhere.push("LOWER(nome) LIKE ?");
|
||||
unionBindings.push(`%${q.toLowerCase()}%`);
|
||||
}
|
||||
const whereSqlTbl = tableWhere.length ? `WHERE ${tableWhere.join(" AND ")}` : "";
|
||||
// category: derivamos do nome da tabela (pt)
|
||||
const levelCol = colNames.has("nivel") ? "nivel" : "NULL";
|
||||
const weightCol = colNames.has("peso") ? "peso" : "NULL";
|
||||
const slotsCol = colNames.has("slots") ? "slots" : "NULL";
|
||||
const skillCol = colNames.has("skill_para_usar") ? "skill_para_usar" : "NULL";
|
||||
selects.push(
|
||||
`SELECT
|
||||
id,
|
||||
nome AS name,
|
||||
'${t}' AS category,
|
||||
image_url AS image,
|
||||
${levelCol} AS level,
|
||||
${weightCol} AS weight,
|
||||
${slotsCol} AS slots,
|
||||
${skillCol} AS skill
|
||||
FROM ${t} ${whereSqlTbl}`
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(`[items-db] Ignorando tabela ${t}:`, (e as any)?.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (selects.length === 0) {
|
||||
console.warn("[items-db] Nenhuma tabela compatível encontrada");
|
||||
return { items: [], total: 0 } as SearchResult<T>;
|
||||
}
|
||||
|
||||
const unionSql = selects.join(" UNION ALL ");
|
||||
// Contagem total
|
||||
const totalStmt = db.prepare(`SELECT COUNT(1) as cnt FROM (${unionSql}) AS u`);
|
||||
const totalRow = totalStmt.get(...unionBindings) as { cnt: number };
|
||||
const total = totalRow?.cnt ?? 0;
|
||||
|
||||
const selectStmt = db.prepare(
|
||||
`SELECT * FROM (${unionSql}) AS u ORDER BY name ASC LIMIT ? OFFSET ?`
|
||||
);
|
||||
const items = selectStmt.all(...unionBindings, safePageSize, offset) as T[];
|
||||
|
||||
return { items, total };
|
||||
} finally {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
+260
-2
@@ -38,6 +38,9 @@ importers:
|
||||
'@types/canvas-confetti':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
better-sqlite3:
|
||||
specifier: ^12.4.1
|
||||
version: 12.4.1
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
@@ -967,12 +970,31 @@ packages:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==}
|
||||
engines: {node: 20.x || 22.x || 23.x || 24.x}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
@@ -1039,6 +1061,14 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1063,6 +1093,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -1081,6 +1114,13 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==}
|
||||
peerDependencies:
|
||||
@@ -1095,19 +1135,34 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1208,6 +1263,16 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
|
||||
|
||||
@@ -1233,6 +1298,9 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -1274,10 +1342,17 @@ packages:
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Pr/5KdBQGG8TirdkS0qN3B+f3eo8zTOfZQWAxHoJqopMz2/uvRnG+S4fWu/6AZxKei2CP2p/psdQ5HFC2Ap5BA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||
|
||||
@@ -1295,12 +1370,24 @@ packages:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
|
||||
peerDependencies:
|
||||
@@ -1363,6 +1450,10 @@ packages:
|
||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1382,6 +1473,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
|
||||
|
||||
@@ -1397,10 +1491,23 @@ packages:
|
||||
resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -1424,12 +1531,22 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
||||
|
||||
@@ -1471,6 +1588,9 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -1478,6 +1598,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
|
||||
|
||||
@@ -2228,10 +2351,34 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
bindings: 1.5.0
|
||||
prebuild-install: 7.1.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
file-uri-to-path: 1.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
@@ -2288,6 +2435,12 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2312,6 +2465,10 @@ snapshots:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -2325,6 +2482,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
motion-dom: 12.23.23
|
||||
@@ -2334,8 +2495,12 @@ snapshots:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0([email protected])
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -2345,8 +2510,14 @@ snapshots:
|
||||
domutils: 3.2.2
|
||||
entities: 4.5.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -2423,6 +2594,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
motion-utils: 12.23.6
|
||||
@@ -2439,6 +2616,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected]):
|
||||
@@ -2479,8 +2658,16 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2499,14 +2686,41 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
expand-template: 2.0.3
|
||||
github-from-package: 0.0.0
|
||||
minimist: 1.2.8
|
||||
mkdirp-classic: 0.5.3
|
||||
napi-build-utils: 2.0.0
|
||||
node-abi: 3.79.0
|
||||
pump: 3.0.3
|
||||
rc: 1.2.8
|
||||
simple-get: 4.0.1
|
||||
tar-fs: 2.1.4
|
||||
tunnel-agent: 0.6.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
deep-extend: 0.6.0
|
||||
ini: 1.3.8
|
||||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
@@ -2560,6 +2774,12 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
[email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.9.2([email protected](@types/[email protected])([email protected])([email protected]))([email protected])
|
||||
@@ -2588,6 +2808,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
deepmerge: 4.3.1
|
||||
@@ -2599,8 +2821,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -2632,8 +2853,22 @@ snapshots:
|
||||
'@img/sharp-win32-x64': 0.34.4
|
||||
optional: true
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
decompress-response: 6.0.0
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -2645,10 +2880,29 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
chownr: 1.1.4
|
||||
mkdirp-classic: 0.5.3
|
||||
pump: 3.0.3
|
||||
tar-stream: 2.2.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
bl: 4.1.0
|
||||
end-of-stream: 1.4.5
|
||||
fs-constants: 1.0.0
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2681,6 +2935,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -2700,4 +2956,6 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -1,2 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- sharp
|
||||
Reference in new issue
Block a user