Client routing: per-tab URLs + state in query params
All checks were successful
Deploy / deploy (push) Successful in 2m51s

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 <noreply@anthropic.com>
This commit is contained in:
Заид Омар Медхат 2026-08-23 05:16:41 +05:00
parent d18464dcde
commit b12a9d1821
8 changed files with 190 additions and 48 deletions

41
package-lock.json generated
View file

@ -2848,6 +2848,44 @@
"node": ">=0.10.0" "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": { "node_modules/real-require": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
@ -3677,7 +3715,8 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0",
"react-router-dom": "^7.18.2"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.1.0", "@types/react": "^19.1.0",

View file

@ -10,7 +10,8 @@
}, },
"dependencies": { "dependencies": {
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0",
"react-router-dom": "^7.18.2"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.1.0", "@types/react": "^19.1.0",

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { BrowserRouter, NavLink, Navigate, Route, Routes } from 'react-router-dom';
import ItemsPage from './pages/ItemsPage'; import ItemsPage from './pages/ItemsPage';
import GameDataPage from './pages/GameDataPage'; import GameDataPage from './pages/GameDataPage';
import MarketPage from './pages/MarketPage'; import MarketPage from './pages/MarketPage';
@ -11,15 +12,15 @@ import SnifferPage from './pages/SnifferPage';
import { getApiKey, onUnauthorized, setApiKey } from './apiKey'; import { getApiKey, onUnauthorized, setApiKey } from './apiKey';
const PAGES = [ const PAGES = [
{ id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, { path: 'opportunities', label: 'Opportunities', component: OpportunitiesPage },
{ id: 'travel', label: 'Travel Planner', component: TravelPage }, { path: 'travel', label: 'Travel Planner', component: TravelPage },
{ id: 'watch', label: 'Market Watch', component: WatchPage }, { path: 'watch', label: 'Market Watch', component: WatchPage },
{ id: 'sniffer', label: 'Game Sniffer', component: SnifferPage }, { path: 'sniffer', label: 'Game Sniffer', component: SnifferPage },
{ id: 'character', label: 'Character', component: CharacterPage }, { path: 'character', label: 'Character', component: CharacterPage },
{ id: 'market', label: 'Market Data', component: MarketPage }, { path: 'market', label: 'Market Data', component: MarketPage },
{ id: 'items', label: 'Item Browser', component: ItemsPage }, { path: 'items', label: 'Item Browser', component: ItemsPage },
{ id: 'gamedata', label: 'Game Data', component: GameDataPage }, { path: 'gamedata', label: 'Game Data', component: GameDataPage },
{ id: 'settings', label: 'Settings', component: SettingsPage }, { path: 'settings', label: 'Settings', component: SettingsPage },
] as const; ] as const;
function ApiKeyGate({ onSaved }: { onSaved: () => void }) { function ApiKeyGate({ onSaved }: { onSaved: () => void }) {
@ -40,13 +41,10 @@ function ApiKeyGate({ onSaved }: { onSaved: () => void }) {
} }
export default function App() { export default function App() {
const [page, setPage] = useState<string>('opportunities');
const [locked, setLocked] = useState(false); const [locked, setLocked] = useState(false);
const Active = PAGES.find((p) => p.id === page)?.component ?? OpportunitiesPage;
useEffect(() => { useEffect(() => {
onUnauthorized(() => setLocked(true)); onUnauthorized(() => setLocked(true));
// probe: if the server needs a key and we don't have a valid one, lock
fetch('/api/settings').then((r) => { fetch('/api/settings').then((r) => {
if (r.status === 401) setLocked(true); if (r.status === 401) setLocked(true);
}); });
@ -55,20 +53,28 @@ export default function App() {
if (locked) return <ApiKeyGate onSaved={() => { setLocked(false); location.reload(); }} />; if (locked) return <ApiKeyGate onSaved={() => { setLocked(false); location.reload(); }} />;
return ( return (
<div className="app"> <BrowserRouter>
<aside className="sidebar"> <div className="app">
<h1>Albion Economy</h1> <aside className="sidebar">
<nav> <h1>Albion Economy</h1>
{PAGES.map((p) => ( <nav>
<button key={p.id} className={page === p.id ? 'active' : ''} onClick={() => setPage(p.id)}> {PAGES.map((p) => (
{p.label} <NavLink key={p.path} to={`/${p.path}`} className={({ isActive }) => (isActive ? 'active' : '')}>
</button> {p.label}
))} </NavLink>
</nav> ))}
</aside> </nav>
<main className="main"> </aside>
<Active /> <main className="main">
</main> <Routes>
</div> <Route path="/" element={<Navigate to="/opportunities" replace />} />
{PAGES.map((p) => (
<Route key={p.path} path={`/${p.path}`} element={<p.component />} />
))}
<Route path="*" element={<Navigate to="/opportunities" replace />} />
</Routes>
</main>
</div>
</BrowserRouter>
); );
} }

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
interface SearchResult { interface SearchResult {
unique_name: string; unique_name: string;
@ -33,10 +34,24 @@ interface ItemDetail {
} }
export default function ItemsPage() { 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<SearchResult[]>([]); const [results, setResults] = useState<SearchResult[]>([]);
const [detail, setDetail] = useState<ItemDetail | null>(null); const [detail, setDetail] = useState<ItemDetail | null>(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(() => { useEffect(() => {
if (!q.trim()) { if (!q.trim()) {
setResults([]); setResults([]);
@ -50,10 +65,26 @@ export default function ItemsPage() {
return () => clearTimeout(t); return () => clearTimeout(t);
}, [q]); }, [q]);
const open = (uniqueName: string) => { // load the item named in the URL (deep link / refresh)
fetch(`/api/items/${encodeURIComponent(uniqueName)}`) useEffect(() => {
if (!openItem) {
setDetail(null);
return;
}
fetch(`/api/items/${encodeURIComponent(openItem)}`)
.then((r) => r.json()) .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 ( return (

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
interface Job { interface Job {
status: 'idle' | 'running' | 'done' | 'error'; status: 'idle' | 'running' | 'done' | 'error';
@ -55,6 +56,7 @@ function tierTag(it: { tier: number | null; enchantment: number }): string {
} }
export default function MarketPage() { export default function MarketPage() {
const [params, setParams] = useSearchParams();
const [summary, setSummary] = useState<Summary | null>(null); const [summary, setSummary] = useState<Summary | null>(null);
const [minTier, setMinTier] = useState(2); const [minTier, setMinTier] = useState(2);
const [maxTier, setMaxTier] = useState(8); const [maxTier, setMaxTier] = useState(8);
@ -112,8 +114,28 @@ export default function MarketPage() {
setResults([]); setResults([]);
setQ(''); setQ('');
loadPrices(it.unique_name); 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 () => { const startRefresh = async () => {
await fetch('/api/market/refresh', { await fetch('/api/market/refresh', {
method: 'POST', method: 'POST',

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
interface InputBreakdown { interface InputBreakdown {
item: string; item: string;
@ -48,21 +49,46 @@ function Age({ hours }: { hours: number | null }) {
} }
export default function OpportunitiesPage() { export default function OpportunitiesPage() {
const [city, setCity] = useState('Lymhurst'); const [params, setParams] = useSearchParams();
const [minTier, setMinTier] = useState(4); const p = (k: string, d: string) => params.get(k) ?? d;
const [maxTier, setMaxTier] = useState(6);
const [useFocus, setUseFocus] = useState(false); const [city, setCity] = useState(() => p('city', 'Lymhurst'));
const [premium, setPremium] = useState(true); const [minTier, setMinTier] = useState(() => Number(p('minTier', '4')));
const [sellMode, setSellMode] = useState<'order' | 'instant'>('order'); const [maxTier, setMaxTier] = useState(() => Number(p('maxTier', '6')));
const [stationFee, setStationFee] = useState(400); const [useFocus, setUseFocus] = useState(() => p('useFocus', '0') === '1');
const [focusEff, setFocusEff] = useState(''); // empty = use the active character profile 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<string | null>(null); const [profileName, setProfileName] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<'profit' | 'roi' | 'focus'>('profit');
const [opps, setOpps] = useState<Opp[]>([]); const [opps, setOpps] = useState<Opp[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState<number | null>(null); const [expanded, setExpanded] = useState<number | null>(null);
const [ranAt, setRanAt] = useState<string | null>(null); const [ranAt, setRanAt] = useState<string | null>(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 () => { const run = async () => {
setLoading(true); setLoading(true);
setExpanded(null); setExpanded(null);

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
interface Zone { interface Zone {
albionId: string; albionId: string;
@ -50,11 +51,25 @@ const CITIES = ['Bridgewatch', 'Caerleon', 'Fort Sterling', 'Lymhurst', 'Martloc
const silver = (n: number) => Math.round(n).toLocaleString('en-US'); const silver = (n: number) => Math.round(n).toLocaleString('en-US');
export default function TravelPage() { 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<Status | null>(null); const [status, setStatus] = useState<Status | null>(null);
const [zones, setZones] = useState<Zone[]>([]); const [zones, setZones] = useState<Zone[]>([]);
const [from, setFrom] = useState('Lymhurst');
const [to, setTo] = useState('Martlock');
const [mode, setMode] = useState<'safe' | 'all'>('safe');
const [route, setRoute] = useState<RouteResult | null>(null); const [route, setRoute] = useState<RouteResult | null>(null);
const [routeError, setRouteError] = useState(''); const [routeError, setRouteError] = useState('');
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);

View file

@ -29,7 +29,8 @@ body {
} }
.sidebar h1 { font-size: 17px; color: var(--accent); margin: 4px 8px 18px; } .sidebar h1 { font-size: 17px; color: var(--accent); margin: 4px 8px 18px; }
.sidebar nav { display: flex; flex-direction: column; gap: 2px; } .sidebar nav { display: flex; flex-direction: column; gap: 2px; }
.sidebar nav button { .sidebar nav button, .sidebar nav a {
display: block;
text-align: left; text-align: left;
background: none; background: none;
border: none; border: none;
@ -38,9 +39,10 @@ body {
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
text-decoration: none;
} }
.sidebar nav button:hover { background: #262c36; color: var(--text); } .sidebar nav button:hover, .sidebar nav a:hover { background: #262c36; color: var(--text); }
.sidebar nav button.active { background: #2b323e; color: var(--accent); font-weight: 600; } .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 { flex: 1; padding: 24px 28px; min-width: 0; }
.main h2 { margin-top: 0; } .main h2 { margin-top: 0; }