Spaces:
Paused
Paused
| 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 <span className="sort-arrow">{sortAsc ? "\u25B2" : "\u25BC"}</span>; | |
| }; | |
| if (loading) { | |
| return ( | |
| <div className="loading"> | |
| <div className="spinner" /> Loading item catalog... | |
| </div> | |
| ); | |
| } | |
| if (error) { | |
| return ( | |
| <div className="error-box"> | |
| <p>Failed to load catalog: {error}</p> | |
| <button onClick={() => window.location.reload()}>Retry</button> | |
| </div> | |
| ); | |
| } | |
| const pricedCount = Object.keys(prices).length; | |
| const pricedPercent = items.length > 0 ? Math.round((pricedCount / items.length) * 100) : 0; | |
| return ( | |
| <div> | |
| {pricesLoading && ( | |
| <div className="progress-bar-wrap"> | |
| <div className="progress-bar"> | |
| <div className="progress-bar-fill" style={{ width: `${pricedPercent}%` }} /> | |
| </div> | |
| <span className="progress-text"> | |
| Loading prices... {priceProgress.loaded}/{priceProgress.total} ({pricedPercent}%) | |
| </span> | |
| </div> | |
| )} | |
| <div className="controls"> | |
| <input | |
| className="search-box" | |
| type="text" | |
| placeholder="Search items..." | |
| value={search} | |
| onChange={(e) => { setSearch(e.target.value); setPage(0); }} | |
| /> | |
| <button | |
| className={`tab-btn${sortKey === "platinum" ? " active" : ""}`} | |
| onClick={() => handleSort("platinum")} | |
| > | |
| {pricesLoading ? `Loading... ${pricedPercent}%` : pricedCount > 0 ? "Sort by Platinum \u2713" : "Sort by Platinum"} | |
| </button> | |
| </div> | |
| <div className="controls"> | |
| <div className="tag-filters"> | |
| {CATEGORIES.map((cat) => ( | |
| <button | |
| key={cat} | |
| className={`tag-btn${selectedCats.includes(cat) ? " active" : ""}`} | |
| onClick={() => toggleCat(cat)} | |
| > | |
| {cat} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="table-wrap"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th style={{ width: 40 }}></th> | |
| <th onClick={() => handleSort("name")}> | |
| Name{sortArrow("name")} | |
| </th> | |
| <th onClick={() => handleSort("category")}> | |
| Category{sortArrow("category")} | |
| </th> | |
| <th onClick={() => handleSort("platinum")}> | |
| Platinum{sortArrow("platinum")} | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {pageItems.map((item) => { | |
| const name = item.i18n?.en?.name || item.slug; | |
| const thumb = item.i18n?.en?.thumb; | |
| const p = prices[item.slug]; | |
| return ( | |
| <tr key={item.id}> | |
| <td> | |
| <button | |
| className={`star-btn${isWatched(item.slug) ? " starred" : ""}`} | |
| onClick={() => toggleItem(item.slug)} | |
| title={isWatched(item.slug) ? "Remove from watchlist" : "Add to watchlist"} | |
| > | |
| {isWatched(item.slug) ? "\u2605" : "\u2606"} | |
| </button> | |
| </td> | |
| <td> | |
| <div className="item-name"> | |
| {thumb && ( | |
| <img | |
| className="item-thumb" | |
| src={`${STATIC_ASSETS}/${thumb}`} | |
| alt="" | |
| loading="lazy" | |
| /> | |
| )} | |
| <span>{name}</span> | |
| </div> | |
| </td> | |
| <td> | |
| <span className="tag-badge"> | |
| {getItemCategory(item)} | |
| </span> | |
| </td> | |
| <td className="price-sell"> | |
| {p?.lowestSell != null ? `${p.lowestSell}\u25BC` : "\u2014"} | |
| </td> | |
| </tr> | |
| ); | |
| })} | |
| {sorted.length === 0 && ( | |
| <tr> | |
| <td colSpan={4} className="empty-msg"> | |
| No items match your filters. | |
| </td> | |
| </tr> | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| {totalPages > 1 && ( | |
| <div className="pagination"> | |
| <button | |
| className="tab-btn" | |
| disabled={safePage === 0} | |
| onClick={() => setPage(safePage - 1)} | |
| > | |
| ← Prev | |
| </button> | |
| <span className="page-info"> | |
| Page {safePage + 1} of {totalPages} ({sorted.length} items) | |
| </span> | |
| <button | |
| className="tab-btn" | |
| disabled={safePage >= totalPages - 1} | |
| onClick={() => setPage(safePage + 1)} | |
| > | |
| Next → | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |