diff --git a/README.md b/README.md index 3d65731..4f277f5 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ minting-key removal for development; otherwise browser mode is read-only. Set ## QAVS and UI styles -The app is at QAVS `1.4.2`: the `1.4` portion is its minimum Qortium platform +The app is at QAVS `1.4.3`: the `1.4` portion is its minimum Qortium platform level and the patch number tracks the app release. `vite.config.ts` reads the package version, injects the visible version badge, and emits `dist/qortium-app.json` with the name `Minting` during every build. diff --git a/package-lock.json b/package-lock.json index 41fe8f6..75c2d6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qortium-minting", - "version": "1.4.2", + "version": "1.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qortium-minting", - "version": "1.4.2", + "version": "1.4.3", "license": "0BSD", "dependencies": { "@vitejs/plugin-react": "^6.0.3", diff --git a/package.json b/package.json index aabd84e..288a1e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qortium-minting", - "version": "1.4.2", + "version": "1.4.3", "private": true, "license": "0BSD", "description": "A small QDN minting status app for Qortium Home.", diff --git a/src/App.tsx b/src/App.tsx index f1b5534..5c6e90d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { startMinting, } from './coreApi'; import { interestingHeights } from './blockFilter'; +import { getMintingRouteUrl, readMintingRoute, type MintingTab } from './mintingRoute'; import { getBridgeState, hasAction, qdnRequest } from './qdnRequest'; import { applyDisplaySettings, @@ -62,7 +63,7 @@ import type { } from './types'; import type { SortState } from './minterSort'; -type Tab = 'status' | 'minters' | 'blocks' | 'reference'; +type Tab = MintingTab; type AsyncState = { error?: string; loading: boolean; value: T }; type Message = { text: string; tone: 'error' | 'info' | 'warning' } | null; type WriteResult = GroupActionResult | RemoveMintingAccountResult | StartMintingResult; @@ -127,7 +128,7 @@ function pendingIsActive(pending: PendingAction | null) { } export default function App() { - const [tab, setTab] = useState('status'); + const [tab, setTab] = useState(() => readMintingRoute(window.location.href).tab); const [bridge, setBridge] = useState>(initial(emptyBridge)); const [nodeStatus, setNodeStatus] = useState(null); const [height, setHeight] = useState(null); @@ -469,6 +470,25 @@ export default function App() { document.title = 'Minting'; }, [settings]); + useEffect(() => { + const applyRouteFromUrl = () => { + const route = readMintingRoute(window.location.href); + setSelectedMinter(null); + setTab(route.tab); + }; + + applyRouteFromUrl(); + + const current = new URL(window.location.href); + const canonical = getMintingRouteUrl(current, readMintingRoute(current)); + if (canonical.href !== current.href) { + window.history.replaceState({}, '', canonical); + } + + window.addEventListener('popstate', applyRouteFromUrl); + return () => window.removeEventListener('popstate', applyRouteFromUrl); + }, []); + useEffect(() => { const listener = (event: MessageEvent) => { setSettings((current) => getDisplaySettingsUpdateFromMessage(event.data, current) ?? current); @@ -740,9 +760,14 @@ export default function App() { } } + function navigateToTab(next: Tab) { + window.history.pushState({}, '', getMintingRouteUrl(window.location.href, { tab: next })); + setTab(next); + } + function openAccount(address: string) { setSelectedMinter(address); - setTab('minters'); + navigateToTab('minters'); if (!minters.value.length && !minters.loading) { void loadMinters(); @@ -795,7 +820,7 @@ export default function App() { key={key} onClick={() => { setSelectedMinter(null); - setTab(key); + navigateToTab(key); }} type="button" > @@ -822,7 +847,7 @@ export default function App() { canStart={canStart} details={details} identity={identity} - onBlocks={() => setTab('blocks')} + onBlocks={() => navigateToTab('blocks')} onJoin={() => { if (!account) return; void submitWrite( diff --git a/src/mintingRoute.test.ts b/src/mintingRoute.test.ts new file mode 100644 index 0000000..bbd5676 --- /dev/null +++ b/src/mintingRoute.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { getMintingRouteUrl, readMintingRoute, type MintingTab } from './mintingRoute'; + +describe('Minting routes', () => { + it('reads valid tab values', () => { + expect(readMintingRoute('https://example.test/app?tab=minters')).toEqual({ tab: 'minters' }); + expect(readMintingRoute('https://example.test/app?tab=blocks')).toEqual({ tab: 'blocks' }); + expect(readMintingRoute('https://example.test/app?tab=reference')).toEqual({ tab: 'reference' }); + expect(readMintingRoute('https://example.test/app?tab=status')).toEqual({ tab: 'status' }); + }); + + it('falls back to status for absent or invalid tabs', () => { + expect(readMintingRoute('https://example.test/app')).toEqual({ tab: 'status' }); + expect(readMintingRoute('https://example.test/app?tab=unknown')).toEqual({ tab: 'status' }); + expect(readMintingRoute('https://example.test/app?tab=')).toEqual({ tab: 'status' }); + }); + + it('replaces only the Minting-owned key while preserving Home settings and fragments', () => { + const url = getMintingRouteUrl( + 'https://example.test/render/APP/Minting/Minting?tab=blocks&qdnHomeBridge=1&theme=dark&lang=en&textSize=large&accent=blue&uiStyle=modern&future=value#detail', + { tab: 'minters' }, + ); + + expect(url.pathname).toBe('/render/APP/Minting/Minting'); + expect(url.searchParams.get('tab')).toBe('minters'); + 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 tab from the produced URL', () => { + const url = getMintingRouteUrl('https://example.test/app?tab=minters&theme=dark', { tab: 'status' }); + + expect(url.searchParams.has('tab')).toBe(false); + expect(url.searchParams.get('theme')).toBe('dark'); + }); + + it('round-trips every supported tab', () => { + const tabs: MintingTab[] = ['status', 'minters', 'blocks', 'reference']; + + for (const tab of tabs) { + expect(readMintingRoute(getMintingRouteUrl('https://example.test/app?theme=dark', { tab }))).toEqual({ tab }); + } + }); +}); diff --git a/src/mintingRoute.ts b/src/mintingRoute.ts new file mode 100644 index 0000000..741acc0 --- /dev/null +++ b/src/mintingRoute.ts @@ -0,0 +1,34 @@ +export type MintingTab = 'status' | 'minters' | 'blocks' | 'reference'; + +export interface MintingRoute { + tab: MintingTab; +} + +const MINTING_TABS: readonly MintingTab[] = ['status', 'minters', 'blocks', 'reference']; +const DEFAULT_TAB: MintingTab = 'status'; +const MINTING_ROUTE_KEYS = ['tab'] as const; + +function isMintingTab(value: string | null): value is MintingTab { + return value !== null && (MINTING_TABS as readonly string[]).includes(value); +} + +export function readMintingRoute(input: string | URL): MintingRoute { + const url = input instanceof URL ? input : new URL(input, 'http://localhost'); + const requestedTab = url.searchParams.get('tab'); + + return { tab: isMintingTab(requestedTab) ? requestedTab : DEFAULT_TAB }; +} + +export function getMintingRouteUrl(input: string | URL, route: MintingRoute): URL { + const url = input instanceof URL ? new URL(input.href) : new URL(input, 'http://localhost'); + + for (const key of MINTING_ROUTE_KEYS) { + url.searchParams.delete(key); + } + + if (route.tab !== DEFAULT_TAB) { + url.searchParams.set('tab', route.tab); + } + + return url; +}