From b12a9d1821f3032a76f48614f08ef96400797d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D0=B8=D0=B4=20=D0=9E=D0=BC=D0=B0=D1=80=20?= =?UTF-8?q?=D0=9C=D0=B5=D0=B4=D1=85=D0=B0=D1=82?= Date: Sun, 23 Aug 2026 05:16:41 +0500 Subject: [PATCH] Client routing: per-tab URLs + state in query params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add react-router so each tab is its own path (/opportunities, /travel, /market, /items, …) and a refresh or deep link lands on the same page. Page state is mirrored to the query string so a refresh restores it: - Item Browser: ?q= (search) and ?item= (open item) - Market Data: ?item= (selected item, rebuilt on load) - Opportunities: all filters (city, tiers, focus, premium, sellMode, station fee, sort) in the URL - Travel Planner: ?from=&to=&mode= The server already serves index.html for non-API paths, so deep links work. Sidebar uses NavLink for active state. Co-Authored-By: Claude Fable 5 --- package-lock.json | 41 +++++++++++++++++++- web/package.json | 3 +- web/src/App.tsx | 60 ++++++++++++++++------------- web/src/pages/ItemsPage.tsx | 39 +++++++++++++++++-- web/src/pages/MarketPage.tsx | 22 +++++++++++ web/src/pages/OpportunitiesPage.tsx | 44 ++++++++++++++++----- web/src/pages/TravelPage.tsx | 21 ++++++++-- web/src/styles.css | 8 ++-- 8 files changed, 190 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3eee769..6b2f137 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2848,6 +2848,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -3677,7 +3715,8 @@ "version": "0.1.0", "dependencies": { "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-router-dom": "^7.18.2" }, "devDependencies": { "@types/react": "^19.1.0", diff --git a/web/package.json b/web/package.json index d6c99cc..c019a2e 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,8 @@ }, "dependencies": { "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-router-dom": "^7.18.2" }, "devDependencies": { "@types/react": "^19.1.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index 0566b69..88cf170 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { BrowserRouter, NavLink, Navigate, Route, Routes } from 'react-router-dom'; import ItemsPage from './pages/ItemsPage'; import GameDataPage from './pages/GameDataPage'; import MarketPage from './pages/MarketPage'; @@ -11,15 +12,15 @@ import SnifferPage from './pages/SnifferPage'; import { getApiKey, onUnauthorized, setApiKey } from './apiKey'; const PAGES = [ - { id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, - { id: 'travel', label: 'Travel Planner', component: TravelPage }, - { id: 'watch', label: 'Market Watch', component: WatchPage }, - { id: 'sniffer', label: 'Game Sniffer', component: SnifferPage }, - { id: 'character', label: 'Character', component: CharacterPage }, - { id: 'market', label: 'Market Data', component: MarketPage }, - { id: 'items', label: 'Item Browser', component: ItemsPage }, - { id: 'gamedata', label: 'Game Data', component: GameDataPage }, - { id: 'settings', label: 'Settings', component: SettingsPage }, + { path: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, + { path: 'travel', label: 'Travel Planner', component: TravelPage }, + { path: 'watch', label: 'Market Watch', component: WatchPage }, + { path: 'sniffer', label: 'Game Sniffer', component: SnifferPage }, + { path: 'character', label: 'Character', component: CharacterPage }, + { path: 'market', label: 'Market Data', component: MarketPage }, + { path: 'items', label: 'Item Browser', component: ItemsPage }, + { path: 'gamedata', label: 'Game Data', component: GameDataPage }, + { path: 'settings', label: 'Settings', component: SettingsPage }, ] as const; function ApiKeyGate({ onSaved }: { onSaved: () => void }) { @@ -40,13 +41,10 @@ function ApiKeyGate({ onSaved }: { onSaved: () => void }) { } export default function App() { - const [page, setPage] = useState('opportunities'); const [locked, setLocked] = useState(false); - const Active = PAGES.find((p) => p.id === page)?.component ?? OpportunitiesPage; useEffect(() => { onUnauthorized(() => setLocked(true)); - // probe: if the server needs a key and we don't have a valid one, lock fetch('/api/settings').then((r) => { if (r.status === 401) setLocked(true); }); @@ -55,20 +53,28 @@ export default function App() { if (locked) return { setLocked(false); location.reload(); }} />; return ( -
- -
- -
-
+ +
+ +
+ + } /> + {PAGES.map((p) => ( + } /> + ))} + } /> + +
+
+
); } diff --git a/web/src/pages/ItemsPage.tsx b/web/src/pages/ItemsPage.tsx index 5dd48b3..5091bae 100644 --- a/web/src/pages/ItemsPage.tsx +++ b/web/src/pages/ItemsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; interface SearchResult { unique_name: string; @@ -33,10 +34,24 @@ interface ItemDetail { } export default function ItemsPage() { - const [q, setQ] = useState(''); + const [params, setParams] = useSearchParams(); + const q = params.get('q') ?? ''; + const openItem = params.get('item') ?? ''; const [results, setResults] = useState([]); const [detail, setDetail] = useState(null); + const setQ = (value: string) => { + setParams( + (prev) => { + const next = new URLSearchParams(prev); + if (value) next.set('q', value); + else next.delete('q'); + return next; + }, + { replace: true }, + ); + }; + useEffect(() => { if (!q.trim()) { setResults([]); @@ -50,10 +65,26 @@ export default function ItemsPage() { return () => clearTimeout(t); }, [q]); - const open = (uniqueName: string) => { - fetch(`/api/items/${encodeURIComponent(uniqueName)}`) + // load the item named in the URL (deep link / refresh) + useEffect(() => { + if (!openItem) { + setDetail(null); + return; + } + fetch(`/api/items/${encodeURIComponent(openItem)}`) .then((r) => r.json()) - .then(setDetail); + .then((d) => setDetail(d.item ? d : null)); + }, [openItem]); + + const open = (uniqueName: string) => { + setParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set('item', uniqueName); + return next; + }, + { replace: true }, + ); }; return ( diff --git a/web/src/pages/MarketPage.tsx b/web/src/pages/MarketPage.tsx index dc33335..35ec305 100644 --- a/web/src/pages/MarketPage.tsx +++ b/web/src/pages/MarketPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; interface Job { status: 'idle' | 'running' | 'done' | 'error'; @@ -55,6 +56,7 @@ function tierTag(it: { tier: number | null; enchantment: number }): string { } export default function MarketPage() { + const [params, setParams] = useSearchParams(); const [summary, setSummary] = useState(null); const [minTier, setMinTier] = useState(2); const [maxTier, setMaxTier] = useState(8); @@ -112,8 +114,28 @@ export default function MarketPage() { setResults([]); setQ(''); loadPrices(it.unique_name); + setParams((prev) => { + const next = new URLSearchParams(prev); + next.set('item', it.unique_name); + return next; + }, { replace: true }); }; + // restore the selected item from the URL on load / refresh + useEffect(() => { + const itemParam = params.get('item'); + if (!itemParam || selected?.unique_name === itemParam) return; + fetch(`/api/items/${encodeURIComponent(itemParam)}`) + .then((r) => r.json()) + .then((d) => { + if (d?.item) { + setSelected({ unique_name: d.item.unique_name, name_en: d.item.name_en, tier: d.item.tier, enchantment: d.item.enchantment }); + loadPrices(d.item.unique_name); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params]); + const startRefresh = async () => { await fetch('/api/market/refresh', { method: 'POST', diff --git a/web/src/pages/OpportunitiesPage.tsx b/web/src/pages/OpportunitiesPage.tsx index 346df66..0ba72f9 100644 --- a/web/src/pages/OpportunitiesPage.tsx +++ b/web/src/pages/OpportunitiesPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; interface InputBreakdown { item: string; @@ -48,21 +49,46 @@ function Age({ hours }: { hours: number | null }) { } export default function OpportunitiesPage() { - const [city, setCity] = useState('Lymhurst'); - const [minTier, setMinTier] = useState(4); - const [maxTier, setMaxTier] = useState(6); - const [useFocus, setUseFocus] = useState(false); - const [premium, setPremium] = useState(true); - const [sellMode, setSellMode] = useState<'order' | 'instant'>('order'); - const [stationFee, setStationFee] = useState(400); - const [focusEff, setFocusEff] = useState(''); // empty = use the active character profile + const [params, setParams] = useSearchParams(); + const p = (k: string, d: string) => params.get(k) ?? d; + + const [city, setCity] = useState(() => p('city', 'Lymhurst')); + const [minTier, setMinTier] = useState(() => Number(p('minTier', '4'))); + const [maxTier, setMaxTier] = useState(() => Number(p('maxTier', '6'))); + const [useFocus, setUseFocus] = useState(() => p('useFocus', '0') === '1'); + const [premium, setPremium] = useState(() => p('premium', '1') === '1'); + const [sellMode, setSellMode] = useState<'order' | 'instant'>(() => (p('sellMode', 'order') === 'instant' ? 'instant' : 'order')); + const [stationFee, setStationFee] = useState(() => Number(p('stationFee', '400'))); + const [focusEff, setFocusEff] = useState(() => p('focusEff', '')); // empty = use the active character profile + const [sortBy, setSortBy] = useState<'profit' | 'roi' | 'focus'>(() => { + const s = p('sortBy', 'profit'); + return s === 'roi' || s === 'focus' ? s : 'profit'; + }); const [profileName, setProfileName] = useState(null); - const [sortBy, setSortBy] = useState<'profit' | 'roi' | 'focus'>('profit'); const [opps, setOpps] = useState([]); const [loading, setLoading] = useState(false); const [expanded, setExpanded] = useState(null); const [ranAt, setRanAt] = useState(null); + // keep the URL in sync so a refresh restores the same filters + useEffect(() => { + setParams( + { + city, + minTier: String(minTier), + maxTier: String(maxTier), + useFocus: useFocus ? '1' : '0', + premium: premium ? '1' : '0', + sellMode, + stationFee: String(stationFee), + sortBy, + ...(focusEff !== '' ? { focusEff } : {}), + }, + { replace: true }, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [city, minTier, maxTier, useFocus, premium, sellMode, stationFee, focusEff, sortBy]); + const run = async () => { setLoading(true); setExpanded(null); diff --git a/web/src/pages/TravelPage.tsx b/web/src/pages/TravelPage.tsx index 1e2695d..84f60ef 100644 --- a/web/src/pages/TravelPage.tsx +++ b/web/src/pages/TravelPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; interface Zone { albionId: string; @@ -50,11 +51,25 @@ const CITIES = ['Bridgewatch', 'Caerleon', 'Fort Sterling', 'Lymhurst', 'Martloc const silver = (n: number) => Math.round(n).toLocaleString('en-US'); export default function TravelPage() { + const [params, setParams] = useSearchParams(); + const from = params.get('from') ?? 'Lymhurst'; + const to = params.get('to') ?? 'Martlock'; + const mode = (params.get('mode') === 'all' ? 'all' : 'safe') as 'safe' | 'all'; + const setParam = (key: string, value: string) => + setParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set(key, value); + return next; + }, + { replace: true }, + ); + const setFrom = (v: string) => setParam('from', v); + const setTo = (v: string) => setParam('to', v); + const setMode = (v: 'safe' | 'all') => setParam('mode', v); + const [status, setStatus] = useState(null); const [zones, setZones] = useState([]); - const [from, setFrom] = useState('Lymhurst'); - const [to, setTo] = useState('Martlock'); - const [mode, setMode] = useState<'safe' | 'all'>('safe'); const [route, setRoute] = useState(null); const [routeError, setRouteError] = useState(''); const [importing, setImporting] = useState(false); diff --git a/web/src/styles.css b/web/src/styles.css index 888fda7..6f8860c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -29,7 +29,8 @@ body { } .sidebar h1 { font-size: 17px; color: var(--accent); margin: 4px 8px 18px; } .sidebar nav { display: flex; flex-direction: column; gap: 2px; } -.sidebar nav button { +.sidebar nav button, .sidebar nav a { + display: block; text-align: left; background: none; border: none; @@ -38,9 +39,10 @@ body { border-radius: 6px; cursor: pointer; font-size: 14px; + text-decoration: none; } -.sidebar nav button:hover { background: #262c36; color: var(--text); } -.sidebar nav button.active { background: #2b323e; color: var(--accent); font-weight: 600; } +.sidebar nav button:hover, .sidebar nav a:hover { background: #262c36; color: var(--text); } +.sidebar nav button.active, .sidebar nav a.active { background: #2b323e; color: var(--accent); font-weight: 600; } .main { flex: 1; padding: 24px 28px; min-width: 0; } .main h2 { margin-top: 0; }