admin: switched to wouter, same as already done in the frontend

Massimo Melina committed Apr 14, 2026 at 15:01 UTC 55284c7c432e7765dbb6bcd512a4f51d92a305f0
6 files changed +107 -5
admin/package.json
+1 -1
@@ -24,7 +24,7 @@
24 "qr-creator": "^1.0.0",
25 "react": "^18.3.1",
26 "react-dom": "^18.2.0",
27 - "react-router-dom": "^6.23.1",
27 + "wouter": "^3.4.1",
28 "react-simple-code-editor": "^0.13.1",
29 "react-window": "^1.8.10",
30 "valtio": "^1.13.0",
admin/src/App.ts
+1 -1
@@ -1,7 +1,7 @@
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import { createElement as h, Fragment, ReactNode, useCallback, useEffect, useState } from 'react'
4 -import { HashRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom'
4 +import { HashRouter, Route, Routes, useLocation, useNavigate } from './router'
5 import MainMenu, { getMenuLabel, mainMenu } from './MainMenu'
6 import { AppBar, Box, BoxProps, Drawer, IconButton, ThemeProvider, Toolbar, Typography } from '@mui/material'
7 import { anyDialogOpen, Dialogs } from './dialog'
admin/src/MainMenu.ts
+1 -1
@@ -7,7 +7,7 @@ import {
7 SvgIconComponent
8 } from '@mui/icons-material'
9 import _ from 'lodash'
10 -import { NavLink } from 'react-router-dom'
10 +import { NavLink } from './router'
11 import MonitorPage from './MonitorPage'
12 import OptionsPage from './OptionsPage';
13 import VfsPage from './VfsPage';
admin/src/OptionsPage.ts
+1 -1
@@ -4,7 +4,7 @@ import { Box, Button, Divider, FormHelperText } from '@mui/material';
4 import { createElement as h, useEffect, useId, useRef, useState } from 'react'
5 import { apiCall, useApiEx } from './api'
6 import { state, useSnapState } from './state'
7 -import { Link as RouterLink } from 'react-router-dom'
7 +import { Link as RouterLink } from './router'
8 import { CardMembership, EditNote, Refresh, Warning } from '@mui/icons-material'
9 import { adminApis } from '../../src/adminApis'
10 import {
admin/src/mui.ts
+1 -1
@@ -15,7 +15,7 @@ import {
15 import { dontBotherWithKeys, restartAnimation, useBatch, useStateMounted } from '@hfs/shared'
16 import { Promisable, StringField } from '@hfs/mui-grid-form'
17 import { alertDialog, confirmDialog, toast } from './dialog'
18 -import { Link as RouterLink, LinkProps as RouterLinkProps, useNavigate } from 'react-router-dom'
18 +import { Link as RouterLink, LinkProps as RouterLinkProps, useNavigate } from './router'
19 import { SvgIconProps } from '@mui/material/SvgIcon/SvgIcon'
20 import _ from 'lodash'
21 import { ALL as COUNTRIES } from './countries'
admin/src/router.ts new
+102
@@ -0,0 +1,102 @@
1 +import { Children, cloneElement, createElement as h, forwardRef, Fragment, isValidElement } from 'react'
2 +import type { AnchorHTMLAttributes, ComponentType, CSSProperties, ReactElement, ReactNode } from 'react'
3 +import { Link as WouterLink, Route as WouterRoute, Router, Switch, useLocation as useWouterLocation } from 'wouter'
4 +import { useHashLocation } from 'wouter/use-hash-location'
5 +
6 +export type LinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> & {
7 + to: string
8 +}
9 +
10 +type NavLinkRenderProps = {
11 + isActive: boolean
12 +}
13 +
14 +type NavLinkProps = Omit<LinkProps, 'children' | 'className' | 'style'> & {
15 + children?: ReactNode | ((props: NavLinkRenderProps) => ReactNode)
16 + className?: string | ((props: NavLinkRenderProps) => string | undefined)
17 + end?: boolean
18 + style?: CSSProperties | ((props: NavLinkRenderProps) => CSSProperties | undefined)
19 +}
20 +
21 +type RouteProps = {
22 + path?: string
23 + element?: ReactElement
24 + children?: ReactNode
25 + component?: ComponentType<any>
26 +}
27 +
28 +export function Routes({ children }: { children?: ReactNode }) {
29 + // In wouter, empty string paths are interpreted like missing paths and become wildcards in Switch matching.
30 + const normalizedChildren = Children.map(children, normalizeChildRoutePath)
31 + return h(Switch as unknown as ComponentType<any>, {}, normalizedChildren)
32 +}
33 +
34 +// We keep hash navigation because admin routes are currently deep-linked with # fragments.
35 +export function HashRouter({ children }: { children?: ReactNode }) {
36 + // We pass children in props to satisfy Router's strict typing in this project setup.
37 + return h(Router, { hook: useHashLocation, children })
38 +}
39 +
40 +export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link({ to, ...rest }, ref) {
41 + // MUI passes refs to custom link components; forwarding it keeps ButtonBase/Link behavior working.
42 + // MUI may inject an `href` prop; keep our normalized target authoritative for hash routing consistency.
43 + return h(WouterLink as unknown as ComponentType<any>, { ...rest, ref, href: normalizePath(to) })
44 +})
45 +
46 +export const NavLink = forwardRef<HTMLAnchorElement, NavLinkProps>(function NavLink({ to, end, className, style, children, ...rest }, ref) {
47 + const [pathname] = useWouterLocation()
48 + const targetPath = normalizePath(to)
49 + const isActive = end
50 + ? pathname === targetPath
51 + : targetPath === '/'
52 + ? pathname === '/'
53 + : pathname === targetPath || pathname.startsWith(`${targetPath}/`)
54 + const activeProps = { isActive }
55 + return h(Link, {
56 + ref,
57 + to: targetPath,
58 + ...rest,
59 + className: typeof className === 'function' ? className(activeProps) : className,
60 + style: typeof style === 'function' ? style(activeProps) : style,
61 + children: typeof children === 'function' ? children(activeProps) : children,
62 + })
63 +})
64 +
65 +export function useNavigate() {
66 + const [, setLocation] = useWouterLocation()
67 + return (to: string, options?: { replace?: boolean }) => {
68 + setLocation(normalizePath(to), options)
69 + }
70 +}
71 +
72 +export function useLocation(): { pathname: string } {
73 + const [pathname] = useWouterLocation()
74 + return { pathname }
75 +}
76 +
77 +export function Route({ path, element, children, component, ...rest }: RouteProps) {
78 + const routePath = path === '*' ? '/:rest*' : normalizePath(path)
79 + if (element)
80 + return h(WouterRoute, { path: routePath, ...rest }, element)
81 + if (component)
82 + return h(WouterRoute, { path: routePath, component, ...rest })
83 + return h(WouterRoute, { path: routePath, ...rest }, children)
84 +}
85 +
86 +function normalizePath(path: string | undefined) {
87 + if (!path || path === '#')
88 + return '/'
89 + return path.startsWith('/') ? path : `/${path}`
90 +}
91 +
92 +export const BrowserRouter = Fragment
93 +
94 +function normalizeChildRoutePath(child: ReactNode) {
95 + if (!isValidElement(child))
96 + return child
97 + if (!('path' in child.props))
98 + return child
99 + if (child.props.path !== '')
100 + return child
101 + return cloneElement(child, { path: '/' })
102 +}