feat: implement basic items page, with a placeholder for item creation tool.
This commit is contained in:
2
.citrine
2
.citrine
@@ -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
|
||||||
|
|||||||
@@ -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
99
panel/src/lib/useItems.ts
Normal 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
493
panel/src/pages/Items.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user