feat: implement basic items page, with a placeholder for item creation tool.

This commit is contained in:
syntaxbullet
2026-02-16 17:22:18 +01:00
parent 15e01906a3
commit 65f5663c97
4 changed files with 596 additions and 1 deletions

View File

@@ -1,5 +1,5 @@
### Frontend ### Frontend
[8bb0] [ ] implement items page [8bb0] [>] implement items page
[de51] [ ] implement classes page [de51] [ ] implement classes page
[d108] [ ] implement quests page [d108] [ ] implement quests page
[8bbe] [ ] implement lootdrops page [8bbe] [ ] implement lootdrops page

View File

@@ -5,6 +5,7 @@ import Layout, { type Page } from "./components/Layout";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import Settings from "./pages/Settings"; import Settings from "./pages/Settings";
import Users from "./pages/Users"; import Users from "./pages/Users";
import Items from "./pages/Items";
import PlaceholderPage from "./pages/PlaceholderPage"; import PlaceholderPage from "./pages/PlaceholderPage";
const placeholders: Record<string, { title: string; description: string }> = { const placeholders: Record<string, { title: string; description: string }> = {
@@ -78,6 +79,8 @@ export default function App() {
<Dashboard /> <Dashboard />
) : page === "users" ? ( ) : page === "users" ? (
<Users /> <Users />
) : page === "items" ? (
<Items />
) : page === "settings" ? ( ) : page === "settings" ? (
<Settings /> <Settings />
) : ( ) : (

99
panel/src/lib/useItems.ts Normal file
View File

@@ -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<Item[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(50);
const [filters, setFiltersState] = useState<ItemFilters>({
search: "",
type: null,
rarity: null,
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<ItemFilters>) => {
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,
};
}

493
panel/src/pages/Items.tsx Normal file
View File

@@ -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<string, string> = {
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 (
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
value={search}
onChange={(e) => 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"
)}
/>
</div>
<select
value={type ?? ""}
onChange={(e) => onTypeChange(e.target.value || null)}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
>
<option value="">All Types</option>
<option value="MATERIAL">Material</option>
<option value="CONSUMABLE">Consumable</option>
<option value="EQUIPMENT">Equipment</option>
<option value="QUEST">Quest</option>
</select>
<select
value={rarity ?? ""}
onChange={(e) => onRarityChange(e.target.value || null)}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
>
<option value="">All Rarities</option>
<option value="C">Common (C)</option>
<option value="R">Rare (R)</option>
<option value="SR">Super Rare (SR)</option>
<option value="SSR">SSR</option>
</select>
<button
onClick={onClear}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-text-secondary",
"hover:bg-destructive hover:text-white hover:border-destructive transition-colors"
)}
>
Clear
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// ItemTable
// ---------------------------------------------------------------------------
function ItemTable({
items,
loading,
}: {
items: Item[];
loading: boolean;
}) {
const columns = ["ID", "Icon", "Name", "Type", "Rarity", "Price", "Description"];
if (loading) {
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-raised border-b border-border">
<tr>
{columns.map((col) => (
<th key={col} className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{[...Array(5)].map((_, i) => (
<tr key={i} className="border-b border-border">
{columns.map((col) => (
<td key={col} className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-20"></div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
if (items.length === 0) {
return (
<div className="bg-card border border-border rounded-lg p-12 text-center">
<Package className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
<p className="text-lg font-semibold text-text-secondary mb-2">
No items found
</p>
<p className="text-sm text-text-tertiary">
Try adjusting your search or filter criteria
</p>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-raised border-b border-border">
<tr>
{columns.map((col) => (
<th key={col} className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.id}
className="border-b border-border hover:bg-raised transition-colors"
>
<td className="px-4 py-3">
<span className="text-sm font-mono text-text-tertiary">
{item.id}
</span>
</td>
<td className="px-4 py-3">
<img
src={item.iconUrl}
alt={item.name}
className="w-8 h-8 rounded object-cover bg-raised"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</td>
<td className="px-4 py-3">
<span className="text-sm font-medium text-foreground">
{item.name}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-foreground capitalize">
{item.type.toLowerCase()}
</span>
</td>
<td className="px-4 py-3">
<span
className={cn(
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
RARITY_COLORS[item.rarity] ?? "bg-gray-500/20 text-gray-400"
)}
>
{item.rarity}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-foreground">
{item.price ? formatNumber(item.price) : "—"}
</span>
</td>
<td className="px-4 py-3 max-w-[200px]">
<span className="text-sm text-text-secondary truncate block">
{item.description || "—"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="mt-4 flex flex-wrap gap-4 items-center justify-between">
<p className="text-sm text-text-secondary">
Showing {startItem}{endItem} of {formatNumber(total)} items
</p>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
currentPage === 1
? "bg-raised text-text-tertiary cursor-not-allowed"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
<ChevronLeft className="w-4 h-4" />
</button>
{getPageNumbers().map((page, i) =>
typeof page === "number" ? (
<button
key={i}
onClick={() => onPageChange(page)}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors min-w-[40px]",
page === currentPage
? "bg-primary text-white"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
{page}
</button>
) : (
<span key={i} className="px-2 text-text-tertiary">
{page}
</span>
)
)}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
currentPage === totalPages
? "bg-raised text-text-tertiary cursor-not-allowed"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
<ChevronRight className="w-4 h-4" />
</button>
<select
value={limit}
onChange={(e) => onLimitChange(Number(e.target.value))}
className={cn(
"ml-2 bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
>
<option value="10">10 / page</option>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main Items Component
// ---------------------------------------------------------------------------
export default function Items() {
const {
items,
total,
currentPage,
limit,
setLimit,
filters,
setFilters,
setSearchDebounced,
setPage,
loading,
error,
} = useItems();
const [activeTab, setActiveTab] = useState<Tab>("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 (
<div className="flex flex-col h-full">
{/* Header */}
<header className="border-b border-border p-6 space-y-4">
<h1 className="text-2xl font-bold text-foreground">Items</h1>
{/* Tabs */}
<div className="flex gap-1 border-b border-border -mb-4 pb-px">
<button
onClick={() => setActiveTab("all")}
className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeTab === "all"
? "border-primary text-primary"
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
)}
>
<Package className="w-4 h-4" />
All Items
</button>
<button
onClick={() => setActiveTab("studio")}
className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeTab === "studio"
? "border-primary text-primary"
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
)}
>
<Sparkles className="w-4 h-4" />
Item Studio
</button>
</div>
</header>
{/* Error banner */}
{error && (
<div className="mx-6 mt-4 bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-semibold text-destructive">Error</p>
<p className="text-sm text-destructive/90">{error}</p>
</div>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{activeTab === "all" ? (
<div className="space-y-4">
<SearchFilterBar
search={searchInput}
onSearchChange={handleSearchChange}
type={filters.type}
onTypeChange={(v) => setFilters({ type: v })}
rarity={filters.rarity}
onRarityChange={(v) => setFilters({ rarity: v })}
onClear={handleClearFilters}
/>
<ItemTable items={items} loading={loading} />
{!loading && items.length > 0 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
limit={limit}
total={total}
onPageChange={setPage}
onLimitChange={setLimit}
/>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-24 text-center">
<Sparkles className="w-16 h-16 text-text-tertiary mb-4" />
<h2 className="text-lg font-semibold text-text-secondary mb-2">
Item Studio
</h2>
<p className="text-sm text-text-tertiary max-w-md">
AI-assisted item editor coming soon. Create and customize items with
generated icons, balanced stats, and lore descriptions.
</p>
</div>
)}
</div>
</div>
);
}