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
+}