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": ">=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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue