WarTracker / client /src /components /CatalogView.jsx
skander101's picture
initial commit: vibes only, probably bloated
6512187
Raw
History Blame Contribute Delete
11.6 kB
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)}
>
&larr; 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 &rarr;
</button>
</div>
)}
</div>
);
}