diff --git a/README.md b/README.md index 58f687c..dadcdd4 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ npm run dev Versioning: - Node follows the Qortium app versioning standard (QAVS): the current app - version is 1.4.1, where the `1.4` prefix declares the minimum Qortium + version is 1.4.2, where the `1.4` prefix declares the minimum Qortium platform level the app is built against and the last number is the app's own release counter. - The build emits a `qortium-app.json` manifest (see `vite.config.ts`) that diff --git a/package-lock.json b/package-lock.json index 61e8125..2779de1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qortium-node", - "version": "1.4.1", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qortium-node", - "version": "1.4.1", + "version": "1.4.2", "license": "0BSD", "dependencies": { "@vitejs/plugin-react": "^6.0.2", diff --git a/package.json b/package.json index 25d67e5..05b0aa5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qortium-node", - "version": "1.4.1", + "version": "1.4.2", "private": true, "license": "0BSD", "description": "A QDN app for inspecting a Qortium node.", diff --git a/src/App.tsx b/src/App.tsx index 969d0fa..cea8a2e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { normalizePeerDiagnostics, normalizePeerList, } from './nodeData'; +import { getNodeRouteUrl, readNodeRoute } from './nodeRoute'; import { getNextPeerSortRules, sortPeerRows, @@ -47,6 +48,7 @@ import { } from './settingsEditor'; import { buildSettingsGroups, normalizeSettingsMetadata, type SettingsEntry } from './settingsView'; import type { + AppPage, BridgeState, ConnectedPeer, CoreSettings, @@ -64,8 +66,6 @@ import type { const SETTINGS_BRIDGE_ACTIONS = ['GET_NODE_SETTINGS_METADATA', 'UPDATE_NODE_SETTINGS', 'RESTART_NODE']; -type AppPage = 'overview' | 'settings'; - type PeerColumnDefinition = { column: PeerSortColumn; labelKey: MessageKey; @@ -826,7 +826,7 @@ export function App() { const [dataPeerDiagnostics, setDataPeerDiagnostics] = useState(null); const [chainPeerSortRules, setChainPeerSortRules] = useState([]); const [dataPeerSortRules, setDataPeerSortRules] = useState([]); - const [page, setPage] = useState('overview'); + const [page, setPage] = useState(() => readNodeRoute(window.location.href).page); const [isPublicNode, setIsPublicNode] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSavingSettings, setIsSavingSettings] = useState(false); @@ -865,6 +865,11 @@ export function App() { [dataPeerRows, dataPeerSortRules], ); + function navigateToPage(next: AppPage) { + window.history.pushState({}, '', getNodeRouteUrl(window.location.href, { page: next })); + setPage(next); + } + function sortChainPeers(column: PeerSortColumn) { setChainPeerSortRules((currentRules) => getNextPeerSortRules(currentRules, column)); } @@ -1003,6 +1008,22 @@ export function App() { refresh(); }, []); + useEffect(() => { + const canonicalUrl = getNodeRouteUrl(window.location.href, readNodeRoute(window.location.href)); + + if (canonicalUrl.href !== window.location.href) { + window.history.replaceState({}, '', canonicalUrl); + } + + const onPopState = () => { + setPage(readNodeRoute(window.location.href).page); + }; + + window.addEventListener('popstate', onPopState); + + return () => window.removeEventListener('popstate', onPopState); + }, []); + return (
@@ -1049,7 +1070,7 @@ export function App() { className="tab-button" role="tab" aria-selected={page === 'overview'} - onClick={() => setPage('overview')} + onClick={() => navigateToPage('overview')} > {t('label.nodeStatus')} @@ -1058,7 +1079,7 @@ export function App() { className="tab-button" role="tab" aria-selected={page === 'settings'} - onClick={() => setPage('settings')} + onClick={() => navigateToPage('settings')} > {t('label.coreSettings')} diff --git a/src/nodeRoute.test.ts b/src/nodeRoute.test.ts new file mode 100644 index 0000000..e091b43 --- /dev/null +++ b/src/nodeRoute.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { getNodeRouteUrl, readNodeRoute } from './nodeRoute'; + +describe('Node routes', () => { + it('reads a valid page value', () => { + expect(readNodeRoute('https://example.test/app?page=settings')).toEqual({ page: 'settings' }); + expect(readNodeRoute('https://example.test/app?page=overview')).toEqual({ page: 'overview' }); + }); + + it('falls back to overview for invalid or missing page values', () => { + expect(readNodeRoute('https://example.test/app')).toEqual({ page: 'overview' }); + expect(readNodeRoute('https://example.test/app?page=unknown')).toEqual({ page: 'overview' }); + expect(readNodeRoute('https://example.test/app?page=')).toEqual({ page: 'overview' }); + }); + + it('replaces only the Node-owned key while preserving host/display settings, unknown params, and fragments', () => { + const url = getNodeRouteUrl( + 'https://example.test/render/APP/Node/Node?page=settings&qdnHomeBridge=1&theme=dark&lang=en&textSize=large&accent=blue&uiStyle=modern&future=value#detail', + { page: 'overview' }, + ); + + expect(url.pathname).toBe('/render/APP/Node/Node'); + expect(url.searchParams.has('page')).toBe(false); + expect(url.searchParams.get('qdnHomeBridge')).toBe('1'); + expect(url.searchParams.get('theme')).toBe('dark'); + expect(url.searchParams.get('lang')).toBe('en'); + expect(url.searchParams.get('textSize')).toBe('large'); + expect(url.searchParams.get('accent')).toBe('blue'); + expect(url.searchParams.get('uiStyle')).toBe('modern'); + expect(url.searchParams.get('future')).toBe('value'); + expect(url.hash).toBe('#detail'); + }); + + it('omits the default page from the produced URL', () => { + const url = getNodeRouteUrl('https://example.test/app?page=settings', { page: 'overview' }); + + expect(url.searchParams.has('page')).toBe(false); + expect(url.href).toBe('https://example.test/app'); + }); + + it('writes the page param for a non-default route', () => { + const url = getNodeRouteUrl('https://example.test/app', { page: 'settings' }); + + expect(url.searchParams.get('page')).toBe('settings'); + }); + + it('round-trips every supported page value', () => { + for (const route of [{ page: 'overview' as const }, { page: 'settings' as const }]) { + expect(readNodeRoute(getNodeRouteUrl('https://example.test/app?theme=dark', route))).toEqual(route); + } + }); +}); diff --git a/src/nodeRoute.ts b/src/nodeRoute.ts new file mode 100644 index 0000000..dc66687 --- /dev/null +++ b/src/nodeRoute.ts @@ -0,0 +1,30 @@ +import type { AppPage } from './types'; + +export interface NodeRoute { + page: AppPage; +} + +const NODE_ROUTE_KEYS = ['page'] as const; +const DEFAULT_PAGE: AppPage = 'overview'; + +export function readNodeRoute(input: string | URL): NodeRoute { + const url = input instanceof URL ? input : new URL(input, 'http://localhost'); + const requestedPage = url.searchParams.get('page'); + const page: AppPage = requestedPage === 'settings' ? 'settings' : DEFAULT_PAGE; + + return { page }; +} + +export function getNodeRouteUrl(input: string | URL, route: NodeRoute): URL { + const url = input instanceof URL ? new URL(input.href) : new URL(input, 'http://localhost'); + + for (const key of NODE_ROUTE_KEYS) { + url.searchParams.delete(key); + } + + if (route.page !== DEFAULT_PAGE) { + url.searchParams.set('page', route.page); + } + + return url; +} diff --git a/src/types.ts b/src/types.ts index 0a3af0a..08fc5d5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +export type AppPage = 'overview' | 'settings'; + export type BridgeState = { actions: string[]; isHomeBridge: boolean;