npm workspaces (Fastify+TS server, Vite+React web), SQLite schema, ao-bin-dumps downloader and parser (items, recipes, enchant upgrades, EN localization), item browser UI and debug API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
133 lines
4.3 KiB
TypeScript
133 lines
4.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
|
||
interface SearchResult {
|
||
unique_name: string;
|
||
name_en: string | null;
|
||
tier: number | null;
|
||
enchantment: number;
|
||
shop_category: string | null;
|
||
shop_subcategory: string | null;
|
||
}
|
||
|
||
interface RecipeInput {
|
||
input_item: string;
|
||
count: number;
|
||
max_return_amount: number | null;
|
||
name_en: string | null;
|
||
}
|
||
|
||
interface Recipe {
|
||
id: number;
|
||
source: string;
|
||
variant: number;
|
||
amount_crafted: number;
|
||
silver_cost: number;
|
||
focus_cost: number;
|
||
inputs: RecipeInput[];
|
||
}
|
||
|
||
interface ItemDetail {
|
||
item: SearchResult & { weight: number | null; item_value: number | null; crafting_category: string | null };
|
||
recipes: Recipe[];
|
||
usedIn: { output_item: string; name_en: string | null }[];
|
||
}
|
||
|
||
export default function ItemsPage() {
|
||
const [q, setQ] = useState('');
|
||
const [results, setResults] = useState<SearchResult[]>([]);
|
||
const [detail, setDetail] = useState<ItemDetail | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!q.trim()) {
|
||
setResults([]);
|
||
return;
|
||
}
|
||
const t = setTimeout(() => {
|
||
fetch(`/api/items/search?q=${encodeURIComponent(q)}`)
|
||
.then((r) => r.json())
|
||
.then((d) => setResults(d.results ?? []));
|
||
}, 250);
|
||
return () => clearTimeout(t);
|
||
}, [q]);
|
||
|
||
const open = (uniqueName: string) => {
|
||
fetch(`/api/items/${encodeURIComponent(uniqueName)}`)
|
||
.then((r) => r.json())
|
||
.then(setDetail);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<h2>Item Browser</h2>
|
||
<div className="card">
|
||
<input
|
||
type="text"
|
||
placeholder="Search items (e.g. bag, T4_BAG, claymore)..."
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
/>
|
||
{results.length > 0 && (
|
||
<table style={{ marginTop: 12 }}>
|
||
<thead>
|
||
<tr><th>Name</th><th>ID</th><th>Tier</th><th>Category</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{results.map((r) => (
|
||
<tr key={r.unique_name} className="clickable" onClick={() => open(r.unique_name)}>
|
||
<td>
|
||
{r.name_en ?? <span className="muted">(unnamed)</span>}
|
||
{r.enchantment > 0 && <span className="tag">.{r.enchantment}</span>}
|
||
</td>
|
||
<td className="muted">{r.unique_name}</td>
|
||
<td>{r.tier ?? '—'}</td>
|
||
<td className="muted">{r.shop_subcategory ?? r.shop_category ?? '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
{detail && (
|
||
<div className="card">
|
||
<h3>
|
||
{detail.item.name_en ?? detail.item.unique_name}
|
||
<span className="tag">{detail.item.unique_name}</span>
|
||
</h3>
|
||
<p className="muted">
|
||
Tier {detail.item.tier ?? '—'} · weight {detail.item.weight ?? '—'} · item value{' '}
|
||
{detail.item.item_value ?? '—'} · {detail.item.crafting_category ?? 'no crafting category'}
|
||
</p>
|
||
{detail.recipes.length === 0 && <p className="muted">No crafting recipe (gathered, dropped, or vendor item).</p>}
|
||
{detail.recipes.map((r) => (
|
||
<div key={r.id} style={{ marginBottom: 12 }}>
|
||
<strong>
|
||
{r.source === 'upgrade' ? 'Enchanting (upgrade)' : 'Crafting'} recipe
|
||
{r.variant > 0 ? ` (alt ${r.variant})` : ''}
|
||
</strong>
|
||
<span className="tag">focus {r.focus_cost}</span>
|
||
{r.amount_crafted !== 1 && <span className="tag">crafts {r.amount_crafted}</span>}
|
||
<ul>
|
||
{r.inputs.map((inp) => (
|
||
<li key={inp.input_item}>
|
||
{inp.count}× {inp.name_en ?? inp.input_item}{' '}
|
||
{inp.max_return_amount === 0 && <span className="tag">no return</span>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
{detail.usedIn.length > 0 && (
|
||
<>
|
||
<strong>Used in</strong>
|
||
<p className="muted" style={{ marginTop: 4 }}>
|
||
{detail.usedIn.slice(0, 20).map((u) => u.name_en ?? u.output_item).join(', ')}
|
||
{detail.usedIn.length > 20 ? ` … +${detail.usedIn.length - 20} more` : ''}
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|