diff --git a/.citrine b/.citrine index a5bb269..9966be0 100644 --- a/.citrine +++ b/.citrine @@ -1,5 +1,5 @@ ### Frontend -[8bb0] [ ] implement items page +[8bb0] [>] implement items page [de51] [ ] implement classes page [d108] [ ] implement quests page [8bbe] [ ] implement lootdrops page diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 9f44198..99dc6de 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -5,6 +5,7 @@ import Layout, { type Page } from "./components/Layout"; import Dashboard from "./pages/Dashboard"; import Settings from "./pages/Settings"; import Users from "./pages/Users"; +import Items from "./pages/Items"; import PlaceholderPage from "./pages/PlaceholderPage"; const placeholders: Record = { @@ -78,6 +79,8 @@ export default function App() { ) : page === "users" ? ( + ) : page === "items" ? ( + ) : page === "settings" ? ( ) : ( diff --git a/panel/src/lib/useItems.ts b/panel/src/lib/useItems.ts new file mode 100644 index 0000000..93c0bf3 --- /dev/null +++ b/panel/src/lib/useItems.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useState } from "react"; +import { get } from "./api"; + +export interface Item { + id: number; + name: string; + description: string | null; + type: string; + rarity: string; + price: string | null; + iconUrl: string; + imageUrl: string; +} + +export interface ItemFilters { + search: string; + type: string | null; + rarity: string | null; +} + +export function useItems() { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [limit, setLimit] = useState(50); + const [filters, setFiltersState] = useState({ + search: "", + type: null, + rarity: null, + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchItems = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const params = new URLSearchParams(); + if (filters.search) params.set("search", filters.search); + if (filters.type) params.set("type", filters.type); + if (filters.rarity) params.set("rarity", filters.rarity); + params.set("limit", String(limit)); + params.set("offset", String((currentPage - 1) * limit)); + + const data = await get<{ items: Item[]; total: number }>( + `/api/items?${params.toString()}` + ); + + setItems(data.items); + setTotal(data.total); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load items"); + } finally { + setLoading(false); + } + }, [filters, currentPage, limit]); + + const setFilters = useCallback((newFilters: Partial) => { + setFiltersState((prev) => ({ ...prev, ...newFilters })); + setCurrentPage(1); + }, []); + + const setSearchDebounced = useCallback( + (() => { + let timeoutId: NodeJS.Timeout; + return (search: string) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + setFilters({ search }); + }, 300); + }; + })(), + [setFilters] + ); + + const setPage = useCallback((page: number) => { + setCurrentPage(page); + }, []); + + useEffect(() => { + fetchItems(); + }, [fetchItems]); + + return { + items, + total, + currentPage, + limit, + setLimit, + filters, + setFilters, + setSearchDebounced, + setPage, + loading, + error, + refetch: fetchItems, + }; +} diff --git a/panel/src/pages/Items.tsx b/panel/src/pages/Items.tsx new file mode 100644 index 0000000..e6cc897 --- /dev/null +++ b/panel/src/pages/Items.tsx @@ -0,0 +1,493 @@ +import { useState, useEffect } from "react"; +import { + Search, + ChevronLeft, + ChevronRight, + Package, + AlertTriangle, + Sparkles, +} from "lucide-react"; +import { cn } from "../lib/utils"; +import { useItems, type Item } from "../lib/useItems"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatNumber(num: number | string): string { + const n = typeof num === "string" ? parseInt(num) : num; + return n.toLocaleString(); +} + +const RARITY_COLORS: Record = { + C: "bg-gray-500/20 text-gray-400", + R: "bg-blue-500/20 text-blue-400", + SR: "bg-purple-500/20 text-purple-400", + SSR: "bg-amber-500/20 text-amber-400", +}; + +type Tab = "all" | "studio"; + +// --------------------------------------------------------------------------- +// SearchFilterBar +// --------------------------------------------------------------------------- + +function SearchFilterBar({ + search, + onSearchChange, + type, + onTypeChange, + rarity, + onRarityChange, + onClear, +}: { + search: string; + onSearchChange: (v: string) => void; + type: string | null; + onTypeChange: (v: string | null) => void; + rarity: string | null; + onRarityChange: (v: string | null) => void; + onClear: () => void; +}) { + return ( +
+
+ + onSearchChange(e.target.value)} + placeholder="Search items..." + className={cn( + "w-full bg-input border border-border rounded-md pl-10 pr-3 py-2 text-sm text-foreground", + "focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30", + "transition-colors" + )} + /> +
+ + + + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// ItemTable +// --------------------------------------------------------------------------- + +function ItemTable({ + items, + loading, +}: { + items: Item[]; + loading: boolean; +}) { + const columns = ["ID", "Icon", "Name", "Type", "Rarity", "Price", "Description"]; + + if (loading) { + return ( +
+
+ + + + {columns.map((col) => ( + + ))} + + + + {[...Array(5)].map((_, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ {col} +
+
+
+
+
+ ); + } + + if (items.length === 0) { + return ( +
+ +

+ No items found +

+

+ Try adjusting your search or filter criteria +

+
+ ); + } + + return ( +
+
+ + + + {columns.map((col) => ( + + ))} + + + + {items.map((item) => ( + + + + + + + + + + ))} + +
+ {col} +
+ + {item.id} + + + {item.name} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + + + {item.name} + + + + {item.type.toLowerCase()} + + + + {item.rarity} + + + + {item.price ? formatNumber(item.price) : "—"} + + + + {item.description || "—"} + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Pagination +// --------------------------------------------------------------------------- + +function Pagination({ + currentPage, + totalPages, + limit, + total, + onPageChange, + onLimitChange, +}: { + currentPage: number; + totalPages: number; + limit: number; + total: number; + onPageChange: (page: number) => void; + onLimitChange: (limit: number) => void; +}) { + const startItem = (currentPage - 1) * limit + 1; + const endItem = Math.min(currentPage * limit, total); + + const getPageNumbers = () => { + const pages: (number | string)[] = []; + const showPages = 5; + const halfShow = Math.floor(showPages / 2); + + let start = Math.max(1, currentPage - halfShow); + const end = Math.min(totalPages, start + showPages - 1); + + if (end - start < showPages - 1) { + start = Math.max(1, end - showPages + 1); + } + + if (start > 1) { + pages.push(1); + if (start > 2) pages.push("..."); + } + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + if (end < totalPages) { + if (end < totalPages - 1) pages.push("..."); + pages.push(totalPages); + } + + return pages; + }; + + return ( +
+

+ Showing {startItem}–{endItem} of {formatNumber(total)} items +

+ +
+ + + {getPageNumbers().map((page, i) => + typeof page === "number" ? ( + + ) : ( + + {page} + + ) + )} + + + + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main Items Component +// --------------------------------------------------------------------------- + +export default function Items() { + const { + items, + total, + currentPage, + limit, + setLimit, + filters, + setFilters, + setSearchDebounced, + setPage, + loading, + error, + } = useItems(); + + const [activeTab, setActiveTab] = useState("all"); + const [searchInput, setSearchInput] = useState(filters.search); + + useEffect(() => { + setSearchInput(filters.search); + }, [filters.search]); + + const handleSearchChange = (value: string) => { + setSearchInput(value); + setSearchDebounced(value); + }; + + const handleClearFilters = () => { + setSearchInput(""); + setFilters({ search: "", type: null, rarity: null }); + }; + + const totalPages = Math.ceil(total / limit); + + return ( +
+ {/* Header */} +
+

Items

+ + {/* Tabs */} +
+ + +
+
+ + {/* Error banner */} + {error && ( +
+ +
+

Error

+

{error}

+
+
+ )} + + {/* Content */} +
+ {activeTab === "all" ? ( +
+ setFilters({ type: v })} + rarity={filters.rarity} + onRarityChange={(v) => setFilters({ rarity: v })} + onClear={handleClearFilters} + /> + + {!loading && items.length > 0 && ( + + )} +
+ ) : ( +
+ +

+ Item Studio +

+

+ AI-assisted item editor coming soon. Create and customize items with + generated icons, balanced stats, and lore descriptions. +

+
+ )} +
+
+ ); +}