admin: moved hints on same row of the title, to maximize working area

Massimo Melina committed Oct 31, 2024 at 15:38 UTC 61d8798bf3f675d138cb34542437db4c29b07109
7 files changed +91 -54
admin/src/App.ts
+41 -11
@@ -1,12 +1,12 @@
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, useState } from 'react'
3 +import { createElement as h, Fragment, ReactNode, useCallback, useEffect, useState } from 'react'
4 import { HashRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom'
5 import MainMenu, { getMenuLabel, mainMenu } from './MainMenu'
6 import { AppBar, Box, Drawer, IconButton, ThemeProvider, Toolbar, Typography } from '@mui/material'
7 import { anyDialogOpen, Dialogs } from './dialog'
8 import { useMyTheme } from './theme'
9 -import { useBreakpoint} from './mui'
9 +import { Flex, useBreakpoint } from './mui'
10 import { LoginRequired } from './LoginRequired'
11 import { Menu } from '@mui/icons-material'
12 import { LocalizationProvider } from '@mui/x-date-pickers'
@@ -17,6 +17,8 @@ import { useEventListener } from 'usehooks-ts'
17 import { AriaOnly, isMac, xlate } from './misc'
18 import { getLocale } from './locale'
19
20 +export interface PageProps { setTitleSide: (content: ReactNode) => void }
21 +
22 function App() {
23 return h(ThemeProvider, { theme: useMyTheme() },
24 h(ApplyTheme, {},
@@ -42,13 +44,15 @@ function ApplyTheme(props:any) {
44 })
45 }
46
47 +let titleSideSet: any
48 +
49 function Routed() {
50 const loc = useLocation().pathname.slice(1)
51 const current = mainMenu.find(x => x.path === loc)
52 let { title } = useSnapState()
53 title = current && (current.title || getMenuLabel(current)) || title
54 const [open, setOpen] = useState(false)
51 - const large = useBreakpoint('lg')
55 + const sideMenu = useBreakpoint('lg')
56 const xs = current?.noPaddingOnMobile ? 0 : 1
57 const navigate = useNavigate()
58 useEventListener('keydown', ({ key, ctrlKey, altKey }) => {
@@ -60,16 +64,26 @@ function Routed() {
64 if (path === undefined) return
65 navigate(path || '/')
66 })
67 + const [titleSide, setTitleSide] = useState()
68 + titleSideSet = null
69 + const set = useCallback((x: any) => {
70 + titleSideSet = x
71 + setTimeout(() => setTitleSide(() => x))
72 + }, [])
73 + useEffect(() => {
74 + if (!titleSideSet)
75 + set(null)
76 + })
77 return h(Fragment, {},
78 h(AriaOnly, {}, h('h1', {}, "Admin-panel")),
65 - !large && h(StickyBar, { title, openMenu: () => setOpen(true) }),
66 - !large && h(Drawer, { anchor:'left', open, onClose(){ setOpen(false) } },
79 + !sideMenu && h(StickyBar, { title, titleSide, openMenu: () => setOpen(true) }),
80 + !sideMenu && h(Drawer, { anchor:'left', open, onClose(){ setOpen(false) } },
81 h(MainMenu, {
82 onSelect: () => setOpen(false),
83 itemTitle,
84 })),
85 h(Box, { display: 'flex', flex: 1, }, // horizontal layout for menu-content
72 - large && h(MainMenu, { itemTitle, onSelect(){} }),
86 + sideMenu && h(MainMenu, { itemTitle, onSelect(){} }),
87 h(Box, {
88 component: 'main',
89 sx: {
@@ -85,10 +99,15 @@ function Routed() {
99 overflowX: 'clip', // keep wide things in space
100 }
101 },
88 - title && large && h(Typography, { variant:'h2', mb:2 }, title),
102 + title && sideMenu && h(Flex, { gap: 4, '& .MuiAlert-root': { p: 0, backgroundColor: 'unset' } },
103 + h(Typography, { variant:'h2', mb:2, whiteSpace: 'nowrap' }, title),
104 + // @ts-ignore
105 + titleSide,
106 + ),
107 h(Routes, {},
108 mainMenu.map((it,idx) =>
91 - h(Route, { key: idx, path: it.path, element: h(it.comp) })),
109 + // @ts-ignore
110 + h(Route, { key: idx, path: it.path, element: h(it.comp, { setTitleSide: set }) })),
111 h(Route, { path: 'edit', element: h(ConfigFilePage) })
112 )
113 ),
@@ -100,8 +119,8 @@ function itemTitle(idx: number) {
119 return idx < 10 ? `${isMac ? 'CTRL' : 'ALT'} + ${(idx+1) % 10}` : ''
120 }
121
103 -function StickyBar({ title, openMenu }: { title?: string, openMenu: ()=>void }) {
104 - return h(AppBar, { position: 'sticky', sx: { mb: 2 } },
122 +function StickyBar({ title, titleSide, openMenu }: { titleSide?: ReactNode, title?: string, openMenu: ()=>void }) {
123 + return h(AppBar, { position: 'sticky', sx: { mb: 1 } },
124 h(Toolbar, {},
125 h(IconButton, {
126 size: 'large',
@@ -111,7 +130,18 @@ function StickyBar({ title, openMenu }: { title?: string, openMenu: ()=>void })
130 'aria-label': "menu",
131 onClick: openMenu
132 }, h(Menu)),
114 - h(Box, { component: 'h2', m: 0 }, title),
133 + h(Flex, {
134 + gap: 4,
135 + '& .MuiAlert-root': {
136 + p: 0,
137 + backgroundColor: 'unset',
138 + '& .MuiAlert-icon': { py: 0 },
139 + '& .MuiAlert-message': { py: '1px' }
140 + }
141 + },
142 + h(Box, { component: 'h2', m: 0, whiteSpace: 'nowrap' }, title),
143 + titleSide
144 + ),
145 )
146 )
147 }
admin/src/CustomHtmlPage.ts
+6 -5
@@ -11,13 +11,14 @@ import _ from 'lodash'
11 import { useDebounce } from 'usehooks-ts'
12 import { TextEditor } from './TextEditor';
13 import { state, useSnapState } from './state'
14 +import { PageProps } from './App'
15
16 const names: any = {
17 top: "Top of HTML Body",
18 bottom: "Bottom of HTML Body",
19 }
20
20 -export default function CustomHtmlPage() {
21 +export default function CustomHtmlPage({ setTitleSide }: PageProps) {
22 const { data, reload } = useApiEx<{ sections: Dict<string> }>('get_custom_html')
23 const { customHtmlSection: section } = useSnapState()
24 const [all, setAll] = useState<Dict<string>>({})
@@ -35,11 +36,11 @@ export default function CustomHtmlPage() {
36 }, [useDebounce(all, 500)])
37 const anyChange = useMemo(() => !_.isEqualWith(saved, all, (a,b) => !a && !b || undefined),
38 [saved, all])
39 + setTitleSide(useMemo(() => h(Alert, { severity: 'info', sx: { display: { xs: 'none', md: 'inherit' } } },
40 + md("Add HTML code to some parts of the Front-end. It's saved to file `custom.html`, that you can edit directly with your editor of choice. "),
41 + wikiLink('customization', "More help")
42 + ), []))
43 return h(Fragment, {},
39 - h(Alert, { severity: 'info' },
40 - md("Add HTML code to some parts of the Front-end. It's saved to file `custom.html`, that you can edit directly with your editor of choice. "),
41 - wikiLink('customization', "More help")
42 - ),
44 h(Box, { display: 'flex', alignItems: 'center', gap: 1, mb: 1 },
45 h(SelectField as Field<string>, {
46 label: "Section",
admin/src/InternetPage.ts
+5 -2
@@ -18,6 +18,7 @@ import { ConfigForm } from './ConfigForm'
18 import { DynamicDnsResult } from '../../src/ddns'
19 import { ArrayField } from './ArrayField'
20 import VfsPathField from './VfsPathField'
21 +import { PageProps } from './App'
22
23 const COUNTRIES = ALL.filter(x => WITH_IP.includes(x.code))
24
@@ -25,7 +26,7 @@ const PORT_FORWARD_URL = 'https://portforward.com/'
26 const HIGHER_PORT = 1080
27 const MSG_ISP = h('div', {}, "HFS will probably not be reachable on the Internet. ", wikiLink('Work-on-the-internet#double-nat', "Read more"))
28
28 -export default function InternetPage() {
29 +export default function InternetPage({ setTitleSide }: PageProps) {
30 const [checkResult, setCheckResult] = useState<boolean | undefined>()
31 const [checking, setChecking] = useState(false)
32 const [mapping, setMapping] = useState(false)
@@ -45,8 +46,10 @@ export default function InternetPage() {
46 if (verifyAgain.state) // skip first
47 void verify(true)
48 }, [verifyAgain.state])
49 + setTitleSide(useMemo(() =>
50 + h(Alert, { severity: 'info', sx: { display: { xs: 'none', sm: 'inherit' } } }, "This page makes sure your site is working correctly on the Internet"),
51 + []))
52 return h(Flex, { vert: true, gap: '2em', maxWidth: '40em' },
49 - h(Alert, { severity: 'info' }, "This page makes sure your site is working correctly on the Internet"),
53 networkBox(),
54 baseUrlBox(),
55 httpsBox(),
admin/src/LangPage.ts
+6 -4
@@ -6,17 +6,19 @@ import { DataTable } from './DataTable';
6 import { Alert, Box } from '@mui/material'
7 import { Delete, Upload } from '@mui/icons-material'
8 import { readFile, selectFiles } from './misc'
9 -import { Btn, IconBtn, useBreakpoint } from './mui'
9 +import { Btn, IconBtn } from './mui'
10 +import { PageProps } from './App'
11 import _ from 'lodash'
12 import { alertDialog, toast } from './dialog'
13 import { Field, SelectField } from '@hfs/mui-grid-form';
14
14 -export default function LangPage() {
15 +export default function LangPage({ setTitleSide }: PageProps) {
16 const { list, error, connecting, reload } = useApiList('get_langs')
17 const langs = useMemo(() => ['en', ..._.uniq(list.map(x => x.code))], [list])
17 - const large = useBreakpoint('md')
18 + setTitleSide(useMemo(() =>
19 + h(Alert, { severity: 'info', sx: { display: { xs: 'none', sm: 'inherit' } } }, "Translation is limited to Front-end, it doesn't apply to Admin-panel"),
20 + []))
21 return h(Fragment, {},
19 - large && h(Alert, { severity: 'info' }, "Translation is limited to Front-end, it doesn't apply to Admin-panel"),
22 h(Box, { mt: 1, maxWidth: '40em', flex: 1, display: 'flex', flexDirection: 'column' },
23 h(Box, { mb: 1, display: 'flex' },
24 h(Btn, { icon: Upload, onClick: add }, "Add"),
admin/src/MainMenu.ts
+2 -1
@@ -22,13 +22,14 @@ import CustomHtmlPage from './CustomHtmlPage';
22 import InternetPage from './InternetPage'
23 import { useWindowSize } from 'usehooks-ts'
24 import { hTooltip } from './mui'
25 +import { PageProps } from './App'
26
27 interface MenuEntry {
28 path: string
29 icon: SvgIconComponent
30 label?: string
31 title?: string
31 - comp: FC
32 + comp: FC<PageProps>
33 noPaddingOnMobile?: true
34 }
35
admin/src/VfsPage.ts
+28 -26
@@ -13,10 +13,11 @@ import { AlertProps } from '@mui/material/Alert/Alert'
13 import FileForm, { Account } from './FileForm'
14 import { Delete } from '@mui/icons-material'
15 import { alertDialog, confirmDialog } from './dialog'
16 +import { PageProps } from './App'
17
18 let selectOnReload: string[] | undefined
19
19 -export default function VfsPage() {
20 +export default function VfsPage({ setTitleSide }: PageProps) {
21 const [id2node] = useState(() => new Map<string, VfsNode>())
22 const { vfs, selectedFiles, movingFile } = useSnapState()
23 const { data, reload, element } = useApiEx('get_vfs')
@@ -30,7 +31,7 @@ export default function VfsPage() {
31 const ret = status?.urls.https || status?.urls.http
32 return b && !ret.includes(b) ? [b, ...ret] : ret
33 }, [status])
33 - const single = selectedFiles.length < 2 && (selectedFiles[0] as VfsNode || vfs)
34 + const single = selectedFiles?.length < 2 && (selectedFiles[0] as VfsNode || vfs)
35 const accountsApi = useApiEx<{ list: Account[] }>('get_accounts') // load accounts once and for all, or !isSideBreakpoint will cause a call for each selection
36
37 // this will take care of closing the dialog, for user's convenience, after "cut" button is pressed
@@ -40,6 +41,24 @@ export default function VfsPage() {
41 closeDialogRef.current()
42 }, [movingFile])
43
44 + const anythingShared = !data?.root?.children?.length && !data?.root?.source
45 + const alert: AlertProps | false = useMemo(() => anythingShared ? {
46 + severity: 'warning',
47 + children: "Add something to your shared files — click Add"
48 + } : urls && {
49 + severity: 'info',
50 + children: [
51 + "Your shared files can be browsed from ",
52 + reactJoin(" or ", urls.slice(0,3).map(href => h(Link, { href, target: 'frontend' }, href)))
53 + ]
54 + }, [anythingShared, urls])
55 +
56 +
57 + setTitleSide(useMemo(() => h(Box, { sx: { display: { xs: 'none', md: 'block' } } },
58 + h(Alert, { severity: 'info' }, "If you rename or delete here, it's virtual, and only affects what is presented to the users"),
59 + alert && h(Alert, alert),
60 + ), [alert]))
61 +
62 const sideContent = accountsApi.element || !vfs ? null
63 : single ? h(FileForm, {
64 addToBar: isSideBreakpoint && h(Box, { flex: 1, textAlign: 'right', mr: 1, color: '#8883' }, vfsNodeIcon(single)),
@@ -104,7 +123,7 @@ export default function VfsPage() {
123
124 function consumeSelectOnReload() {
125 if (selectOnReload)
107 - closeDialogRef.current() // noop when side-paneling
126 + closeDialogRef.current() // this is noop when side-paneling
127 const ret = selectOnReload && onlyTruthy(selectOnReload.map(id => id2node.get(id)))
128 selectOnReload = undefined
129 return ret
@@ -125,30 +144,13 @@ export default function VfsPage() {
144 id2node.clear()
145 return element
146 }
128 - const anythingShared = !data?.root?.children?.length && !data?.root?.source
129 - const alert: AlertProps | false = anythingShared ? {
130 - severity: 'warning',
131 - children: "Add something to your shared files — click Add"
132 - } : urls && {
133 - severity: 'info',
134 - children: [
135 - "Your shared files can be browsed from ",
136 - reactJoin(" or ", urls.slice(0,3).map(href => h(Link, { href, target: 'frontend' }, href)))
137 - ]
138 - }
147 const scrollProps = { height: '100%', display: 'flex', flexDirection: 'column', overflow: 'auto' } as const
140 - return h(Fragment, {},
141 - h(Box, { mb: 2 },
142 - h(Alert, { severity: 'info' }, "If you rename or delete here, it's virtual, and only affects what is presented to the users"),
143 - alert && h(Alert, alert),
144 - ),
145 - h(Grid, { container: true, rowSpacing: 1, columnSpacing: 2, top: 0, flex: '1 1 auto', height: 0 },
146 - h(Grid, { item: true, xs: 12, [sideBreakpoint]: 6, lg: 6, xl: 5, ...scrollProps },
147 - h(VfsTree, { id2node, statusApi }) ),
148 - isSideBreakpoint && sideContent && h(Grid, { item: true, [sideBreakpoint]: true, maxWidth: '100%', ...scrollProps },
149 - h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
150 - h(CardContent, {}, sideContent)) )
151 - )
148 + return h(Grid, { container: true, rowSpacing: 1, columnSpacing: 2, top: 0, flex: '1 1 auto', height: 0 },
149 + h(Grid, { item: true, xs: 12, [sideBreakpoint]: 6, lg: 6, xl: 5, ...scrollProps },
150 + h(VfsTree, { id2node, statusApi }) ),
151 + isSideBreakpoint && sideContent && h(Grid, { item: true, [sideBreakpoint]: true, maxWidth: '100%', ...scrollProps },
152 + h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
153 + h(CardContent, {}, sideContent)) )
154 )
155 }
156
admin/src/VfsTree.ts
+3 -5
@@ -88,7 +88,6 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
88 )
89 })()
90 ),
91 - key: name,
91 collapseIcon: h(ExpandMore, {
92 onClick(ev) {
93 setExpanded(was => was?.filter(x => x !== id) )
@@ -105,14 +104,14 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
104 }),
105 nodeId: id
106 }, isRoot && !node.children?.length ? h(TreeItem, { nodeId: '?', label: h('i', {}, "nothing here") })
108 - : node.children?.map(x => h(Branch, { node: x})) )
107 + : node.children?.map(x => h(Branch, { key: x.id, node: x })) )
108
109 function isRestricted(who: Who | undefined) {
110 return who !== undefined && who !== true
111 }
112 }, [setExpanded])
114 - const ref = useRef<HTMLUListElement>()
115 - const [expandAll, toggleBtn] = useToggleButton("Collapse all", "Expand all", exp => ({ icon: exp ? UnfoldLess : UnfoldMore, sx: {} }))
113 + const ref = useRef<HTMLUListElement>(null)
114 + const [expandAll, toggleBtn] = useToggleButton("Collapse all", "Expand all", exp => ({ icon: exp ? UnfoldLess : UnfoldMore, sx: { rotate: exp ? 0 : '180deg' } }))
115 useEffectOnce(() => setExpanded(expandAll ? Array.from(id2node.keys()) : ['/']), [expandAll])
116 // be sure selected element is visible
117 const treeId = 'vfs'
@@ -125,7 +124,6 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
124 h(VfsMenuBar, { statusApi, add: toggleBtn }),
125 ),
126 vfs && h(TreeView, {
128 - // @ts-ignore the type declared on the lib doesn't seem to be compatible with useRef()
127 ref,
128 expanded,
129 selected: selectedFiles.map(x => x.id),