main
ts 179 lines 7.06 KB
Raw
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