removed legacy code: wouter wrapper for admin app
Massimo Melina committed
May 15, 2026 at 00:19 UTC
a4760d8229dc30cb8c07bfa0f6494946eb202e08
12 files changed
+75
-144
admin/src/App.ts
+19
-18
@@ -1,7 +1,8 @@
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, Route, Routes, useLocation, useNavigate } from './router'
4
+import { Route, Router, Switch, useLocation } from 'wouter'
5
+import { useHashLocation } from 'wouter/use-hash-location'
6
import MainMenu, { getMenuLabel, mainMenu, matchesMenuPath } from './MainMenu'
7
import { AppBar, Box, BoxProps, Drawer, IconButton, ThemeProvider, Toolbar, Typography } from '@mui/material'
8
import { anyDialogOpen, Dialogs } from './dialog'
@@ -26,14 +27,16 @@ function App() {
27
h(ApplyTheme, {},
28
h(Localization, {},
29
h(LoginRequired, {},
29
- h(HashRouter, {},
30
- h(Dialogs, {
30
+ h(Router, {
31
+ hook: useHashLocation,
32
+ children: h(Dialogs, {
33
style: {
34
display: 'flex', flexDirection: 'column',
35
minHeight: '100%', flex: 1,
36
maxWidth: '100%',
37
}
36
- }, h(Routed) ))) )))
38
+ }, h(Routed))
39
+ }) ))))
40
}
41
42
function Localization(props: any) {
@@ -62,14 +65,13 @@ function ApplyTheme(props:any) {
65
let titleSideSet: any
66
67
function Routed() {
65
- const loc = useLocation().pathname.slice(1)
66
- const current = mainMenu.find(x => matchesMenuPath(x, loc))
68
+ const [location, navigate] = useLocation()
69
+ const current = mainMenu.find(x => matchesMenuPath(x, location))
70
let { title } = useSnapState()
71
title = current && (current.title || getMenuLabel(current)) || title
72
const [open, setOpen] = useState(false)
73
const sideMenu = useBreakpoint('lg')
74
const xs = current?.noPaddingOnMobile ? 0 : 1
72
- const navigate = useNavigate()
75
useEventListener('keydown', ({ key, ctrlKey, altKey }) => {
76
if (anyDialogOpen()) return
77
if (!(isMac ? ctrlKey : altKey)) return // alt doesn't work on Mac, but it is the only suitable key on Windows
@@ -77,7 +79,7 @@ function Routed() {
79
if (!idx) return
80
const path = mainMenu[idx - 1]?.path
81
if (path === undefined) return
80
- navigate(path || '/')
82
+ navigate(path)
83
})
84
const [titleSide, setTitleSide] = useState()
85
const [titleSideFullWidth, setTitleSideFullWidth] = useState(false)
@@ -127,17 +129,16 @@ function Routed() {
129
// @ts-ignore
130
h(Flex, { ...titleSideFullWidth as any && { width: '100%' } }, titleSide),
131
),
130
- h(Routes, {},
131
- mainMenu.flatMap((it,idx) => [
132
- // @ts-ignore
133
- h(Route, { key: idx, path: it.path, element: h(it.comp, { setTitleSide: set }) }),
134
- it.subRoutes &&
135
- // tab pages encode their selected tab after the parent menu path
136
- // @ts-ignore
137
- h(Route, { key: it.path + '/:tab', path: it.path + '/:tab', element: h(it.comp, { setTitleSide: set }) })
132
+ h(Switch, {
133
+ children: [
134
+ ...mainMenu.flatMap((it,idx) => [
135
+ h(Route, { key: idx, path: it.path }, h(it.comp, { setTitleSide: set }) ),
136
+ // tab pages encode their selected tab after the parent menu path
137
+ it.subRoutes && h(Route, { key: it.path + '/:tab', path: `${it.path}/:tab` }, h(it.comp, { setTitleSide: set }) )
138
]),
139
- h(Route, { path: 'config', element: h(ConfigFilePage) })
140
- )
139
+ h(Route, { path: '/config' }, h(ConfigFilePage))
140
+ ]
141
+ })
142
),
143
)
144
)
admin/src/ConfigForm.ts
+1
-1
@@ -4,7 +4,7 @@ import { createElement as h, useEffect, useState, Dispatch } from 'react'
4
import _ from 'lodash'
5
import { IconBtn, propsForModifiedValues } from './mui'
6
import { RestartAlt } from '@mui/icons-material'
7
-import { Callback, onlyTruthy } from '../../src/cross'
7
+import { Callback, onlyTruthy } from './misc'
8
9
type FormRest<T> = Omit<FormProps<T>, 'values' | 'set' | 'save'> & Partial<Pick<FormProps<T>, 'save'>>
10
export function ConfigForm<T=any>({ keys, form, saveOnChange, onSave, ...rest }: Partial<FormRest<T>> & {
admin/src/HomePage.ts
+4
-4
@@ -72,7 +72,7 @@ export default function HomePage() {
72
vfs && !vfs.children?.length && !vfs.source ? entry('warning', "You have no shared files", SOLUTION_SEP, fsLink("add some")) : null,
73
account?.adminActualAccess ? entry('', "Welcome, "+username)
74
: entry('', md("You're accessing the Admin-panel without an account because you are on localhost"),
75
- ...status.anyAccountCanLoginAdmin ? [] : [SOLUTION_SEP, "to access from another computer, you must ", h(InLink, { to:'accounts' }, md("create an account with *admin* permission"))] ),
75
+ ...status.anyAccountCanLoginAdmin ? [] : [SOLUTION_SEP, "to access from another computer, you must ", h(InLink, { to:'/accounts' }, md("create an account with *admin* permission"))] ),
76
!href && entry('warning', "Frontend unreachable: ",
77
_.map(serverErrors, (v,k) => k + " " + (v ? "is in error" : "is off")).join(', '),
78
!errors.length && [ SOLUTION_SEP, cfgLink("switch http or https on") ]
@@ -81,7 +81,7 @@ export default function HomePage() {
81
with_(status.blacklistedInstalledPlugins, x => x?.length > 0
82
&& entry('warning', "Found blacklisted plugin(s): ", x.join(', ')) ),
83
with_(plugins?.filter(x => x.error || x.badApi).length, x => x > 0
84
- && entry('warning', `${x} plugin(s) failing`, SOLUTION_SEP, h(InLink, { to:'plugins' }, "check now"))),
84
+ && entry('warning', `${x} plugin(s) failing`, SOLUTION_SEP, h(InLink, { to:'/plugins' }, "check now"))),
85
!cfg.data?.split_uploads && (Date.now() - Number(status.cloudflareDetected || 0)) < DAY
86
&& entry('', wikiLink('Reverse-proxy#cloudflare', "Cloudflare detected, read our guide")),
87
with_(proxyWarning(cfg.data, status), x => x && entry('warning', x,
@@ -227,11 +227,11 @@ function entry(color: Color, ...content: ReactNode[]) {
227
}
228
229
function fsLink(text=`File System page`) {
230
- return h(InLink, { to:'fs' }, text)
230
+ return h(InLink, { to:'/fs' }, text)
231
}
232
233
function cfgLink(text=`Options page`) {
234
- return h(InLink, { to: 'options' }, text)
234
+ return h(InLink, { to: '/options' }, text)
235
}
236
237
export function proxyWarning(cfg: any, status: any) {
admin/src/LogsPage.ts
+1
-1
@@ -23,7 +23,7 @@ import { BoolField, SelectField } from '@hfs/mui-grid-form'
23
import { toast, useDialogBarColors } from './dialog'
24
import { BlockIpBtn } from './blockIp'
25
import { ALL as COUNTRIES } from './countries'
26
-import { useRoutedTab } from './router'
26
+import { useRoutedTab } from './routing'
27
28
const logLabels = {
29
log: "Served",
admin/src/MainMenu.ts
+17
-17
@@ -7,7 +7,7 @@ import {
7
SvgIconComponent
8
} from '@mui/icons-material'
9
import _ from 'lodash'
10
-import { Link, useLocation } from './router'
10
+import { Link, useLocation } from 'wouter'
11
import MonitorPage from './MonitorPage'
12
import OptionsPage from './OptionsPage';
13
import VfsPage from './VfsPage';
@@ -26,7 +26,7 @@ import { PageProps } from './App'
26
import { confirmDialog } from './dialog'
27
28
export interface MenuEntry {
29
- path: string
29
+ path: `/${string}`
30
icon: SvgIconComponent
31
label?: string
32
title?: string
@@ -36,24 +36,24 @@ export interface MenuEntry {
36
}
37
38
export const mainMenu: MenuEntry[] = [
39
- { path: '', icon: Home, label: "Home", comp: HomePage },
40
- { path: 'fs', icon: AccountTree, label: "Shared files", comp: VfsPage },
41
- { path: 'accounts', icon: ManageAccounts, comp: AccountsPage },
42
- { path: 'options', icon: Settings, comp: OptionsPage },
43
- { path: 'internet', icon: Public, comp: InternetPage },
44
- { path: 'monitoring', icon: Monitor, comp: MonitorPage, noPaddingOnMobile: true },
45
- { path: 'logs', icon: History, comp: LogsPage, noPaddingOnMobile: true, subRoutes: true },
46
- { path: 'language', icon: Translate, comp: LangPage },
47
- { path: 'plugins', icon: Extension, comp: PluginsPage, noPaddingOnMobile: true, subRoutes: true },
48
- { path: 'html', icon: Code, label: "Custom HTML", comp: CustomHtmlPage },
49
- { path: 'logout', icon: Logout, comp: LogoutPage }
39
+ { path: '/', icon: Home, label: "Home", comp: HomePage },
40
+ { path: '/fs', icon: AccountTree, label: "Shared files", comp: VfsPage },
41
+ { path: '/accounts', icon: ManageAccounts, comp: AccountsPage },
42
+ { path: '/options', icon: Settings, comp: OptionsPage },
43
+ { path: '/internet', icon: Public, comp: InternetPage },
44
+ { path: '/monitoring', icon: Monitor, comp: MonitorPage, noPaddingOnMobile: true },
45
+ { path: '/logs', icon: History, comp: LogsPage, noPaddingOnMobile: true, subRoutes: true },
46
+ { path: '/language', icon: Translate, comp: LangPage },
47
+ { path: '/plugins', icon: Extension, comp: PluginsPage, noPaddingOnMobile: true, subRoutes: true },
48
+ { path: '/html', icon: Code, label: "Custom HTML", comp: CustomHtmlPage },
49
+ { path: '/logout', icon: Logout, comp: LogoutPage }
50
]
51
52
export default function Menu({ onSelect, itemTitle }: { onSelect: ()=>void, itemTitle: (idx: number) => string }) {
53
const { VERSION } = getHFS()
54
const logo = 'hfs-logo.svg'
55
const short = useWindowSize().height < 700
56
- const currentPath = useLocation().pathname.slice(1)
56
+ const currentPath = useLocation()[0]
57
return h(Box, { sx: { display: 'flex', flexDirection: 'column', bgcolor: 'primary.main', minHeight: '100%' } },
58
h(List, {
59
sx:{
@@ -79,7 +79,7 @@ export default function Menu({ onSelect, itemTitle }: { onSelect: ()=>void, item
79
h(ListItemButton, {
80
// @ts-expect-error mui createElement overload does not infer custom Link props
81
component: Link,
82
- to: it.path,
82
+ href: it.path,
83
onClick: onSelect,
84
selected: matchesMenuPath(it, currentPath),
85
sx: { '&.Mui-selected': { '&,&:hover': { bgcolor: 'primary.dark', textDecoration: 'underline' } } },
@@ -96,9 +96,9 @@ export default function Menu({ onSelect, itemTitle }: { onSelect: ()=>void, item
96
}
97
98
export function getMenuLabel(it: MenuEntry) {
99
- return it && (it.label ?? _.capitalize(it.path))
99
+ return it && (it.label ?? _.capitalize(it.path.slice(1)))
100
}
101
102
export function matchesMenuPath(it: MenuEntry, path: string) {
103
- return it.path ? path === it.path || path.startsWith(it.path + '/') : !path
103
+ return path === it.path || path.startsWith(it.path + '/')
104
}
admin/src/OptionsPage.ts
+3
-3
@@ -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 './router'
7
+import { Link as RouterLink } from 'wouter'
8
import { CardMembership, EditNote, Refresh, Warning } from '@mui/icons-material'
9
import { adminApis } from '../../src/adminApis'
10
import {
@@ -105,7 +105,7 @@ export default function OptionsPage() {
105
}, "Reload"),
106
h(Button, { // @ts-ignore
107
component: RouterLink,
108
- to: "/config",
108
+ href: "/config",
109
startIcon: h(EditNote),
110
}, sm ? "Config file" : "File"),
111
],
@@ -439,7 +439,7 @@ export async function suggestMakingCert() {
439
onClose: resolve,
440
Content: () => h(Box, { sx: { p: 1, lineHeight: 1.5 } },
441
h(Box, {}, "HTTPS needs a certificate to work."),
442
- h(Box, {}, "We suggest you to ", h(InLink, { to: 'internet' }, "get a free but proper certificate"), '.'),
442
+ h(Box, {}, "We suggest you to ", h(InLink, { to: '/internet' }, "get a free but proper certificate"), '.'),
443
h(Box, {}, "If you don't have a domain ", h(LinkBtn, { onClick: makeCertAndSave }, "make a self-signed certificate"),
444
" but that ", wikiLink('HTTPS#certificate', " won't be perfect"), '.' ),
445
)
admin/src/PluginsPage.ts
+1
-1
@@ -4,7 +4,7 @@ import { createElement as h, Fragment } from 'react'
4
import { Tab, Tabs } from '@mui/material'
5
import InstalledPlugins from './InstalledPlugins'
6
import OnlinePlugins from './OnlinePlugins'
7
-import { useRoutedTab } from './router'
7
+import { useRoutedTab } from './routing'
8
9
const TABS = [
10
{ label: "Installed", path: 'installed', Pane: InstalledPlugins },
admin/src/mui.ts
+5
-5
@@ -17,7 +17,7 @@ import {
17
import { dontBotherWithKeys, restartAnimation, useBatch, useStateMounted } from '@hfs/shared'
18
import { mergeSx, Promisable, StringField } from '@hfs/mui-grid-form'
19
import { alertDialog, confirmDialog, toast } from './dialog'
20
-import { Link as RouterLink, LinkProps as RouterLinkProps, useNavigate } from './router'
20
+import { Link as RouterLink, useLocation } from 'wouter'
21
import { SvgIconProps } from '@mui/material/SvgIcon/SvgIcon'
22
import _ from 'lodash'
23
import { ALL as COUNTRIES } from './countries'
@@ -239,16 +239,16 @@ export function iconTooltip(icon: SvgIconComponent, tooltip: ReactNode, sx?: SxP
239
}
240
241
// link for internal navigation
242
-export function InLink({ ...props }: LinkProps & RouterLinkProps) {
242
+export function InLink({ to, ...props }: LinkProps & { to: `/${string}` }) {
243
// make links inside dialogs work correctly
244
- const nav = useNavigate()
244
+ const navigate = useLocation()[1]
245
props.onClickCapture = async ev => {
246
ev.preventDefault()
247
while (anyDialogOpen())
248
await closeDialog()?.closed
249
- nav(props.to)
249
+ navigate(to)
250
}
251
- return h(Link, { component: RouterLink, ...props })
251
+ return h(Link, { component: RouterLink, href: to, ...props })
252
}
253
254
export const Center = forwardRef(({ sx, ...props }: BoxProps, ref) =>
admin/src/pluginOptions.ts
+1
-1
@@ -1,7 +1,7 @@
1
import { apiCall } from '@hfs/shared/api'
2
import { createElement as h, Fragment, useMemo } from 'react'
3
import { Box, Link, Paper } from '@mui/material'
4
-import { callable, formatDate, formatTime, newObj } from '../../src/cross'
4
+import { callable, formatDate, formatTime, newObj } from './misc'
5
import { Btn, Flex, iconTooltip, NetmaskField } from './mui'
6
import { MilitaryTech, Clear } from '@mui/icons-material'
7
import { Html, md, replaceStringToReact, useAutoScroll } from '@hfs/shared'
admin/src/router.ts
deleted
-92
@@ -1,92 +0,0 @@
1
-import { Children, cloneElement, createElement as h, forwardRef, isValidElement, useEffect } from 'react'
2
-import type { AnchorHTMLAttributes, ComponentType, 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 RouteProps = {
11
- path?: string
12
- element?: ReactElement
13
- children?: ReactNode
14
- component?: ComponentType<any>
15
-}
16
-
17
-export function Routes({ children }: { children?: ReactNode }) {
18
- // In wouter, empty string paths are interpreted like missing paths and become wildcards in Switch matching.
19
- const normalizedChildren = Children.map(children, normalizeChildRoutePath)
20
- return h(Switch as unknown as ComponentType<any>, {}, normalizedChildren)
21
-}
22
-
23
-// We keep hash navigation because admin routes are currently deep-linked with # fragments.
24
-export function HashRouter({ children }: { children?: ReactNode }) {
25
- // We pass children in props to satisfy Router's strict typing in this project setup.
26
- return h(Router, { hook: useHashLocation, children })
27
-}
28
-
29
-export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link({ to, ...rest }, ref) {
30
- // MUI passes refs to custom link components; forwarding it keeps ButtonBase/Link behavior working.
31
- // MUI may inject an `href` prop; keep our normalized target authoritative for hash routing consistency.
32
- return h(WouterLink as unknown as ComponentType<any>, { ...rest, ref, href: normalizePath(to) })
33
-})
34
-
35
-export function useNavigate() {
36
- const [, setLocation] = useWouterLocation()
37
- return (to: string, options?: { replace?: boolean }) => {
38
- setLocation(normalizePath(to), options)
39
- }
40
-}
41
-
42
-export function useLocation(): { pathname: string } {
43
- const [pathname] = useWouterLocation()
44
- return { pathname }
45
-}
46
-
47
-export function Route({ path, element, children, component, ...rest }: RouteProps) {
48
- const routePath = path === '*' ? '/:rest*' : normalizePath(path)
49
- if (element)
50
- return h(WouterRoute, { path: routePath, ...rest }, element)
51
- if (component)
52
- return h(WouterRoute, { path: routePath, component, ...rest })
53
- return h(WouterRoute, { path: routePath, ...rest }, children)
54
-}
55
-
56
-function normalizePath(path: string | undefined) {
57
- if (!path || path === '#')
58
- return '/'
59
- return path.startsWith('/') ? path : `/${path}`
60
-}
61
-
62
-function normalizeChildRoutePath(child: ReactNode) {
63
- if (!isValidElement(child))
64
- return child
65
- if (!('path' in child.props))
66
- return child
67
- if (child.props.path !== '')
68
- return child
69
- return cloneElement(child, { path: '/' })
70
-}
71
-
72
-export function useRoutedTab(basePath: string, tabPaths: readonly string[]) {
73
- const { pathname } = useLocation()
74
- const navigate = useNavigate()
75
- const prefix = `/${basePath}/`
76
- const pathTab = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : ''
77
- const pathTabIndex = tabPaths.indexOf(pathTab)
78
- const tab = pathTabIndex < 0 ? 0 : pathTabIndex
79
-
80
- useEffect(() => {
81
- const wanted = `/${basePath}/${tabPaths[tab]}`
82
- // replace bare/unknown tab URLs so refresh and history stay aligned with the visible tab
83
- if (pathname !== wanted)
84
- navigate(wanted, { replace: true })
85
- }, [basePath, navigate, pathname, tab, tabPaths])
86
-
87
- return [tab, setTab] as const
88
-
89
- function setTab(i: number) {
90
- navigate(`/${basePath}/${tabPaths[i]}`)
91
- }
92
-}
admin/src/routing.ts
new
+22
@@ -0,0 +1,22 @@
1
+import { useEffect } from 'react'
2
+import { useLocation } from 'wouter'
3
+import { removeStarting } from './misc'
4
+
5
+export function useRoutedTab(basePath: string, tabPaths: readonly string[]) {
6
+ const [pathname, navigate] = useLocation()
7
+ const prefix = `/${basePath}/`
8
+ const tab = Math.max(0, tabPaths.indexOf(removeStarting(prefix, pathname)))
9
+
10
+ useEffect(() => {
11
+ const wanted = `/${basePath}/${tabPaths[tab]}`
12
+ // replace bare/unknown tab URLs so refresh and history stay aligned with the visible tab
13
+ if (pathname !== wanted)
14
+ navigate(wanted, { replace: true })
15
+ }, [basePath, navigate, pathname, tab, tabPaths])
16
+
17
+ return [tab, setTab] as const
18
+
19
+ function setTab(i: number) {
20
+ navigate(`/${basePath}/${tabPaths[i]}`)
21
+ }
22
+}
e2e/frontend.spec.ts
+1
-1
@@ -347,7 +347,7 @@ test('anew', async ({ page, browserName }) => {
347
await page.getByRole('button', { name: 'Admin-panel' }).click()
348
const adminPage = await page1Promise
349
await adminPage.getByRole('link', { name: 'add some' }).click()
350
- const addBtn = adminPage.getByRole('button').nth(1)
350
+ const addBtn = adminPage.getByRole('button', { name: 'Add item to virtual file system' })
351
await addBtn.click()
352
await adminPage.getByRole('menuitem', { name: 'from disk' }).click()
353
await expect(adminPage.getByText('data.kv')).toBeVisible()