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