| 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 { 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' |
| 9 | import { useMyTheme } from './theme' |
| 10 | import { Flex, useBreakpoint } from './mui' |
| 11 | import { LoginRequired } from './LoginRequired' |
| 12 | import { Menu } from '@mui/icons-material' |
| 13 | import { LocalizationProvider } from '@mui/x-date-pickers' |
| 14 | import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' |
| 15 | import ConfigFilePage from './ConfigFilePage' |
| 16 | import { useSnapState } from './state' |
| 17 | import { useEventListener } from 'usehooks-ts' |
| 18 | import { AriaOnly, isMac, useFixSticky, xlate } from './misc' |
| 19 | import { loadLocale } from './locale' |
| 20 | import { fillFlexParentSx } from './DataTable' |
| 21 | |
| 22 | // always use useMemo with setTitleSide |
| 23 | export interface PageProps { setTitleSide: (content: ReactNode, fullWidth?: boolean) => void } |
| 24 | |
| 25 | function App() { |
| 26 | return h(ThemeProvider, { theme: useMyTheme() }, |
| 27 | h(ApplyTheme, {}, |
| 28 | h(Localization, {}, |
| 29 | h(LoginRequired, {}, |
| 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 | } |
| 38 | }, h(Routed)) |
| 39 | }) )))) |
| 40 | } |
| 41 | |
| 42 | function Localization(props: any) { |
| 43 | const [locale, setLocale] = useState('') |
| 44 | useEffect(() => { |
| 45 | let cancelled = false |
| 46 | loadLocale().then(locale => { |
| 47 | if (!cancelled) |
| 48 | setLocale(locale || '') |
| 49 | }) |
| 50 | return () => { cancelled = true } |
| 51 | }, []) |
| 52 | return h(LocalizationProvider, { dateAdapter: AdapterDayjs, adapterLocale: locale, ...props }) |
| 53 | } |
| 54 | |
| 55 | function ApplyTheme(props:any) { |
| 56 | return h(Box, { |
| 57 | sx: { bgcolor: 'background.default', color: 'text.primary', flex: 1, |
| 58 | transition: 'background-color .4s', |
| 59 | maxWidth: '100%' /*avoid horizontal overflow (eg: customHtml with long line) */ |
| 60 | }, |
| 61 | ...props |
| 62 | }) |
| 63 | } |
| 64 | |
| 65 | let titleSideSet: any |
| 66 | |
| 67 | function Routed() { |
| 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 |
| 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 |
| 78 | const idx = Number(xlate(key, { 0: 10 })) // key 0 is after 9 and works as 10 |
| 79 | if (!idx) return |
| 80 | const path = mainMenu[idx - 1]?.path |
| 81 | if (path === undefined) return |
| 82 | navigate(path) |
| 83 | }) |
| 84 | const [titleSide, setTitleSide] = useState() |
| 85 | const [titleSideFullWidth, setTitleSideFullWidth] = useState(false) |
| 86 | titleSideSet = null |
| 87 | const set = useCallback((x: any, fullWidth=false) => { |
| 88 | titleSideSet = x |
| 89 | setTimeout(() => { |
| 90 | setTitleSide(() => x) |
| 91 | setTitleSideFullWidth(() => fullWidth) |
| 92 | }) |
| 93 | }, []) |
| 94 | useEffect(() => { |
| 95 | if (!titleSideSet) |
| 96 | set(null) |
| 97 | }) |
| 98 | return h(Fragment, {}, |
| 99 | h(AriaOnly, {}, h('h1', {}, "Admin-panel")), |
| 100 | !sideMenu && h(StickyBar, { |
| 101 | title, |
| 102 | titleSide, |
| 103 | openMenu: () => setOpen(true), |
| 104 | ...titleSideFullWidth as any && { props: { flex: 1 } } |
| 105 | }), |
| 106 | !sideMenu && h(Drawer, { anchor:'left', open, onClose(){ setOpen(false) } }, |
| 107 | h(MainMenu, { |
| 108 | onSelect: () => setOpen(false), |
| 109 | itemTitle, |
| 110 | })), |
| 111 | h(Box, { sx: { display: 'flex', flex: 1 } }, // horizontal layout for menu-content |
| 112 | sideMenu && h(MainMenu, { itemTitle, onSelect(){} }), |
| 113 | h(Box as any, { |
| 114 | component: 'main', |
| 115 | sx: { |
| 116 | background: 'url(cup.svg) no-repeat right fixed', |
| 117 | backgroundSize: 'contain', |
| 118 | px: { xs, md: 2, lg: 3 }, |
| 119 | pb: { xs, md: 2 }, |
| 120 | boxSizing: 'border-box', // keep padding inside the viewport |
| 121 | position: 'relative', |
| 122 | ...fillFlexParentSx, |
| 123 | width: '100%', |
| 124 | overflowX: 'clip', // keep wide things in space |
| 125 | } |
| 126 | }, |
| 127 | title && sideMenu && h(Flex, { gap: 4, '& .MuiAlert-root': { p: 0, backgroundColor: 'unset' } }, |
| 128 | h(Typography, { variant:'h2', sx: { mb: 2, whiteSpace: 'nowrap' } }, title), |
| 129 | // @ts-ignore |
| 130 | h(Flex, { ...titleSideFullWidth as any && { width: '100%' } }, titleSide), |
| 131 | ), |
| 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' }, h(ConfigFilePage)) |
| 140 | ] |
| 141 | }) |
| 142 | ), |
| 143 | ) |
| 144 | ) |
| 145 | } |
| 146 | |
| 147 | function itemTitle(idx: number) { |
| 148 | return idx < 10 ? `${isMac ? 'CTRL' : 'ALT'} + ${(idx+1) % 10}` : '' |
| 149 | } |
| 150 | |
| 151 | function StickyBar({ title, titleSide, openMenu, props }: { props?: BoxProps, titleSide?: ReactNode, title?: string, openMenu: ()=>void }) { |
| 152 | return h(AppBar, { ref: useFixSticky(), position: 'sticky', sx: { mb: 1 }, }, |
| 153 | h(Toolbar, {}, |
| 154 | h(IconButton, { |
| 155 | size: 'large', |
| 156 | edge: 'start', |
| 157 | color: 'inherit', |
| 158 | sx: { mr: 2 }, |
| 159 | 'aria-label': "menu", |
| 160 | onClick: openMenu |
| 161 | }, h(Menu)), |
| 162 | h(Flex, { |
| 163 | gap: 4, |
| 164 | props, |
| 165 | '& .MuiAlert-root': { |
| 166 | p: 0, |
| 167 | backgroundColor: 'unset', |
| 168 | '& .MuiAlert-icon': { py: 0 }, |
| 169 | '& .MuiAlert-message': { py: '1px' } |
| 170 | } |
| 171 | }, |
| 172 | h(Box as any, { component: 'h2', sx: { m: 0, whiteSpace: 'nowrap' } }, title), |
| 173 | titleSide |
| 174 | ), |
| 175 | ) |
| 176 | ) |
| 177 | } |
| 178 | |
| 179 | export default App |