import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useWatchlist } from "../context/WatchlistContext.jsx"; const STATIC_ASSETS = "https://warframe.market/static/assets"; const PAGE_SIZE = 200; const CATEGORIES = ["mods", "arcanes", "rivens", "weapons", "warframes", "miscellaneous"]; const WEAPON_TAGS = new Set([ "weapon", "primary", "secondary", "melee", "rifle", "pistol", "shotgun", "archgun", "archmelee", "bow", "sniper", "thrown_melee", "claws", "daggers", "dual_daggers", "dual_swords", "fists", "glaives", "hammers", "heavy_blade", "machetes", "nikanas", "polearms", "rapiers", "scythes", "sparring", "staves", "sword_and_shield", "swords", "tonfas", "whips", "blade_and_whip", "gunblade", "nunchaku", "dual_nikanas", "two_handed_nikana", "heavy_scythe", "assault_rifle", "sniper", "submachine", "speargun", "crossbow", "beam", "kitgun", "zaw", "k_drive", "parazon", ]); function getItemCategory(item) { const tags = item.tags || []; if (tags.includes("riven_mod") || tags.includes("riven")) return "rivens"; if (tags.includes("mod") || tags.includes("augment") || tags.includes("stance")) return "mods"; if (tags.includes("arcane_enhancement") || tags.includes("arcane_helmet")) return "arcanes"; if (tags.includes("warframe")) return "warframes"; if (tags.some((t) => WEAPON_TAGS.has(t))) return "weapons"; return "miscellaneous"; } export default function CatalogView() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [search, setSearch] = useState(""); const [selectedCats, setSelectedCats] = useState([]); const [sortKey, setSortKey] = useState("name"); const [sortAsc, setSortAsc] = useState(true); const [page, setPage] = useState(0); const { toggleItem, isWatched } = useWatchlist(); const [prices, setPrices] = useState({}); const [pricesLoading, setPricesLoading] = useState(false); const [priceProgress, setPriceProgress] = useState({ loaded: 0, total: 0 }); const bgDoneRef = useRef(false); const doStream = useCallback(async (slugs, { label = "", onComplete } = {}) => { if (slugs.length === 0) { if (onComplete) onComplete(); return; } setPricesLoading(true); setPriceProgress({ loaded: 0, total: slugs.length }); let loaded = 0; try { const res = await fetch("/api/prices/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slugs }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop(); for (const line of lines) { if (line.startsWith("data: ")) { const json = line.slice(6); try { const batch = JSON.parse(json); const count = Object.keys(batch).length; if (count > 0) { setPrices((prev) => ({ ...prev, ...batch })); loaded += count; setPriceProgress({ loaded, total: slugs.length }); } } catch {} } else if (line.startsWith("event: done")) { if (onComplete) onComplete(); return; } } } } catch (err) { console.error(label ? `[${label}] ` : "", "Stream price fetch failed:", err); } finally { setPricesLoading(false); } }, []); useEffect(() => { let cancelled = false; async function fetchItems() { setLoading(true); setError(null); try { const res = await fetch("/api/items"); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); if (!cancelled) setItems(data); } catch (err) { if (!cancelled) setError(err.message); } finally { if (!cancelled) setLoading(false); } } fetchItems(); return () => { cancelled = true; }; }, []); useEffect(() => { if (items.length === 0 || bgDoneRef.current) return; const slugs = items.map((item) => item.slug); doStream(slugs, { label: "bg", onComplete: () => { bgDoneRef.current = true; }, }); }, [items, doStream]); const filtered = useMemo(() => { let list = items; if (search) { const q = search.toLowerCase(); list = list.filter((item) => { const name = item.i18n?.en?.name || ""; return name.toLowerCase().includes(q); }); } if (selectedCats.length > 0) { list = list.filter((item) => selectedCats.includes(getItemCategory(item)) ); } return list; }, [items, search, selectedCats]); const sorted = useMemo(() => { let list = [...filtered]; if (sortKey === "platinum") { const priced = []; const unpriced = []; for (const item of list) { const p = prices[item.slug]; if (p?.lowestSell != null && p?.highestBuy != null) priced.push(item); else unpriced.push(item); } priced.sort((a, b) => prices[a.slug].lowestSell - prices[b.slug].lowestSell); if (!sortAsc) priced.reverse(); unpriced.sort((a, b) => (a.i18n?.en?.name || "").localeCompare(b.i18n?.en?.name || "")); return [...priced, ...unpriced]; } list.sort((a, b) => { let va, vb; if (sortKey === "name") { va = a.i18n?.en?.name || ""; vb = b.i18n?.en?.name || ""; } else if (sortKey === "category") { va = getItemCategory(a); vb = getItemCategory(b); } else { va = a.id; vb = b.id; } if (va < vb) return sortAsc ? -1 : 1; if (va > vb) return sortAsc ? 1 : -1; return 0; }); return list; }, [filtered, sortKey, sortAsc, prices]); const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages - 1); const pageItems = sorted.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE); function handleSort(key) { if (sortKey === key) setSortAsc(!sortAsc); else { setSortKey(key); setSortAsc(true); } setPage(0); } function toggleCat(cat) { setSelectedCats((prev) => prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat] ); setPage(0); } const sortArrow = (key) => { if (sortKey !== key) return ""; return {sortAsc ? "\u25B2" : "\u25BC"}; }; if (loading) { return (
Loading item catalog...
); } if (error) { return (

Failed to load catalog: {error}

); } const pricedCount = Object.keys(prices).length; const pricedPercent = items.length > 0 ? Math.round((pricedCount / items.length) * 100) : 0; return (
{pricesLoading && (
Loading prices... {priceProgress.loaded}/{priceProgress.total} ({pricedPercent}%)
)}
{ setSearch(e.target.value); setPage(0); }} />
{CATEGORIES.map((cat) => ( ))}
{pageItems.map((item) => { const name = item.i18n?.en?.name || item.slug; const thumb = item.i18n?.en?.thumb; const p = prices[item.slug]; return ( ); })} {sorted.length === 0 && ( )}
handleSort("name")}> Name{sortArrow("name")} handleSort("category")}> Category{sortArrow("category")} handleSort("platinum")}> Platinum{sortArrow("platinum")}
{thumb && ( )} {name}
{getItemCategory(item)} {p?.lowestSell != null ? `${p.lowestSell}\u25BC` : "\u2014"}
No items match your filters.
{totalPages > 1 && (
Page {safePage + 1} of {totalPages} ({sorted.length} items)
)}
); }