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()