Client routing: per-tab URLs + state in query params
All checks were successful
Deploy / deploy (push) Successful in 2m51s
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:
parent
d18464dcde
commit
b12a9d1821
8 changed files with 190 additions and 48 deletions
41
package-lock.json
generated
41
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string>('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 <ApiKeyGate onSaved={() => { setLocked(false); location.reload(); }} />;
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<h1>Albion Economy</h1>
|
||||
<nav>
|
||||
{PAGES.map((p) => (
|
||||
<button key={p.id} className={page === p.id ? 'active' : ''} onClick={() => setPage(p.id)}>
|
||||
<NavLink key={p.path} to={`/${p.path}`} className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
{p.label}
|
||||
</button>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="main">
|
||||
<Active />
|
||||
<Routes>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SearchResult[]>([]);
|
||||
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(() => {
|
||||
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 (
|
||||
|
|
|
|||
|
|
@ -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<Summary | null>(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',
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [sortBy, setSortBy] = useState<'profit' | 'roi' | 'focus'>('profit');
|
||||
const [opps, setOpps] = useState<Opp[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expanded, setExpanded] = useState<number | 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 () => {
|
||||
setLoading(true);
|
||||
setExpanded(null);
|
||||
|
|
|
|||
|
|
@ -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<Status | null>(null);
|
||||
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 [routeError, setRouteError] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue