mui 9
Massimo Melina committed
Apr 19, 2026 at 14:13 UTC
cc3d2425b084db3df18134653c6fb6f051b99d51
41 files changed
+345
-252
admin/package.json
+9
-9
@@ -13,23 +13,23 @@
13
"@gregoranders/csv": "^0.0.13",
14
"@hfs/mui-grid-form": "*",
15
"@hfs/shared": "*",
16
- "@mui/icons-material": "^7.3.10",
17
- "@mui/lab": "^7.0.1-beta.24",
18
- "@mui/material": "^7.3.9",
19
- "@mui/x-data-grid": "^8.27.5",
20
- "@mui/x-date-pickers": "^8.27.2",
21
- "@mui/x-tree-view": "^8.27.2",
16
+ "@mui/icons-material": "^9.0.0",
17
+ "@mui/lab": "^9.0.0-beta.2",
18
+ "@mui/material": "^9.0.0",
19
+ "@mui/x-data-grid": "^9.0.4",
20
+ "@mui/x-date-pickers": "^9.0.4",
21
+ "@mui/x-tree-view": "^9.0.4",
22
"dayjs": "^1.11.10",
23
+ "immer": "*",
24
"prismjs": "^1.29.0",
25
"qr-creator": "^1.0.0",
26
"react": "^18.3.1",
27
"react-dom": "^18.2.0",
27
- "wouter": "^3.4.1",
28
"react-simple-code-editor": "^0.13.1",
29
"react-window": "^1.8.10",
30
"valtio": "^1.13.0",
31
- "immer": "*",
32
- "usehooks-ts": "^2.9.5"
31
+ "usehooks-ts": "^2.9.5",
32
+ "wouter": "^3.4.1"
33
},
34
"devDependencies": {
35
"@types/prismjs": "^1.26.5",
admin/src/AccountForm.ts
+1
-1
@@ -69,7 +69,7 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
69
70
{ k: 'disabled', comp: BoolField, fromField: x=>!x, toField: x=>!x, label: "Enabled", xs: 12, sm: 6, lg: 4,
71
helperText: values.disabled || values.canLogin !== false ? "Login is prevented if account is disabled, or all its groups are disabled"
72
- : h(Box, { color: 'warning.main', component: 'span' } as any, // Box.component has ts problems with h()
72
+ : h(Box, { sx: { color: 'warning.main' }, component: 'span' } as any, // Box.component has ts problems with h()
73
new Date(account.expire!) < new Date() ? "Login is prevented because account is expired" // use account instead of values, so to use the value currently applied
74
: "Login is prevented because all of its groups are disabled")
75
},
admin/src/AccountsPage.ts
+10
-10
@@ -52,7 +52,7 @@ export default function AccountsPage() {
52
account: a,
53
groups: list.filter(x => x.isGroup).map(x => x.username),
54
addToBar: isSideBreakpoint && [
55
- h(Box, { flex:1 }),
55
+ h(Box, { sx: { flex: 1 } }),
56
account2icon(a, { fontSize: 'large', sx: { p: 1 }}),
57
// not really useful, but users misled in thinking it's a dialog will find satisfaction in dismissing the form
58
h(IconBtn, { icon: Close, title: "Close", onClick: selectNone }),
@@ -80,15 +80,15 @@ export default function AccountsPage() {
80
const scrollProps = { height: '100%', display: 'flex', flexDirection: 'column', overflow: 'auto' } as const
81
const [showTree, showTreeBtn] = useToggleButton("Show tree", "Show list", () => ({ icon: AccountTree }), accountsAsTree)
82
state.accountsAsTree = showTree
83
- return element || h(Grid, { container: true, rowSpacing: 1, columnSpacing: 2, top: 0, flex: '1 1 auto', height: 0 },
84
- h(Grid, { size: { xs: 12, [sideBreakpoint]: 5, lg: 4, xl: 5 } as any, ...scrollProps },
83
+ return element || h(Grid, { container: true, sx: { rowSpacing: 1, columnSpacing: 2, top: 0, flex: '1 1 auto', height: 0 } },
84
+ h(Grid, { size: { xs: 12, [sideBreakpoint]: 5, lg: 4, xl: 5 } as any, sx: scrollProps },
85
h(Box, {
86
- display: 'flex',
87
- flexWrap: 'wrap',
88
- gap: 2,
89
- mb: 2,
90
- boxShadow: theme => `0px -8px 4px 10px ${theme.palette.background.paper}`,
86
sx: {
87
+ display: 'flex',
88
+ flexWrap: 'wrap',
89
+ gap: 2,
90
+ mb: 2,
91
+ boxShadow: theme => `0px -8px 4px 10px ${theme.palette.background.paper}`,
92
position: 'sticky',
93
top: 0,
94
zIndex: 2,
@@ -107,7 +107,7 @@ export default function AccountsPage() {
107
}, "Add"),
108
reloadBtn(reload),
109
showTreeBtn,
110
- list?.length! > 0 && h(Typography, { p: 1 }, `${list!.length} account(s)`),
110
+ list?.length! > 0 && h(Typography, { sx: { p: 1 } }, `${list!.length} account(s)`),
111
),
112
!list?.length && h(Alert, { severity: 'info' }, md`To access administration <u>remotely</u> you will need to create a user account with admin permission`),
113
h(SimpleTreeView<true>, { // true because it's not detecting multiSelect correctly (ts495)
@@ -150,7 +150,7 @@ export default function AccountsPage() {
150
})(showTree ? list.filter(ac => !list.some(x => x.members?.includes(ac.username))) : list)
151
)
152
),
153
- isSideBreakpoint && sideContent && h(Grid, { size: 'grow', maxWidth: '100%', ...scrollProps },
153
+ isSideBreakpoint && sideContent && h(Grid, { size: 'grow', sx: { ...scrollProps, maxWidth: '100%' } },
154
h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
155
h(CardContent, {}, sideContent)) )
156
)
admin/src/App.ts
+3
-3
@@ -93,7 +93,7 @@ function Routed() {
93
onSelect: () => setOpen(false),
94
itemTitle,
95
})),
96
- h(Box, { display: 'flex', flex: 1, }, // horizontal layout for menu-content
96
+ h(Box, { sx: { display: 'flex', flex: 1 } }, // horizontal layout for menu-content
97
sideMenu && h(MainMenu, { itemTitle, onSelect(){} }),
98
h(Box as any, {
99
component: 'main',
@@ -110,7 +110,7 @@ function Routed() {
110
}
111
},
112
title && sideMenu && h(Flex, { gap: 4, '& .MuiAlert-root': { p: 0, backgroundColor: 'unset' } },
113
- h(Typography, { variant:'h2', mb:2, whiteSpace: 'nowrap' }, title),
113
+ h(Typography, { variant:'h2', sx: { mb: 2, whiteSpace: 'nowrap' } }, title),
114
// @ts-ignore
115
h(Flex, { ...titleSideFullWidth as any && { width: '100%' } }, titleSide),
116
),
@@ -150,7 +150,7 @@ function StickyBar({ title, titleSide, openMenu, props }: { props?: BoxProps, ti
150
'& .MuiAlert-message': { py: '1px' }
151
}
152
},
153
- h(Box as any, { component: 'h2', m: 0, whiteSpace: 'nowrap' }, title),
153
+ h(Box as any, { component: 'h2', sx: { m: 0, whiteSpace: 'nowrap' } }, title),
154
titleSide
155
),
156
)
admin/src/ArrayField.ts
+7
-5
@@ -6,7 +6,7 @@ import { GridActionsCellItem, GridAlignment, GridColDef } from '@mui/x-data-grid
6
import { BoolField, FieldDescriptor, FieldProps, labelFromKey } from '@hfs/mui-grid-form'
7
import { Box, FormHelperText, FormLabel } from '@mui/material'
8
import _ from 'lodash'
9
-import { Center, Flex, IconBtn, useBreakpoint } from './mui'
9
+import { Center, Flex, IconBtn, mergeSx, useBreakpoint } from './mui'
10
import { DataTable, DataTableColumn } from './DataTable'
11
import { DateTimeField } from './DateTimeField'
12
@@ -32,7 +32,7 @@ type ArrayFieldProps<T> = FieldProps<T[] | Dict<T>> & {
32
}
33
export function ArrayField<T extends object>({
34
label, helperText, fields, value, onChange, onError, setApi, reorder, prepend, noRows, valuesForAdd, autoRowHeight,
35
- dialog, form, details, objectK, saveOn, ...rest
35
+ dialog, form, details, objectK, saveOn, height, error, sx, ...rest
36
}: ArrayFieldProps<T>) {
37
const valueA = Array.isArray(value) ? value
38
: !objectK || !value ? [] // avoid crash if non-array values are passed, especially developing plugins
@@ -40,6 +40,7 @@ export function ArrayField<T extends object>({
40
const rows = useMemo(() => valueA!.map((x,$idx) =>
41
setHidden({ ...x } as any, x.hasOwnProperty('id') ? { $idx } : { id: $idx })),
42
[JSON.stringify(valueA)]) //eslint-disable-line
43
+ const fieldError = Boolean(error) || undefined
44
const getFormProp = (more: any) => (values: any) => ({
45
fields: callable(fields, values).map(({ $width, $column, $type, $hideUnder, showIf, $render, $mergeRender, ...rest }) =>
46
(!showIf || showIf(values)) && _.defaults(rest, byType[$type]?.field)),
@@ -50,10 +51,11 @@ export function ArrayField<T extends object>({
51
const [undo, setUndo] = useState<typeof valueA>()
52
return h(Fragment, {},
53
h(Flex, { rowGap: 0, flexWrap: 'wrap', ml: '2px' },
53
- label && h(FormLabel, { sx: { color: 'text.primary' } }, label),
54
- helperText && h(FormHelperText, {}, helperText),
54
+ label && h(FormLabel, { error: fieldError, sx: { color: fieldError ? undefined : 'text.primary' } }, label),
55
+ helperText && h(FormHelperText, { error: fieldError }, helperText),
56
),
56
- h(Box, { ...rest },
57
+ // field-level error is rendered through helperText, not forwarded to the DOM wrapper
58
+ h(Box, { ...rest, sx: mergeSx({ height }, sx) },
59
h(DataTable, {
60
rows,
61
details,
admin/src/ConfigFilePage.ts
+1
-1
@@ -39,7 +39,7 @@ export default function ConfigFilePage() {
39
setTimeout(() => focusSelector('main textarea'), 500)
40
}
41
}, "Edit"),
42
- h(Box, { flex: 1, minWidth: 'fit-content' }, h(DisplayField, { label: "File path", value: data?.fullPath, size: 'small' }))
42
+ h(Box, { sx: { flex: 1, minWidth: 'fit-content' } }, h(DisplayField, { label: "File path", value: data?.fullPath, size: 'small' }))
43
),
44
element || text !== undefined && // avoids bad undo behavior on start
45
h(Box, { sx: { '& pre,& textarea': { wordBreak: 'break-all !important' } } }, // fixes long lines not wrapping at the right point when the side menu is visible
admin/src/CustomHtmlPage.ts
+1
-1
@@ -51,7 +51,7 @@ export default function CustomHtmlPage({ setTitleSide }: PageProps) {
51
h(Alert, { severity: 'info' }, md("To customize icons "), wikiLink('customization#icons', "read documentation") ),
52
), []))
53
return h(Fragment, {},
54
- h(Box, { display: 'flex', alignItems: 'center', gap: 1, mb: 1 },
54
+ h(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1, mb: 1 } },
55
h(SelectField as Field<string>, {
56
label: "Section",
57
value: section,
admin/src/DataTable.ts
+39
-22
@@ -1,11 +1,12 @@
1
import { DataGrid, DataGridProps, getGridStringOperators, GridColDef, GridFooter, GridFooterContainer,
2
GridValidRowModel, useGridApiRef, GridRenderCellParams } from '@mui/x-data-grid'
3
import { enUS } from '@mui/x-data-grid/locales'
4
-import { Alert, Box, BoxProps, Breakpoint, LinearProgress, useTheme } from '@mui/material'
4
+import { Alert, Box, BoxProps, LinearProgress, useTheme } from '@mui/material'
5
+import type { Breakpoint } from '@mui/material/styles'
6
import { createElement as h, Fragment, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
7
import { callable, Callback, Falsy, newDialog, onlyTruthy, useGetSize } from '@hfs/shared'
8
import _ from 'lodash'
8
-import { Center, Flex } from './mui'
9
+import { Center, Flex, mergeSx } from './mui'
10
import { SxProps } from '@mui/system'
11
import { state, updateStateObject } from './state'
12
import { useDebounce } from 'usehooks-ts'
@@ -33,7 +34,10 @@ export interface DataTableProps<R extends GridValidRowModel=any> extends Omit<Da
34
persist?: string
35
details?: boolean
36
}
36
-export function DataTable({ columns, initialState={}, actions, actionsProps, initializing, noRows, error, compact, footerSide, fillFlex, persist, details, ...rest }: DataTableProps) {
37
+export function DataTable({
38
+ columns, initialState={}, actions, actionsProps, initializing, noRows, error, compact, footerSide, fillFlex,
39
+ persist, details, slots, slotProps, ...rest
40
+}: DataTableProps) {
41
const theme = useTheme()
42
const apiRef = useGridApiRef()
43
const [actionsLength, setActionsLength] = useState(0)
@@ -63,14 +67,15 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini
67
originalRenderCell: col.renderCell || true,
68
renderCell(params: GridRenderCellParams) {
69
const { columns } = params.api.store.getSnapshot()
66
- return h(Box, { maxHeight: '100%', ...col.cellInnerProps, sx: { textWrap: 'wrap', lineHeight: '1.2em', ...callable(sx as any, params) } }, // wrap if necessary, but stay within the row
70
+ return h(Box, { ...col.cellInnerProps, sx: { maxHeight: '100%', textWrap: 'wrap', lineHeight: '1.2em', ...callable(sx as any, params) } }, // wrap if necessary, but stay within the row
71
col.renderCell ? col.renderCell(params) : params.formattedValue,
72
col.mergeRender && h(Flex, { fontSize: 'smaller', flexWrap: 'wrap', mt: '1px', rowGap: 0, ...col.mergeRenderSx }, // wrap, normally causing overflow/hiding, if it doesn't fit
73
...onlyTruthy(_.map(col.mergeRender, (props, other) => {
74
if (!props || columns.columnVisibilityModel[other] !== false) return null
75
const rendered = renderCell({ ...columns.lookup[other], ...props.override }, params.row)
76
// keep mergeRender permissive for editor autocomplete, then narrow only at the render boundary
73
- return rendered && h(Box as any, { ...props, ...{ override: undefined }, ...compact && { lineHeight: '1em' } }, rendered)
77
+ const { override, sx, ...boxProps } = props
78
+ return rendered && h(Box as any, { ...boxProps, sx: mergeSx(sx, compact && { lineHeight: '1em' }) }, rendered)
79
}))
80
)
81
)
@@ -89,7 +94,7 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini
94
renderCell(params: any) {
95
const ret = actions({ ...params.row, ...params })
96
setTimeout(() => setActionsLength(ret.length)) // cannot update state during rendering
92
- return h(Box, { whiteSpace: 'nowrap' }, ...ret)
97
+ return h(Box, { sx: { whiteSpace: 'nowrap' } }, ...ret)
98
},
99
...actionsProps
100
})
@@ -129,7 +134,7 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini
134
135
return h(Fragment, {},
136
error && h(Alert, { severity: 'error' }, error),
132
- initializing && h(Box, { position: 'relative' },
137
+ initializing && h(Box, { sx: { position: 'relative' } },
138
h(LinearProgress, { // differently from "loading", this is not blocking user interaction
139
sx: { position: 'absolute', width: 'calc(100% - 2px)', borderRadius: 1, m: '1px 1px' }
140
}) ),
@@ -142,23 +147,27 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini
147
disableRowSelectionOnClick: true,
148
ref: sizeGrid.refToPass,
149
...rest,
145
- sx: {
150
+ sx: mergeSx({
151
...fillFlex && { height: 0, flex: 'auto' }, // limit table to available screen space, if parent is flex. Consider using fillFlexParentSx
152
'& .MuiDataGrid-virtualScroller': { minHeight: '3em' }, // without this, no-entries gets just 1px
153
'& .MuiTablePagination-root': { scrollbarWidth: 'none'},
149
- ...rest.sx,
150
- },
154
+ }, rest.sx),
155
slots: {
152
- noRowsOverlay: () => initializing ? null : h(Center, {}, noRows || "No entries"),
153
- footer: () => h(CustomFooter, { add: wrappedFooterSide }),
154
- },
156
+ footer: CustomFooter,
157
+ noRowsOverlay: NoRowsOverlay,
158
+ ...slots
159
+ } as any,
160
slotProps: {
161
+ ...slotProps,
162
+ footer: { ...(slotProps as any)?.footer, add: wrappedFooterSide },
163
+ noRowsOverlay: { ...(slotProps as any)?.noRowsOverlay, initializing, noRows },
164
pagination: {
165
labelRowsPerPage: "Rows",
166
...!causingScrolling && {
167
showFirstButton: true,
168
showLastButton: true,
161
- }
169
+ },
170
+ ...(slotProps as any)?.pagination,
171
},
172
},
173
onCellClick({ field, row }) {
@@ -181,13 +190,17 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini
190
const rowToShow = keepRow.current
191
displayingDetails.current = { id: rowToShow.id, setCurRow }
192
return h(Box, {
184
- display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(8em,1fr))', gap: '1em',
185
- gridAutoFlow: 'dense',
186
- minWidth: 'max(16em, 40vw)',
187
- sx: { opacity: curRow ? undefined : .5 },
193
+ sx: {
194
+ display: 'grid',
195
+ gridTemplateColumns: 'repeat(auto-fill, minmax(8em,1fr))',
196
+ gap: '1em',
197
+ gridAutoFlow: 'dense',
198
+ minWidth: 'max(16em, 40vw)',
199
+ opacity: curRow ? undefined : .5,
200
+ },
201
}, showInDialog.map(col =>
189
- h(Box, { key: col.field, gridColumn: col.flex! >= 1 ? '1/-1' : undefined },
190
- h(Box, { bgcolor: '#0003', p: 1 }, col.headerName || col.field),
202
+ h(Box, { key: col.field, sx: { gridColumn: col.flex! >= 1 ? '1/-1' : undefined } },
203
+ h(Box, { sx: { bgcolor: '#0003', p: 1 } }, col.headerName || col.field),
204
h(Flex, { minHeight: '2.5em', px: 1, wordBreak: 'break-word', flexWrap: 'wrap' },
205
renderCell(col, rowToShow) )
206
) ))
@@ -224,8 +237,12 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini
237
}
238
}
239
227
-function CustomFooter({ add, ...props }: { add: ReactNode }) {
228
- return h(GridFooterContainer, props, h(Box, { ml: { sm: 1 } }, add), h(GridFooter, { sx: { border: 'none' } }))
240
+function CustomFooter({ add, ...props }: { add?: ReactNode }) {
241
+ return h(GridFooterContainer, props, h(Box, { sx: { ml: { sm: 1 } } }, add), h(GridFooter, { sx: { border: 'none' } }))
242
+}
243
+
244
+function NoRowsOverlay({ initializing, noRows }: { initializing?: boolean, noRows?: ReactNode }) {
245
+ return initializing ? null : h(Center, {}, noRows || "No entries")
246
}
247
248
// required in case of fillFlex:true
admin/src/DateTimeField.ts
+2
-1
@@ -4,12 +4,13 @@ import { FieldProps } from '@hfs/mui-grid-form'
4
import { createElement as h } from 'react'
5
import { Box } from '@mui/material'
6
import { isTimestampString, objSameKeys } from './misc'
7
+import { mergeSx } from './mui'
8
9
export function DateTimeField({ onChange, error, helperText, ...rest }: FieldProps<Date>) {
10
return h(Box, {},
11
h(DateTimePicker, {
12
...objSameKeys(rest, x => isTimestampString(x) || x && x instanceof Date ? dayjs(x) : (x ?? null)), // null to not be considered uncontrolled
12
- sx: { width: '100%', color: 'error.main', ...rest.sx },
13
+ sx: mergeSx({ width: '100%', color: 'error.main' }, rest.sx),
14
onChange(v: any) {
15
onChange(v && new Date(v), { was: rest.value, event: undefined })
16
},
admin/src/FileField.ts
-1
@@ -14,7 +14,6 @@ export default function FileField({ value, onChange, files=true, folders=false,
14
...props,
15
value,
16
onChange,
17
- size: 'small',
17
wrap: true,
18
end: h(IconBtn, {
19
icon: Eject,
admin/src/FileForm.ts
+5
-5
@@ -69,7 +69,7 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved, i
69
const barColors = useDialogBarColors()
70
const { movingFile } = useSnapState()
71
72
- const needSourceWarning = !hasSource && h(Box as any, { color: 'warning.main', component: 'span' }, "Works only on folders with disk source! ")
72
+ const needSourceWarning = !hasSource && h(Box as any, { sx: { color: 'warning.main' }, component: 'span' }, "Works only on folders with disk source! ")
73
const show: Record<keyof VfsPerms, boolean> = {
74
can_read: !isLink,
75
can_see: true,
@@ -364,7 +364,7 @@ function LinkField({ value, statusApi }: LinkFieldProps) {
364
target: 'frontend',
365
}, link)
366
), [link])
367
- return h(Box, { display: 'flex' },
367
+ return h(Box, { sx: { display: 'flex' } },
368
!baseHost ? "Invalid baseUrl" : !urls ? 'error' : // check data is ok
369
h(DisplayField, {
370
label: "Link",
@@ -431,8 +431,8 @@ export async function changeBaseUrl() {
431
const proto = new URL(v || urls[0]).protocol + '//'
432
const host = urls.includes(v) ? '' : v.slice(proto.length)
433
const check = h(Check, { sx: { ml: 2 } })
434
- return h(Box, { display: 'flex', flexDirection: 'column' },
435
- h(Box, { mb: 2 }, "Choose a main address for your links"),
434
+ return h(Box, { sx: { display: 'flex', flexDirection: 'column' } },
435
+ h(Box, { sx: { mb: 2 } }, "Choose a main address for your links"),
436
h(MenuList, {},
437
h(MenuItem, {
438
selected: !v,
@@ -459,7 +459,7 @@ export async function changeBaseUrl() {
459
}),
460
sx: { mt: 2 }
461
}),
462
- h(Box, { mt: 2, textAlign: 'right' },
462
+ h(Box, { sx: { mt: 2, textAlign: 'right' } },
463
h(Btn, {
464
icon: Save,
465
children: "Save",
admin/src/FilePicker.ts
+5
-4
@@ -3,7 +3,7 @@
3
import { createElement as h, Fragment, useEffect, useMemo, useRef, useState } from 'react'
4
import { apiCall, useApi, useApiList } from './api'
5
import _ from 'lodash'
6
-import { Alert, Box, Checkbox, ListItemIcon, ListItemText, MenuItem, TextField, Typography } from '@mui/material'
6
+import { Alert, Box, Checkbox, ListItemButton, ListItemIcon, ListItemText, TextField, Typography } from '@mui/material'
7
import { enforceFinal, formatBytes, isWindowsDrive, err2msg, basename, formatPerc } from './misc'
8
import { spinner, Center, IconBtn, Flex, IconProgress, useBreakpoint, Btn } from './mui'
9
import { ArrowUpward, CreateNewFolder, Storage, VerticalAlignTop } from '@mui/icons-material'
@@ -108,14 +108,15 @@ export default function FilePicker({ onSelect, multiple=true, files=true, folder
108
},
109
sx: { flex: 1, display: 'flex', flexDirection: 'column' }
110
},
111
- !list.length ? h(Center as any, { sx: { flex: 1 }, mt: '4em' }, connecting ? spinner() : "No elements in this folder")
111
+ !list.length ? h(Center as any, { sx: { flex: 1, mt: '4em' } }, connecting ? spinner() : "No elements in this folder")
112
: h(FixedSizeList, {
113
width: '100%', height: listHeight,
114
itemSize: 46, itemCount: filteredList.length, overscanCount: 5,
115
children({ index, style }) {
116
const it = filteredList[index]
117
const isFolder = it.k === 'd'
118
- return h(MenuItem, {
118
+ // mui v9 requires MenuItem under MenuList, while these virtualized rows are plain list buttons
119
+ return h(ListItemButton, {
120
style: { ...style, padding: 0 },
121
key: it.n,
122
onClick() {
@@ -191,7 +192,7 @@ export function ListLsItem({ it }: { it: LsEntry }) {
192
!it.k && it.s !== undefined && h(Typography, {
193
variant: 'body2',
194
color: 'text.secondary',
194
- ml: 4, mr: 1,
195
+ sx: { ml: 4, mr: 1 },
196
}, formatBytes(it.s))
197
)
198
}
admin/src/HomePage.ts
+4
-5
@@ -65,7 +65,7 @@ export default function HomePage() {
65
const vfs = cfg.data?.vfs
66
return h(Box, {},
67
h(RandomPlugin),
68
- h(Box, { display:'flex', gap: 2, flexDirection:'column', alignItems: 'flex-start', height: '100%' },
68
+ h(Box, { sx: { display:'flex', gap: 2, flexDirection:'column', alignItems: 'flex-start', height: '100%' } },
69
dontBotherWithKeys(status.alerts?.map(x => entry('warning', md(x, { html: false }))) || []),
70
errors.length ? dontBotherWithKeys(errors.map(msg => entry('error', dontBotherWithKeys(msg))))
71
: entry('success', "Server is working"),
@@ -166,7 +166,7 @@ function Update({ info, title, bodyCollapsed, fromAuto }: { title?: ReactNode, i
166
return h(Flex, { alignItems: 'flex-start', flexWrap: 'wrap' },
167
h(Card, { className: 'release' }, h(CardContent, {},
168
h(Flex, {},
169
- title && h(Box, { fontSize: 'larger', mb: 1 }, title),
169
+ title && h(Box, { sx: { fontSize: 'larger', mb: 1 } }, title),
170
h(Btn, {
171
icon: UpdateIcon,
172
...!info.isNewer && info.prerelease && { color: 'warning', variant: 'outlined' },
@@ -175,7 +175,7 @@ function Update({ info, title, bodyCollapsed, fromAuto }: { title?: ReactNode, i
175
h(Link, { href: REPO_URL + 'releases/tag/' + info.tag_name, target: 'repo' }, h(OpenInNew)),
176
),
177
collapsed ? h(LinkBtn, { sx: { display: 'block', mt: 1 }, onClick(){ setCollapsed(false) } }, "See details")
178
- : h(Box, { mt: 1 }, renderChangelog(info.body))
178
+ : h(Box, { sx: { mt: 1 } }, renderChangelog(info.body))
179
)),
180
)
181
}
@@ -216,8 +216,7 @@ type Color = '' | 'success' | 'warning' | 'error'
216
217
function entry(color: Color, ...content: ReactNode[]) {
218
return h(Box, {
219
- fontSize: 'x-large',
220
- color: th => color && th.palette[color]?.main,
219
+ sx: { fontSize: 'x-large', color: th => color && th.palette[color]?.main },
220
},
221
h(({ success: CheckCircle, info: Info, '': Info, warning: Warning, error: Error })[color], {
222
sx: { mr: 1, color: color ? undefined : 'primary.main' }
admin/src/InstalledPlugins.ts
+4
-3
@@ -2,7 +2,8 @@
2
3
import { apiCall, useApiList } from './api'
4
import { createElement as h, Fragment, useEffect, useState } from 'react'
5
-import { Box, Breakpoint, Link, Table, TableCell, TableRow, useTheme } from '@mui/material'
5
+import { Box, Link, Table, TableCell, TableRow, useTheme } from '@mui/material'
6
+import type { Breakpoint } from '@mui/material/styles'
7
import { DataTable, DataTableColumn } from './DataTable'
8
import {
9
Delete, Error as ErrorIcon, FormatPaint as ThemeIcon, ListAlt, PlayCircle, Settings, StopCircle, Upgrade
@@ -53,14 +54,14 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
54
minWidth: 150,
55
renderCell: renderName,
56
valueGetter(_value: any, row: any) { return row.repo || row.id },
56
- mergeRender: { [updates ? 'changelog' : 'description']: { fontSize: 'x-small' } }
57
+ mergeRender: { [updates ? 'changelog' : 'description']: { sx: { fontSize: 'x-small' } } }
58
},
59
{
60
field: 'version',
61
width: 70,
62
hideUnder: 'sm',
63
cellInnerProps: { className: HIDE_IN_TESTS },
63
- mergeRender: { installedVersion: { fontSize: 'x-small' } }
64
+ mergeRender: { installedVersion: { sx: { fontSize: 'x-small' } } }
65
},
66
themeField,
67
{
admin/src/InternetPage.ts
+14
-14
@@ -96,8 +96,8 @@ export default function InternetPage({ setTitleSide }: PageProps) {
96
onClick: () => formDialog({
97
title: label + " wizard",
98
form: {
99
- maxWidth: '20em',
100
- before: h(Box, { mb: 1 }, "The following information is stored unencrypted"),
99
+ sx: { maxWidth: '20em' },
100
+ before: h(Box, { sx: { mb: 1 } }, "The following information is stored unencrypted"),
101
fields: fields.map(k => _.isString(k) ? { k } : k)
102
}
103
}).then(symbols => symbols && setValues({ [CFG.dynamic_dns_url]: replace(url, symbols as any, '$') }))
@@ -153,7 +153,7 @@ export default function InternetPage({ setTitleSide }: PageProps) {
153
]
154
] }),
155
addToBar: [
156
- h(Box, { flex: 1 }),
156
+ h(Box, { sx: { flex: 1 } }),
157
h(Btn, { icon: Search, onClick: lookup }, "Lookup IP")
158
],
159
})
@@ -191,7 +191,7 @@ export default function InternetPage({ setTitleSide }: PageProps) {
191
)),
192
h(Divider),
193
h(Form, {
194
- gap: 1,
194
+ sx: { gap: 1 },
195
gridProps: {rowSpacing:1},
196
values,
197
set(v, k) {
@@ -315,21 +315,21 @@ export default function InternetPage({ setTitleSide }: PageProps) {
315
const direct = publicIps.includes(data?.localIp!)
316
return h(Flex, { justifyContent: 'space-around' },
317
h(Device, { name: "Server", icon: direct ? Storage : HomeWorkTwoTone, color: localColor, ip: data?.localIp,
318
- below: port && h(Box, { fontSize: 'smaller', className: 'port ' + HIDE_IN_TESTS }, "port ", port),
318
+ below: port && h(Box, { sx: { fontSize: 'smaller' }, className: 'port ' + HIDE_IN_TESTS }, "port ", port),
319
}),
320
!direct && h(DataLine),
321
!direct && h(Device, {
322
name: "Router", icon: RouterTwoTone, ip: data?.gatewayIp,
323
color: data?.mapped && (wrongMap ? 'warning' : 'success'),
324
below: mapping ? h(LinearProgress, { sx: { height: '1em' } })
325
- : h(LinkBtn, { fontSize: 'smaller', display: 'block', onClick: configure },
325
+ : h(LinkBtn, { sx: { fontSize: 'smaller', display: 'block' }, onClick: configure },
326
"port ", wrongMap ? 'is wrong' : data?.externalPort || "unknown"),
327
}),
328
h(DataLine),
329
h(Device, { name: "Internet", icon: PublicTwoTone, ip: publicIps,
330
color: checkResult ? 'success' : checkResult === false ? 'error' : doubleNat ? 'warning' : undefined,
331
- below: checking ? h(LinearProgress, { sx: { height: '1em' } }) : h(Box, { fontSize: 'smaller', className: HIDE_IN_TESTS },
332
- doubleNat && h(LinkBtn, { display: 'block', onClick: () => alertDialog(MSG_ISP, 'warning') }, "Double NAT"),
331
+ below: checking ? h(LinearProgress, { sx: { height: '1em' } }) : h(Box, { sx: { fontSize: 'smaller' }, className: HIDE_IN_TESTS },
332
+ doubleNat && h(LinkBtn, { sx: { display: 'block', fontSize: 'smaller' }, onClick: () => alertDialog(MSG_ISP, 'warning') }, "Double NAT"),
333
checkResult ? "Working!" : checkResult === false ? "Failed!" : '',
334
' ',
335
(baseUrl > '' || publicIps.length > 0) && data.internalPort && h(LinkBtn, { onClick: () => verify() }, "Verify")
@@ -419,7 +419,7 @@ export default function InternetPage({ setTitleSide }: PageProps) {
419
return await confirmDialog(`There is a port-forwarding but it is pointing to the wrong port (${wrongMap})`, { trueText: "Fix it" })
420
&& fixPort()
421
if (!data.upnp)
422
- return alertDialog(h(Box, { lineHeight: 1.5 }, md(`We cannot help you configuring your router because UPnP is not available.\nFind more help [on this website](${PORT_FORWARD_URL}).`)), 'info')
422
+ return alertDialog(h(Box, { sx: { lineHeight: 1.5 } }, md(`We cannot help you configuring your router because UPnP is not available.\nFind more help [on this website](${PORT_FORWARD_URL}).`)), 'info')
423
const res = await promptDialog(md(`This will ask the router to map your port, so that it can be reached from the Internet.\nYou can set the same number of the local network (${port}), or a different one.`), {
424
value: data.externalPort || port,
425
field: { label: "Port seen from the Internet", comp: NumberField },
@@ -463,22 +463,22 @@ export default function InternetPage({ setTitleSide }: PageProps) {
463
}
464
465
function DataLine() {
466
- return h(Box, { flex: 1, className: 'animated-dashed-line' })
466
+ return h(Box, { sx: { flex: 1 }, className: 'animated-dashed-line' })
467
}
468
469
function Device({ name, icon, color, ip, below }: any) {
470
const fontSize = 'min(20vw, 10vh)'
471
- return h(Box, { display: 'inline-block', textAlign: 'center' },
471
+ return h(Box, { sx: { display: 'inline-block', textAlign: 'center' } },
472
h(icon, { color, sx: { fontSize, mb: '-0.1em' } }),
473
- h(Box, { fontSize: 'larger' }, name),
474
- h(Box, { fontSize: 'smaller', whiteSpace: 'pre-wrap', className: 'ip ' + HIDE_IN_TESTS }, wantArray(ip).join('\n') || "unknown"),
473
+ h(Box, { sx: { fontSize: 'larger' } }, name),
474
+ h(Box, { sx: { fontSize: 'smaller', whiteSpace: 'pre-wrap' }, className: 'ip ' + HIDE_IN_TESTS }, wantArray(ip).join('\n') || "unknown"),
475
below,
476
)
477
}
478
479
function TitleCard({ title, icon, color, children }: { title: ReactNode, icon?: SvgIconComponent, color?: SvgIconProps['color'], children?: ReactNode }) {
480
return h(Card, {}, h(CardContent, {}, h(Flex, { vert: true },
481
- h(Typography, { variant: 'h3', fontSize: 'x-large' }, icon && h(icon, { color, sx: { mr: 1, mb: '2px' } }), title),
481
+ h(Typography, { variant: 'h3', sx: { fontSize: 'x-large' } }, icon && h(icon, { color, sx: { mr: 1, mb: '2px' } }), title),
482
children
483
)))
484
}
admin/src/LangPage.ts
+4
-3
@@ -19,10 +19,10 @@ export default function LangPage({ setTitleSide }: PageProps) {
19
h(Alert, { severity: 'info', sx: { display: { xs: 'none', sm: 'inherit' } } }, "Translation is limited to the Front-end and doesn't apply to the Admin-panel"),
20
[]))
21
return h(Fragment, {},
22
- h(Box, { mt: 1, maxWidth: '50em', flex: 1, ...fillFlexParentSx },
23
- h(Box, { mb: 1, display: 'flex' },
22
+ h(Box, { sx: { mt: 1, maxWidth: '50em', flex: 1, ...fillFlexParentSx } },
23
+ h(Box, { sx: { mb: 1, display: 'flex' } },
24
h(Btn, { icon: Upload, onClick: add }, "Add"),
25
- h(Box, { flex: 1 }),
25
+ h(Box, { sx: { flex: 1 } }),
26
h(ForceLang, { langs }),
27
),
28
h(DataTable, {
@@ -92,6 +92,7 @@ function ForceLang({ langs }: { langs: string[] }) {
92
93
return h(SelectField as Field<string>, {
94
fullWidth: false,
95
+ size: 'small',
96
disabled: Boolean(loading) || typeof saving === 'string',
97
value: saving ?? lang,
98
async onChange(v) {
admin/src/LoginRequired.ts
+2
-3
@@ -14,7 +14,7 @@ export function LoginRequired({ children }: any) {
14
if (loginRequired === HTTP_FORBIDDEN)
15
return h(Center, {},
16
h(Alert, { severity: 'error' }, "Admin-panel only for localhost"),
17
- h(Box, { mt: 2, fontSize: 'small' }, "because no admin account was configured")
17
+ h(Box, { sx: { mt: 2, fontSize: 'small' } }, "because no admin account was configured")
18
)
19
if (loginRequired)
20
return h(LoginForm)
@@ -31,8 +31,7 @@ function LoginForm() {
31
h(Form, {
32
formRef,
33
values,
34
- m: 2,
35
- maxWidth: '25em',
34
+ sx: { m: 2, maxWidth: '25em' },
35
set(v, k) {
36
setValues(values => ({ ...values, [k]: v }))
37
},
admin/src/LogoutPage.ts
+1
-1
@@ -14,7 +14,7 @@ export default function LogoutPage() {
14
const { username } = useSnapState()
15
if (element)
16
return element
17
- return h(Box, { display: 'flex', flexDirection:'column', alignItems: 'flex-start', gap: 2 },
17
+ return h(Box, { sx: { display: 'flex', flexDirection:'column', alignItems: 'flex-start', gap: 2 } },
18
!username ? h(Alert, { severity: 'info' }, "You are not logged in, because authentication is not required on localhost")
19
: h(Fragment, {},
20
"You are logged in as: " + username,
admin/src/LogsPage.ts
+7
-7
@@ -43,7 +43,7 @@ export default function LogsPage({ setTitleSide }: PageProps) {
43
44
const logInfo = useApiEx('get_log_info')
45
setTitleSide(useMemo(() => fileAvailable && (logInfo.element || with_(logInfo.data, data =>
46
- h(Box, { fontSize: 'smaller' },
46
+ h(Box, { sx: { fontSize: 'smaller' } },
47
`Current: ${formatBytes(_.sum(Object.values(data.current)))}`,
48
h('br'),
49
with_(Object.values(data.rotated).flat(), rotatedAsArray =>
@@ -59,7 +59,7 @@ export default function LogsPage({ setTitleSide }: PageProps) {
59
key: f,
60
sx: { minWidth: 0, px: { xs: 1.5, sm: 2 } } // save space
61
}))),
62
- h(Box, { flex: 1 }),
62
+ h(Box, { sx: { flex: 1 } }),
63
h(IconBtn, {
64
icon: Download,
65
title: fileAvailable ? "Download as file" : "Not available",
@@ -95,7 +95,7 @@ export default function LogsPage({ setTitleSide }: PageProps) {
95
{ k: CFG.log_ua, sm: 6, comp: BoolField, label: "Log User-Agent", helperText: "Contains browser and possibly OS information. Can double the size of your logs on disk." },
96
{ k: CFG.log_spam, sm: 6, comp: BoolField, label: "Log spam requests", helperText: md`Spam requests are *failed* requests that you probably don't want to see` },
97
{ k: CFG.track_ips, sm: 6, comp: BoolField, label: "Keep track of IPs",
98
- parentProps: { display: 'flex', gap: 1 },
98
+ parentProps: { sx: { display: 'flex', gap: 1 } },
99
after: h(Btn, {
100
size: 'small', variant: 'outlined', color: 'warning',
101
confirm: true, doneMessage: true,
@@ -170,7 +170,7 @@ export function LogFile({ file, footerSide, hidden, limit, filter, ...rest }: Lo
170
minWidth: 130,
171
maxWidth: 230,
172
mergeRender: {
173
- user: { display: 'flex', justifyContent: 'space-between', gap: '.5em', },
173
+ user: { sx: { display: 'flex', justifyContent: 'space-between', gap: '.5em' } },
174
country: showCountry && {},
175
ua: {},
176
},
@@ -284,7 +284,7 @@ export function LogFile({ file, footerSide, hidden, limit, filter, ...rest }: Lo
284
width: 70,
285
hideUnder: 'xl',
286
renderCell: ({ value }) => hTooltip(prefix(value + ' - ', httpCodes[value]) || "Unknown", undefined,
287
- h(Box, { bgcolor: '#888a', color: '#fff', borderRadius: '.3em', p: '.05em .3em', lineHeight: '1.2em' }, value))
287
+ h(Box, { sx: { bgcolor: '#888a', color: '#fff', borderRadius: '.3em', p: '.05em .3em', lineHeight: '1.2em' } }, value))
288
},
289
{
290
field: 'length',
@@ -322,7 +322,7 @@ export function LogFile({ file, footerSide, hidden, limit, filter, ...rest }: Lo
322
if (_.isArray(ul))
323
return path + ul.join(' + ')
324
if (!path.startsWith(API_URL))
325
- return [path, query && h(Box as any, { key: 0, component: 'span', color: 'text.secondary', fontSize: 'smaller' }, '?', query)]
325
+ return [path, query && h(Box as any, { key: 0, component: 'span', sx: { color: 'text.secondary', fontSize: 'smaller' } }, '?', query)]
326
const name = path.slice(API_URL.length)
327
const params = query && ': ' + Array.from(new URLSearchParams(query)).map(x => `${x[0]}=${tryJson(x[1]) ?? x[1]}`).join(' ; ')
328
return "API " + name + params
@@ -396,7 +396,7 @@ export function agentIcons(agent: string | undefined) {
396
const short = shortenAgent(agent)
397
const browserIcon = h(AgentIcon, { k: short, altText: true, map: CLIENT_ICONS })
398
const os = _.findKey(OSS, re => re.test(agent))
399
- return h(Box, { fontSize: '110%' }, browserIcon, ' ', os && osIcon(os as any))
399
+ return h(Box, { sx: { fontSize: '110%' } }, browserIcon, ' ', os && osIcon(os as any))
400
}
401
402
const alreadyFailed: any = {}
admin/src/MainMenu.ts
+4
-6
@@ -52,7 +52,7 @@ export default function Menu({ onSelect, itemTitle }: { onSelect: ()=>void, item
52
const { VERSION } = getHFS()
53
const logo = 'hfs-logo.svg'
54
const short = useWindowSize().height < 700
55
- return h(Box, { display: 'flex', flexDirection: 'column', bgcolor: 'primary.main', minHeight: '100%', },
55
+ return h(Box, { sx: { display: 'flex', flexDirection: 'column', bgcolor: 'primary.main', minHeight: '100%' } },
56
h(List, {
57
sx:{
58
pr: 1, py: 0, color: 'primary.contrastText',
@@ -62,17 +62,15 @@ export default function Menu({ onSelect, itemTitle }: { onSelect: ()=>void, item
62
display: 'flex', flexDirection: 'column', '&>a': { flex: '0' },
63
}
64
},
65
- h(Box, { id: 'hfs-name', display: 'flex', px: 2, py: .5, gap: 2, alignItems: 'center' },
65
+ h(Box, { id: 'hfs-name', sx: { display: 'flex', px: 2, py: .5, gap: 2, alignItems: 'center' } },
66
h(Box, {
67
- color: 'primary.contrastText',
68
- fontSize: 'min(3rem, max(5vw, 4vh))',
69
- sx: { cursor: 'pointer' },
67
+ sx: { color: 'primary.contrastText', fontSize: 'min(3rem, max(5vw, 4vh))', cursor: 'pointer' },
68
async onClick() {
69
if (await confirmDialog("Open HFS website?"))
70
window.open(WEBSITE)
71
}
72
}, 'HFS'),
75
- h(Box, { fontSize: 'small', className: HIDE_IN_TESTS }, replaceStringToReact(VERSION||'', /-/, () => h('br'))),
73
+ h(Box, { sx: { fontSize: 'small' }, className: HIDE_IN_TESTS }, replaceStringToReact(VERSION||'', /-/, () => h('br'))),
74
short && h('img', { src: logo, style: { height: '2.5em' } }),
75
),
76
mainMenu.map((it, idx) => hTooltip( itemTitle(idx), getMenuLabel(it) + ' ' + itemTitle(idx),
admin/src/MenuButton.ts
+1
-1
@@ -26,7 +26,7 @@ export default function MenuButton({ items, ...rest }: BtnProps & { items: any[]
26
anchorEl,
27
open,
28
onClose,
29
- MenuListProps: { 'aria-labelledby': id },
29
+ slotProps: { list: { 'aria-labelledby': id } },
30
children: items.map((it,idx) =>
31
h(MenuItem, {
32
key: idx,
admin/src/MonitorPage.ts
+9
-11
@@ -42,7 +42,7 @@ function MoreInfo() {
42
const sm = useBreakpoint('sm')
43
const xl = useBreakpoint('xl')
44
const formatDuration = createDurationFormatter({ maxTokens: 2, skipZeroes: true })
45
- return element || h(Box, { display: 'flex', flexWrap: 'wrap', gap: { xs: .5, md: 1 }, mb: { xs: 1, sm: 2 } },
45
+ return element || h(Box, { sx: { display: 'flex', flexWrap: 'wrap', gap: { xs: .5, md: 1 }, mb: { xs: 1, sm: 2 } } },
46
(allInfo || md) && pair('started', {
47
label: "Uptime",
48
render: x => formatDuration(Date.now() - +new Date(x)),
@@ -141,7 +141,7 @@ function Connections() {
141
wantLog ? "Live log" : h(Box),
142
wantLogButton),
143
),
144
- h(Grid, { container: true, flex: 1, columnSpacing: 1 },
144
+ h(Grid, { container: true, sx: { flex: 1 }, columnSpacing: 1 },
145
h(Grid, { size: 12 - logSize, sx: fillFlexParentSx },
146
h(DataTable, {
147
persist: 'connections',
@@ -167,7 +167,7 @@ function Connections() {
167
maxWidth: 400,
168
renderCell: ({ row, value }) => ipForUrl(value) + ' :' + row.port,
169
mergeRender: {
170
- user: { display: 'flex', justifyContent: 'space-between', gap: '.5em', },
170
+ user: { sx: { display: 'flex', justifyContent: 'space-between', gap: '.5em' } },
171
agent: {},
172
country: {},
173
},
@@ -199,7 +199,7 @@ function Connections() {
199
if (!value || !row.op) return
200
const rowContentSx = { display: 'flex', alignItems: 'center', height: '100%', minWidth: 0, gap: 1 } as const
201
if (row.op === 'browsing')
202
- return h(Box, { sx: rowContentSx }, h(Box, {}, value, h(Box, { fontSize: 'x-small' }, "browsing")))
202
+ return h(Box, { sx: rowContentSx }, h(Box, {}, value, h(Box, { sx: { fontSize: 'x-small' } }, "browsing")))
203
// keep icon and filename on the same row: datagrid v7 wraps cell content differently than before
204
return h(Box, { sx: rowContentSx },
205
h(IconProgress, {
@@ -209,14 +209,12 @@ function Connections() {
209
title: md(formatPerc(row.opProgress) + (row.opTotal ? "\nTotal: " + formatBytes(row.opTotal) : '')),
210
}),
211
// clamp line-height locally so this cell doesn't inherit tall line metrics from datagrid wrappers
212
- h(Box, { lineHeight: '1.2em', minWidth: 0 }, row.archive ? h(Box, {}, value, h(Box, {
213
- fontSize: 'x-small',
214
- color: 'text.secondary'
212
+ h(Box, { sx: { lineHeight: '1.2em', minWidth: 0 } }, row.archive ? h(Box, {}, value, h(Box, {
213
+ sx: { fontSize: 'x-small', color: 'text.secondary' }
214
}, row.archive))
215
: with_(value?.lastIndexOf('/'), i => h(Box, {}, value.slice(i + 1),
216
i > 0 && h(Box, {
218
- fontSize: 'x-small',
219
- color: 'text.secondary'
217
+ sx: { fontSize: 'x-small', color: 'text.secondary' }
218
}, value.slice(0, i))
219
))),
220
)
@@ -229,7 +227,7 @@ function Connections() {
227
hideUnder: 'sm',
228
type: 'number',
229
renderCell: ({ value, row }) => formatSpeedK(Math.max(value || 0, row.inSpeedKb || 0) || undefined),
232
- mergeRender: { sent: { fontSize: 'small', textAlign: 'right' } }
230
+ mergeRender: { sent: { sx: { fontSize: 'small', textAlign: 'right' } } }
231
},
232
{
233
field: 'sent',
@@ -267,7 +265,7 @@ function Connections() {
265
]
266
}),
267
),
270
- logAble && wantLog && h(Grid, { size: logSize, ...fillFlexParentSx },
268
+ logAble && wantLog && h(Grid, { size: logSize, sx: fillFlexParentSx },
269
h(LogFile, {
270
file: `${CFG.log}|${CFG.error_log}`,
271
filter: monitorOnlyFiles ? (row => !row.uri.startsWith(SPECIAL_URI)) : undefined,
admin/src/OnlinePlugins.ts
+2
-2
@@ -44,7 +44,7 @@ export default function OnlinePlugins() {
44
headerName: "name",
45
flex: 1,
46
renderCell: renderName,
47
- mergeRender: { description: { fontSize: 'x-small' } },
47
+ mergeRender: { description: { sx: { fontSize: 'x-small' } } },
48
},
49
{
50
field: 'version',
@@ -114,7 +114,7 @@ export async function installPluginFromResult(row: any) {
114
h(Flex, { vert: true, alignItems: 'center' },
115
h(Warning, { color: 'warning', fontSize: 'large' }),
116
"Proceed only if you trust this plugin",
117
- h(Box, { fontSize: '60%' }, "A plugin has the same power of any other software"),
117
+ h(Box, { sx: { fontSize: '60%' } }, "A plugin has the same power of any other software"),
118
))) return
119
if (row.missing && !await confirmDialog("This will also install: " + _.map(row.missing, 'repo').join(', '))) return
120
const branch = row.branch || row.default_branch
admin/src/OptionsPage.ts
+7
-7
@@ -140,7 +140,7 @@ export default function OptionsPage() {
140
comp: SelectField,
141
sm: 4,
142
afterList: listenInterfaceOptions.some(x => x.disabled)
143
- && h(Box, { p: '8px 16px 0', borderTop: '1px solid', fontSize: 'small' }, "Disabled addresses depend on the address you used to connect"),
143
+ && h(Box, { sx: { p: '8px 16px 0', borderTop: '1px solid', fontSize: 'small' } }, "Disabled addresses depend on the address you used to connect"),
144
options: listenInterfaceOptions,
145
},
146
{ k: 'max_kbps', ...maxSpeedDefaults, sm: 4, label: "Limit output", helperText: "Doesn't apply to localhost" },
@@ -168,7 +168,7 @@ export default function OptionsPage() {
168
helperText: "In case another website is linking your files" },
169
170
{ k: 'block', label: false, comp: ArrayField, xs: 12, prepend: true, sm: true, autoRowHeight: true,
171
- form: { maxWidth: '30em' },
171
+ form: { sx: { maxWidth: '40em' } },
172
fields: [
173
{ k: 'ip', label: "Blocked IP", sm: 12, required: true, wrap: true, $width: 2, comp: NetmaskField,
174
$column: { mergeRender: { comment: {}, expire: {} } },
@@ -321,7 +321,7 @@ export default function OptionsPage() {
321
322
function Section({ title, subtitle }: { title: string, subtitle?: string }) {
323
return h(Divider, { role: 'heading', sx: { fontSize: 'larger', fontWeight: 'bold' } }, title,
324
- h(Box, { fontSize: 'small', fontWeight: 'normal' }, subtitle))
324
+ h(Box, { sx: { fontSize: 'small', fontWeight: 'normal' } }, subtitle))
325
}
326
327
function recalculateChanges() {
@@ -353,7 +353,7 @@ function PortField({ label, value, onChange, setApi, status, suggestedPort=1, er
353
else
354
error = true
355
return h(Box, {},
356
- h(Box, { display: 'flex' },
356
+ h(Box, { sx: { display: 'flex' } },
357
h(SelectField as Field<number>, {
358
sx: { flexGrow: 1 },
359
label,
@@ -388,7 +388,7 @@ function PortField({ label, value, onChange, setApi, status, suggestedPort=1, er
388
function AllowedReferer({ label, value, onChange, error }: FieldProps<string>) {
389
const yesNo = !value || value==='-'
390
const example = 'example.com'
391
- return h(Box, { display: 'flex' },
391
+ return h(Box, { sx: { display: 'flex' } },
392
h(SelectField as Field<string>, {
393
label,
394
value: yesNo ? value : example,
@@ -414,7 +414,7 @@ function WebdavAgentAuthField({ label, value, onChange, error, helperText, fallb
414
useEffect(() => setLastRegex(isRE ? value : fallbackRE), [value])
415
const helperId = useId()
416
return h(Box, {},
417
- h(Box, { display: 'flex' },
417
+ h(Box, { sx: { display: 'flex' } },
418
h(SelectField as Field<boolean | string>, {
419
label, value, onChange, error,
420
'aria-describedby': helperId,
@@ -433,7 +433,7 @@ export async function suggestMakingCert() {
433
icon: CardMembership,
434
title: "Get a certificate",
435
onClose: resolve,
436
- Content: () => h(Box, { p: 1, lineHeight: 1.5, },
436
+ Content: () => h(Box, { sx: { p: 1, lineHeight: 1.5 } },
437
h(Box, {}, "HTTPS needs a certificate to work."),
438
h(Box, {}, "We suggest you to ", h(InLink, { to: 'internet' }, "get a free but proper certificate"), '.'),
439
h(Box, {}, "If you don't have a domain ", h(LinkBtn, { onClick: makeCertAndSave }, "make a self-signed certificate"),
admin/src/RandomPlugin.ts
+3
-3
@@ -33,8 +33,8 @@ export function RandomPlugin() {
33
if (hideRandomPlugin || !one) return
34
return h(Card, { sx: { display: { xs: 'none', md: 'block' }, float: 'right', width: 'min(50%, 30em)', ml: '1em' } },
35
h(CardContent, {},
36
- h(Box, { fontWeight: 'bold', fontSize: '1.4em' }, h(Box, { color: 'warning.main', mr: 1, display: 'inline' }, '🎲'), "Random plugin:"),
37
- h(Box, { fontWeight: 'bold', fontSize: '1.8em', my: 1 }, renderName({ row: one })),
36
+ h(Box, { sx: { fontWeight: 'bold', fontSize: '1.4em' } }, h(Box, { sx: { color: 'warning.main', mr: 1, display: 'inline' } }, '🎲'), "Random plugin:"),
37
+ h(Box, { sx: { fontWeight: 'bold', fontSize: '1.8em', my: 1 } }, renderName({ row: one })),
38
h(Box, {}, one.description),
39
one.preview && h('img', {
40
src: wantArray(one.preview)[0],
@@ -53,4 +53,4 @@ export function RandomPlugin() {
53
h(Btn, { variant: 'outlined', onClick() { state.hideRandomPlugin = true } }, "Hide this box"),
54
)
55
)
56
-}
\ No newline at end of file
56
+}
admin/src/TextEditor.ts
+6
-3
@@ -45,8 +45,11 @@ export function TextEditorField({ onChange, value, onBlur, setApi, lang, ...prop
45
multiline: true,
46
fullWidth: true,
47
value: state,
48
- InputProps: { inputComponent: TextEditorAsInput },
49
- inputProps: { lang },
48
+ slotProps: {
49
+ ...props.slotProps,
50
+ input: { ...props.slotProps?.input, inputComponent: TextEditorAsInput },
51
+ htmlInput: { ...props.slotProps?.htmlInput, lang },
52
+ },
53
onChange(event) { setState(event.target.value) },
54
onBlur(event) {
55
onBlur?.(event)
@@ -82,4 +85,4 @@ const TextEditorAsInput = forwardRef<HTMLInputElement, any>(({ onChange, ...rest
85
ev.preventDefault()
86
},
87
})
85
- ))
\ No newline at end of file
88
+ ))
admin/src/VfsMenuBar.ts
+1
-1
@@ -87,7 +87,7 @@ function SystemIntegrationButton({ platform }: { platform: string | undefined })
87
...(!integrated?.is ? {
88
children: "System integration",
89
async onClick() {
90
- const msg = h(Box, { width: { xs: '100%', sm: '34em' } },
90
+ const msg = h(Box, { sx: { width: { xs: '100%', sm: '34em' } } },
91
h('img', { src: 'win-shell.png', style: { display: 'block', width: '100%' } }),
92
h(Alert, { severity: 'info' }, "We are going to add a command in the right-click of Windows File Manager.",
93
h(Box, {}, "It will also automatically copy the URL, ready to paste!")),
admin/src/VfsPage.ts
+11
-6
@@ -83,7 +83,7 @@ export default function VfsPage({ setTitleSide }: PageProps) {
83
const single = selectedFiles?.length < 2 && selectedFiles[0] as VfsNodeAdmin
84
const sideContent = useMemo(() => accountsApi.element || !vfs ? null
85
: diskContent.enabled ? diskContent.element || h(Box, {},
86
- h(Box, { fontSize: 'xx-large', sx: { wordBreak: 'break-all' } }, "From ", vfsShowDiskContentFor),
86
+ h(Box, { sx: { fontSize: 'xx-large', wordBreak: 'break-all' } }, "From ", vfsShowDiskContentFor),
87
h(List, { dense: true },
88
diskContent.list.map(it =>
89
h(ListItem, { key: it.n, sx: { borderTop: '1px solid #8888' } }, h(ListLsItem, { it })))
@@ -92,7 +92,7 @@ export default function VfsPage({ setTitleSide }: PageProps) {
92
: single ? h(FileForm, {
93
key: single.id,
94
isSideBreakpoint,
95
- addToBar: isSideBreakpoint && h(Box, { flex: 1, textAlign: 'right', mr: 1, color: '#8883' }, vfsNodeIcon(single)),
95
+ addToBar: isSideBreakpoint && h(Box, { sx: { flex: 1, textAlign: 'right', mr: 1, color: '#8883' } }, vfsNodeIcon(single)),
96
statusApi,
97
saved: () => closeDialogRef.current(),
98
accounts: accounts ?? [],
@@ -128,7 +128,7 @@ export default function VfsPage({ setTitleSide }: PageProps) {
128
vfsNodeIcon(selectedFiles[0] as VfsNodeAdmin),
129
h(Flex, { flexWrap: 'wrap', gap: '0 0.5em' },
130
selectedFiles[0].name || "Home",
131
- h(Box, { component: 'span', color: 'text.secondary' } as any, ancestors.join(' /'))
131
+ h(Box, { component: 'span', sx: { color: 'text.secondary' } } as any, ancestors.join(' /'))
132
)
133
),
134
dialogProps: { sx: { justifyContent: 'flex-end' } },
@@ -167,10 +167,15 @@ export default function VfsPage({ setTitleSide }: PageProps) {
167
return element
168
}
169
const scrollProps = { height: '100%', display: 'flex', flexDirection: 'column', overflow: 'auto' } as const
170
- return h(Grid, { container: true, rowSpacing: 1, columnSpacing: 2, top: 0, flex: '1 1 auto', height: 0 },
171
- h(Grid, { size: { xs: 12, [sideBreakpoint]: 5, lg: 6, xl: 5 } as any, ...scrollProps },
170
+ return h(Grid, {
171
+ container: true,
172
+ rowSpacing: 1,
173
+ columnSpacing: 2,
174
+ sx: { top: 0, flex: '1 1 auto', height: 0 },
175
+ },
176
+ h(Grid, { size: { xs: 12, [sideBreakpoint]: 5, lg: 6, xl: 5 } as any, sx: scrollProps },
177
h(VfsTree, { statusApi }) ),
173
- isSideBreakpoint && sideContent && h(Grid, { size: 'grow', maxWidth: '100%', ...scrollProps },
178
+ isSideBreakpoint && sideContent && h(Grid, { size: 'grow', sx: { ...scrollProps, maxWidth: '100%' } },
179
h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
180
h(CardContent, {}, sideContent)) )
181
)
admin/src/VfsPathField.ts
+29
-9
@@ -1,7 +1,7 @@
1
import { dirname, enforceFinal, join } from './misc'
2
import _ from 'lodash'
3
import { useApiList } from './api'
4
-import { createElement as h, useMemo } from 'react'
4
+import { ChangeEvent, createElement as h, useMemo } from 'react'
5
import { Autocomplete, AutocompleteProps, TextField } from '@mui/material'
6
import { FieldProps } from '@hfs/mui-grid-form'
7
@@ -9,7 +9,10 @@ interface VfsPathFieldProps extends FieldProps<string> {
9
autocompleteProps: Partial<AutocompleteProps<string, false, true, undefined>>
10
}
11
12
-export default function VfsPathField({ value='', onChange, helperText, setApi, autocompleteProps, folders=true, files=true, ...props }: VfsPathFieldProps) {
12
+export default function VfsPathField({
13
+ value='', onChange, helperText, setApi, autocompleteProps, folders=true, files=true,
14
+ InputLabelProps, slotProps, ...props
15
+}: VfsPathFieldProps) {
16
const uri = dirname(value.replace(/\/{2,}/g, '/'))
17
const { list, loading } = useApiList('get_file_list', {
18
uri,
@@ -37,11 +40,6 @@ export default function VfsPathField({ value='', onChange, helperText, setApi, a
40
disableCloseOnSelect: true,
41
renderInput: params => h(TextField, {
42
helperText,
40
- onChange(event) {
41
- const v = event.target.value
42
- if (files || !v || v.endsWith('/'))
43
- onChange(v, { was: value, event })
44
- },
43
onBlur(event) {
44
// if the user specified a folder without the final slash, try to enforce it
45
const v = enforceFinal('/', event.target.value)
@@ -50,9 +48,31 @@ export default function VfsPathField({ value='', onChange, helperText, setApi, a
48
},
49
...params,
50
...props,
53
- InputLabelProps: { shrink: true, ...params.InputLabelProps, ...props.InputLabelProps },
51
+ slotProps: {
52
+ ...slotProps,
53
+ input: {
54
+ ...slotProps?.input,
55
+ ...params.slotProps.input,
56
+ },
57
+ inputLabel: {
58
+ shrink: true,
59
+ ...params.slotProps.inputLabel,
60
+ ...InputLabelProps,
61
+ ...slotProps?.inputLabel,
62
+ },
63
+ htmlInput: {
64
+ ...slotProps?.htmlInput,
65
+ ...params.slotProps.htmlInput,
66
+ onChange(event: ChangeEvent<HTMLInputElement>) {
67
+ params.slotProps.htmlInput.onChange?.(event)
68
+ const v = event.target.value
69
+ if (files || !v || v.endsWith('/'))
70
+ onChange(v, { was: value, event })
71
+ },
72
+ },
73
+ },
74
}),
75
onChange: (event, sel) => onChange(sel, { was: value, event }),
76
...autocompleteProps,
77
})
58
-}
\ No newline at end of file
78
+}
admin/src/VfsTree.ts
+2
-2
@@ -67,7 +67,7 @@ export default function VfsTree({ statusApi }:{ statusApi: ApiObject }) {
67
minHeight: '1.8em', pt: '.2em', // comfy, make single-line ones taller
68
}
69
},
70
- h(Box, { display: 'flex', flex: 0, },
70
+ h(Box, { sx: { display: 'flex', flex: 0 } },
71
vfsNodeIcon(node),
72
// attributes, as icons
73
h(Box, {
@@ -131,7 +131,7 @@ export default function VfsTree({ statusApi }:{ statusApi: ApiObject }) {
131
document.getElementById(`${treeId}-${first?.id}`)?.scrollIntoView({ block: 'nearest', behavior: 'instant' })
132
}, [first])
133
return h(Flex, { flexDirection: 'column', alignItems: 'stretch', flex: 1 },
134
- h(Flex, { mb: 1, flexWrap: 'wrap', gap: [1, 2] },
134
+ h(Flex, { mb: 1, flexWrap: 'wrap', gap: [1, 2], mt: '2px' /*account for the save button's outline*/ },
135
h(Typography, { variant: 'h6' }, "Virtual File System"),
136
h(VfsMenuBar, { statusApi, add: toggleBtn }),
137
),
admin/src/dialog.ts
+16
-15
@@ -10,7 +10,7 @@ import { Check, Close, Error as ErrorIcon, Forward, Info, Warning } from '@mui/i
10
import { newDialog, closeDialog, dialogsDefaults, DialogOptions, componentOrNode, pendingPromise,
11
focusSelector, md, focusableSelector, useIsMobile } from '@hfs/shared'
12
import { Form, FormProps } from '@hfs/mui-grid-form'
13
-import { IconBtn, Flex, Center } from './mui'
13
+import { IconBtn, Flex, Center, mergeSx } from './mui'
14
import { useDark } from './theme'
15
import _ from 'lodash'
16
import { err2msg } from './misc'
@@ -52,16 +52,15 @@ dialogsDefaults.Container = function Container(d: DialogOptions) {
52
},
53
},
54
d.icon && componentOrNode(d.icon),
55
- h(Box, { flex:1, minWidth: 40, ml: 1 }, componentOrNode(d.title)),
55
+ h(Box, { sx: { flex: 1, minWidth: 40, ml: 1 } }, componentOrNode(d.title)),
56
d.closable && h(IconBtn, { icon: Close, title: "Close", onClick: () => closeDialog() }),
57
),
58
h(DialogContent, {
59
ref,
60
- sx: {
60
+ sx: mergeSx({
61
p: d.padding ? { xs: 1, sm: undefined } : 0, pt: '16px !important', overflow: 'initial',
62
display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'stretch',
63
- ...sx,
64
- }
63
+ }, sx)
64
}, h(d.Content) )
65
)
66
}
@@ -96,9 +95,9 @@ export function alertDialog(msg: ReactElement | string | Error, options?: AlertT
95
dialogProps: { fullScreen: false },
96
...rest,
97
Content() {
99
- return h(Box, { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 },
98
+ return h(Box, { sx: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 } },
99
isValidElement(msg) ? msg
101
- : h(Box, { fontSize: 'large', lineHeight: '1.8em', pb: 1 }, String(msg)),
100
+ : h(Box, { sx: { fontSize: 'large', lineHeight: '1.8em', pb: 1 } }, String(msg)),
101
)
102
}
103
})
@@ -126,7 +125,7 @@ export function confirmDialog(msg: ReactNode, { href, trueText="Go", falseText="
125
126
function Content() {
127
return h(Fragment, {},
129
- h(Box, { mb: 2 }, typeof msg === 'string' ? md(msg) : msg),
128
+ h(Box, { sx: { mb: 2 } }, typeof msg === 'string' ? md(msg) : msg),
129
h(Flex, {},
130
before?.({ onClick: (v: any) => dialog.close(v) }),
131
h('a', {
@@ -196,7 +195,7 @@ export async function promptDialog(msg: ReactNode, { value='', field, save, addT
195
values: { text: value },
196
form: {
197
fields: [
199
- { k: 'text', label: null, autoFocus: true, ...field, before: h(Box, { mb: 2 }, msg) },
198
+ { k: 'text', label: null, autoFocus: true, ...field, before: h(Box, { sx: { mb: 2 } }, msg) },
199
],
200
save: {
201
children: "Continue",
@@ -225,11 +224,13 @@ export function toast(msg: string | ReactElement, type: AlertType | ReactElement
224
Content,
225
dialogProps: {
226
fullScreen: false,
228
- PaperProps: {
229
- sx: { transition: `opacity ${ms}ms ease-in` },
230
- ref(x: HTMLElement) { // we need to set opacity later to trigger transition
231
- if (x)
232
- x.style.opacity = '0'
227
+ slotProps: {
228
+ paper: {
229
+ sx: { transition: `opacity ${ms}ms ease-in` },
230
+ ref(x: HTMLElement) { // we need to set opacity later to trigger transition
231
+ if (x)
232
+ x.style.opacity = '0'
233
+ }
234
}
235
}
236
}
@@ -238,7 +239,7 @@ export function toast(msg: string | ReactElement, type: AlertType | ReactElement
239
return dialog
240
241
function Content(){
241
- return h(Box, { display:'flex', flexDirection: 'column', alignItems: 'center', gap: 1 },
242
+ return h(Box, { sx: { display:'flex', flexDirection: 'column', alignItems: 'center', gap: 1 } },
243
isValidElement(type) ? type : h(type2ico[type], { color:type }),
244
isValidElement(msg) ? msg : h('div', {}, String(msg))
245
)
admin/src/importAccountsCsv.ts
+2
-2
@@ -35,7 +35,7 @@ export async function importAccountsCsv(cb?: () => void) {
35
return {
36
save: { startIcon: h(Upload), children: 'Go' },
37
fields: [
38
- h(Box, { p: 1 }, "Total lines:", rows.length),
38
+ h(Box, { sx: { p: 1 } }, "Total lines:", rows.length),
39
{ k: 'skipFirstLines', comp: NumberField, max: rows.length-1, typing: true, md: 6,
40
helperText: h(Fragment, {}, "First line: ", h('code', {}, row.join(', ')) ),
41
},
@@ -110,7 +110,7 @@ export async function importAccountsCsv(cb?: () => void) {
110
})
111
return () => { stop = true }
112
}, [])
113
- return h(Box, { display: 'flex', gap: 2, alignItems: 'center' },
113
+ return h(Box, { sx: { display: 'flex', gap: 2, alignItems: 'center' } },
114
h(IconProgress, { icon: Group, progress }),
115
record?.u,
116
)
admin/src/index.scss
+1
-1
@@ -105,4 +105,4 @@ h2.MuiDialogTitle-root { /* less padding */
105
// adjust prism black theme
106
.token.operator { background: unset !important; }
107
108
-.fflag { width: 26px; height: 17px; vertical-align: text-bottom; border: none; }
\ No newline at end of file
108
+.fflag { width: 26px; height: 17px; vertical-align: bottom; border: none; }
\ No newline at end of file
admin/src/mui.ts
+18
-11
@@ -7,13 +7,14 @@ import {
7
createElement as h, forwardRef, Fragment, ReactElement, ReactNode, useCallback, useEffect, useRef,
8
ForwardedRef, useState, useMemo, isValidElement, ElementType
9
} from 'react'
10
-import { Box, BoxProps, Breakpoint, ButtonProps, CircularProgress, IconButton, IconButtonProps, Link, LinkProps,
10
+import { Box, BoxProps, ButtonProps, CircularProgress, IconButton, IconButtonProps, Link, LinkProps,
11
Tooltip, TooltipProps, useMediaQuery, Button } from '@mui/material'
12
+import type { Breakpoint } from '@mui/material/styles'
13
import {
14
anyDialogOpen, closeDialog, formatPerc, isIpLan, isIpLocalHost, prefix, WIKI_URL, with_, Functionable, callable
15
} from './misc'
16
import { dontBotherWithKeys, restartAnimation, useBatch, useStateMounted } from '@hfs/shared'
16
-import { Promisable, StringField } from '@hfs/mui-grid-form'
17
+import { mergeSx, Promisable, StringField } from '@hfs/mui-grid-form'
18
import { alertDialog, confirmDialog, toast } from './dialog'
19
import { Link as RouterLink, LinkProps as RouterLinkProps, useNavigate } from './router'
20
import { SvgIconProps } from '@mui/material/SvgIcon/SvgIcon'
@@ -67,13 +68,15 @@ export function IconProgress({ icon, progress, offset, title, sx }: IconProgress
68
value: (offset || 1e-7) * 100,
69
variant: 'determinate',
70
size: 32,
70
- sx: _.defaults(sx as any, { display: 'flex' }) as any, // workaround: without this the element has 0 width when the space is crammy (monitor/file)
71
+ sx: mergeSx({ display: 'flex' }, sx), // workaround: without this the element has 0 width when the space is crammy (monitor/file)
72
}),
73
)
74
)
75
}
76
76
-type FlexProps = { vert?: boolean, center?: boolean, children?: ReactNode, props?: BoxProps, component?: ElementType } & Record<string, any>
77
+export { mergeSx }
78
+
79
+type FlexProps = { vert?: boolean, center?: boolean, children?: ReactNode, props?: Omit<BoxProps, 'sx'>, component?: ElementType } & Record<string, any>
80
export function Flex({ vert=false, center=false, children=null, props={}, component, ...rest }: FlexProps) {
81
return h(Box as any, {
82
sx: {
@@ -233,15 +236,19 @@ export function InLink({ ...props }: LinkProps & RouterLinkProps) {
236
return h(Link, { component: RouterLink, ...props })
237
}
238
236
-export const Center = forwardRef((props: BoxProps, ref) =>
237
- h(Box, { ref, display:'flex', height:'100%', width:'100%', justifyContent:'center', alignItems:'center', flexDirection: 'column', ...props }))
239
+export const Center = forwardRef(({ sx, ...props }: BoxProps, ref) =>
240
+ h(Box, {
241
+ ref,
242
+ sx: mergeSx({ display:'flex', height:'100%', width:'100%', justifyContent:'center', alignItems:'center', flexDirection: 'column' }, sx),
243
+ ...props
244
+ }))
245
246
// looks like a link, but it's a button
247
export function LinkBtn({ ...rest }: LinkProps) {
248
return h(Link, {
249
...rest,
250
href: '',
244
- sx: { cursor: 'pointer', ...rest.sx },
251
+ sx: mergeSx({ cursor: 'pointer' }, rest.sx),
252
role: 'button',
253
onClick(ev) {
254
ev.preventDefault()
@@ -270,7 +277,7 @@ export function useToggleButton(onTitle: string, offTitle: undefined | string, i
277
}) : init)
278
279
const toggle = useCallback(() => setState(x => !x), [])
273
- const props = iconBtn(state)
280
+ const props = iconBtn(state) // returned props should vary only with state
281
const el = useMemo(() => h(IconBtn, {
282
size: 'small',
283
color: state ? 'primary' : undefined,
@@ -278,7 +285,7 @@ export function useToggleButton(onTitle: string, offTitle: undefined | string, i
285
'aria-label': onTitle, // aria should be steady, and rely on aria-pressed
286
'aria-pressed': state,
287
...props,
281
- sx: { transition: 'all .5s', ...props.sx },
288
+ sx: mergeSx({ transition: 'all .5s' }, props.sx),
289
onClick(ev) {
290
props.onClick?.(ev)
291
toggle()
@@ -317,7 +324,7 @@ export function Country({ code, ip, def, long, short }: { code: string, ip?: str
324
h(Box as any, {
325
className: `fflag fflag-${code.toUpperCase()}`,
326
component: 'span',
320
- mr: 1,
327
+ sx: { mr: 1 },
328
}),
329
long ? country.name + prefix(' (', short && code, ')') : code
330
) )
@@ -332,7 +339,7 @@ async function ip2countryBatch(ips: string[]) {
339
export function hTooltip(title: ReactNode, ariaLabel: string | undefined, children: ReactElement, props?: Omit<TooltipProps, 'title' | 'children'> & { key?: any }) {
340
return h(Tooltip, { title, children,
341
...(ariaLabel === '' ? { 'aria-hidden': true } : { 'aria-label': ariaLabel || _.isString(title) && title || undefined }),
335
- componentsProps: { popper: { sx: { whiteSpace: 'pre-wrap', ...props?.sx } } } as any,
342
+ slotProps: { popper: { sx: mergeSx({ whiteSpace: 'pre-wrap' }, props?.sx) } } as any,
343
...props
344
})
345
}
admin/src/pluginOptions.ts
+6
-6
@@ -27,7 +27,7 @@ export async function showPluginOptions(row: any, maxWidth: string) {
27
const values = await formDialog({
28
title: showOptions ? `Options for ${id}` : `Log for ${id}`,
29
form: values => ({
30
- before: row.description && h(Box, { mx: 2, mb: 2 }, row.description),
30
+ before: row.description && h(Box, { sx: { mx: 2, mb: 2 } }, row.description),
31
fields: makeFields(callable(row.config, values) || {}, values),
32
save: showOptions ? { children: "Save and close" } : false,
33
barSx: { gap: 1 },
@@ -53,17 +53,17 @@ export async function showPluginOptions(row: any, maxWidth: string) {
53
const autoScroll = useAutoScroll(list)
54
let lastDate: any
55
return h(Flex, { alignItems: 'stretch', justifyContent: 'center', flexWrap: 'wrap', flexDirection: showOptions ? undefined : 'column' },
56
- h(Box, { maxWidth, minWidth: 'min-content' /*in case content requires more space (eg: reverse-proxy's table)*/ }, children),
56
+ h(Box, { sx: { maxWidth, minWidth: 'min-content' /*in case content requires more space (eg: reverse-proxy's table)*/ } }, children),
57
h(Paper, { elevation: 1, sx: { position: 'relative', fontFamily: 'monospace', flex: 1, minWidth: 'min(40em, 90vw)', minHeight: '20em', px: .5 } },
58
- h(Box, { my: .5, pb: .5, borderBottom: '1px solid', display: 'flex', alignItems: 'center', justifyContent: 'space-between' },
58
+ h(Box, { sx: { my: .5, pb: .5, borderBottom: '1px solid', display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
59
"Output",
60
h(Btn, { size: 'small', sx: { p: 0 }, onClick() { setList([]) } }, "Clear")
61
),
62
h(Box, {
63
- position: 'absolute', bottom: 0, top: '31px', left: 0, right: 0, sx: { overflowY: 'auto' },
63
+ sx: { position: 'absolute', bottom: 0, top: '31px', left: 0, right: 0, overflowY: 'auto' },
64
ref: autoScroll,
65
},
66
- !list.length && h(Box, { p: 1 }, "Log is empty"),
66
+ !list.length && h(Box, { sx: { p: 1 } }, "Log is empty"),
67
h(Box, {
68
sx: {
69
textIndent: '-1em', pl: '1em',
@@ -75,7 +75,7 @@ export async function showPluginOptions(row: any, maxWidth: string) {
75
return h(Fragment, { key: x.id },
76
thisDate !== lastDate && (lastDate = thisDate),
77
h(Box, {},
78
- h(Box, { title: thisDate, display: 'inline', color: 'text.secondary', mr: 1 }, formatTime(x.ts)),
78
+ h(Box, { title: thisDate, sx: { display: 'inline', color: 'text.secondary', mr: 1 } }, formatTime(x.ts)),
79
replaceStringToReact(x.msg, /https?:\/\/\S+/, m => h(Link, {
80
href: m[0],
81
target: '_blank'
admin/tsconfig.json
+1
-1
@@ -1,4 +1,4 @@
1
{
2
"extends": "../tsconfig-web",
3
"include": ["src"]
4
-}
\ No newline at end of file
4
+}
mui-grid-form/SelectField.ts
+2
-2
@@ -88,7 +88,7 @@ export function MultiSelectField<T>({ renderOption, ...props }: MultiSelectField
88
renderValue: () => h('div', {
89
'aria-label': label + ': ' + valueAsOptions.map(x => x.label ?? String(x.value)),
90
style: { overflow: "hidden", display: "flex", flexWrap: "wrap", gap: ".5em" },
91
- children: isEmpty ? h(Box, { position: 'relative', top: '.3em', fontSize: 'small', fontStyle: 'italic', color: 'text.secondary' }, placeholder)
91
+ children: isEmpty ? h(Box, { sx: { position: 'relative', top: '.3em', fontSize: 'small', fontStyle: 'italic', color: 'text.secondary' } }, placeholder)
92
: valueAsOptions.map((x, i) => h('span', { key: i }, renderOption!(x), i < valueAsOptions.length - 1 && valueSeparator)),
93
}),
94
...rest,
@@ -99,7 +99,7 @@ export function MultiSelectField<T>({ renderOption, ...props }: MultiSelectField
99
onClickCapture(ev) { ev.stopPropagation() }
100
}, "No options available")),
101
!isMobile && normalizedOptions?.length! > 20 && h(Box, {
102
- sx: { float: 'right' }, fontSize: 'small', width: '8em', textAlign: 'right', marginRight: '.5em'
102
+ sx: { float: 'right', fontSize: 'small', width: '8em', textAlign: 'right', marginRight: '.5em' },
103
}, "ⓘ You can type the name"),
104
h(Button, {
105
size: 'small',
mui-grid-form/StringField.ts
+76
-45
@@ -1,6 +1,6 @@
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, ReactNode, useEffect, useRef, useState } from 'react'
3
+import { createElement as h, Fragment, ReactNode, useEffect, useRef, useState } from 'react'
4
import { FieldProps } from '.'
5
import { Autocomplete, InputAdornment, TextField } from '@mui/material'
6
import { StandardTextFieldProps } from '@mui/material/TextField/TextField'
@@ -15,7 +15,10 @@ export interface StringFieldProps extends FieldProps<string>, Partial<Omit<Stand
15
end?: ReactNode
16
wrap?: boolean
17
}
18
-export function StringField({ value, onChange, min, max, required, setApi, typing, start, end, onTyping, suggestions, wrap, fieldRef, ...props }: StringFieldProps) {
18
+export function StringField({
19
+ value, onChange, min, max, required, setApi, typing, start, end, onTyping, suggestions, wrap, fieldRef,
20
+ InputProps, InputLabelProps, inputProps, slotProps, ...props
21
+}: StringFieldProps) {
22
const normalized = value ?? ''
23
setApi?.({
24
getError() {
@@ -34,48 +37,68 @@ export function StringField({ value, onChange, min, max, required, setApi, typin
37
}, [normalized])
38
const valueFocusing = useRef<string | undefined>()
39
const autoFillDetected = useRef(false)
37
- const render = (params: any) => h(TextField, {
38
- fullWidth: true,
39
- InputLabelProps: state || props.placeholder ? { shrink: true } : undefined,
40
- ...props,
41
- ...params,
42
- ref: fieldRef,
43
- sx: props.label ? props.sx : Object.assign({ '& .MuiInputBase-input': { pt: 1.5 } }, props.sx),
44
- value: state,
45
- onChange(ev) {
46
- let val = ev.target.value
47
- if (wrap && val.includes('\n')) return // prevent newlines, we are a wrapped yet single line
48
- if (onTyping) {
49
- const res = onTyping(val)
50
- if (res === false) return
51
- val = res
52
- }
53
- setState(val)
54
- if (typing || autoFillDetected.current || valueFocusing.current === undefined)
55
- go(ev, val)
56
- },
57
- onKeyDown(ev) {
58
- props.onKeyDown?.(ev)
59
- autoFillDetected.current = ev.code === undefined
60
- if (ev.key === 'Enter' && (ev.target as HTMLElement).ariaExpanded !== 'true') // don't act if suggestion list is expanded
61
- go(ev)
62
- },
63
- onFocus(ev) {
64
- valueFocusing.current = ev.target.value
65
- },
66
- onBlur(ev) {
67
- props.onBlur?.(ev)
68
- if (valueFocusing.current !== ev.target.value)
69
- go(ev)
70
- },
71
- InputProps: {
72
- ...wrap && { multiline: true },
73
- startAdornment: start && h(InputAdornment, { position: 'start' }, start),
74
- endAdornment: end && h(InputAdornment, { position: 'end' }, end),
75
- ...props.InputProps,
76
- ...params?.InputProps,
77
- },
78
- })
40
+ const render = (params: any) => {
41
+ // callers still use both legacy adornments and StringField's shortcuts, so keep them equivalent under slotProps
42
+ const inputSlotProps = {
43
+ ...slotProps?.input,
44
+ ...InputProps,
45
+ ...params?.slotProps?.input,
46
+ }
47
+ return h(TextField, {
48
+ fullWidth: true,
49
+ ...params,
50
+ ...props,
51
+ ref: fieldRef,
52
+ sx: props.label ? props.sx : Object.assign({ '& .MuiInputBase-input': { pt: 1.5 } }, props.sx),
53
+ value: state,
54
+ // v9 routes TextField customization through slotProps; keep the old callers working by folding the legacy props in here
55
+ slotProps: {
56
+ ...slotProps,
57
+ input: {
58
+ ...inputSlotProps,
59
+ startAdornment: addShortcutAdornment('start', start, inputSlotProps.startAdornment),
60
+ endAdornment: addShortcutAdornment('end', end, inputSlotProps.endAdornment),
61
+ },
62
+ inputLabel: state || props.placeholder || InputLabelProps || slotProps?.inputLabel ? {
63
+ shrink: true,
64
+ ...params?.slotProps?.inputLabel,
65
+ ...InputLabelProps,
66
+ ...slotProps?.inputLabel,
67
+ } : undefined,
68
+ htmlInput: {
69
+ ...slotProps?.htmlInput,
70
+ ...inputProps,
71
+ ...params?.slotProps?.htmlInput,
72
+ },
73
+ },
74
+ onChange(ev) {
75
+ let val = ev.target.value
76
+ if (wrap && val.includes('\n')) return // prevent newlines, we are a wrapped yet single line
77
+ if (onTyping) {
78
+ const res = onTyping(val)
79
+ if (res === false) return
80
+ val = res
81
+ }
82
+ setState(val)
83
+ if (typing || autoFillDetected.current || valueFocusing.current === undefined)
84
+ go(ev, val)
85
+ },
86
+ onKeyDown(ev) {
87
+ props.onKeyDown?.(ev)
88
+ autoFillDetected.current = ev.code === undefined
89
+ if (ev.key === 'Enter' && (ev.target as HTMLElement).ariaExpanded !== 'true') // don't act if suggestion list is expanded
90
+ go(ev)
91
+ },
92
+ onFocus(ev) {
93
+ valueFocusing.current = ev.target.value
94
+ },
95
+ onBlur(ev) {
96
+ props.onBlur?.(ev)
97
+ if (valueFocusing.current !== ev.target.value)
98
+ go(ev)
99
+ },
100
+ })
101
+ }
102
return !suggestions ? render(null)
103
: h(Autocomplete, {
104
freeSolo: true,
@@ -86,6 +109,15 @@ export function StringField({ value, onChange, min, max, required, setApi, typin
109
}
110
})
111
112
+ function addShortcutAdornment(position: 'start' | 'end', shortcut: ReactNode, existing: ReactNode) {
113
+ if (!shortcut)
114
+ return existing
115
+ const shortcutAdornment = h(InputAdornment, { position }, shortcut)
116
+ return position === 'start'
117
+ ? h(Fragment, {}, shortcutAdornment, existing)
118
+ : h(Fragment, {}, existing, shortcutAdornment)
119
+ }
120
+
121
function go(event: any, newVal: string=state) {
122
newVal = newVal?.trim()
123
if (newVal === lastChange.current) return // don't compare to 'value' as that represents only accepted changes, while we are interested also in changes through discarded values
@@ -99,4 +131,3 @@ export function StringField({ value, onChange, min, max, required, setApi, typin
131
})
132
}
133
}
102
-
mui-grid-form/index.ts
+25
-15
@@ -4,12 +4,13 @@ import {
4
createElement as h, FC, Fragment, isValidElement, ReactElement, ReactNode, useEffect, useState, useRef,
5
MutableRefObject
6
} from 'react'
7
-import { Box, BoxProps, Button, Tooltip } from '@mui/material'
7
+import { Box, BoxProps, Button, Grid, GridProps, Tooltip } from '@mui/material'
8
import { Save } from '@mui/icons-material'
9
import _ from 'lodash'
10
import { StringField } from './StringField'
11
-import Grid, { GridProps } from '@mui/material/Grid'
11
import { useDebounce } from 'usehooks-ts'
12
+import type { SxProps } from '@mui/system'
13
+import type { Theme } from '@mui/material/styles'
14
export * from './SelectField'
15
export * from './misc-fields'
16
export { StringField }
@@ -51,6 +52,10 @@ export interface FieldProps<T> {
52
53
export type Dict<T=any> = Record<string,T>
54
55
+export function mergeSx(...parts: Array<SxProps<Theme> | false | null | undefined>): SxProps<Theme> {
56
+ return parts.filter(Boolean).flatMap(x => _.castArray(x)) as SxProps<Theme>
57
+}
58
+
59
export interface FormApi {
60
submit(): void
61
validate(): Promise<boolean>
@@ -92,7 +97,8 @@ export function Form<Values extends Dict>({
97
onValidation,
98
saveOnEnter,
99
gridProps,
95
- ...rest
100
+ sx,
101
+ ...boxProps
102
}: FormProps<Values>) {
103
const mounted = useRef(false)
104
useEffect(() => {
@@ -125,9 +131,11 @@ export function Form<Values extends Dict>({
131
const apis: Dict<FieldApi<unknown>> = {} // consider { [K in keyof Values]?: FieldApi<Values[K]> }
132
return h(Box, {
133
component: 'form',
128
- display: 'flex',
129
- flexDirection: 'column',
130
- gap: 3,
134
+ sx: mergeSx({
135
+ display: 'flex',
136
+ flexDirection: 'column',
137
+ gap: 3,
138
+ }, sx),
139
ref: formRef,
140
onSubmit(ev) {
141
ev.preventDefault()
@@ -136,7 +144,8 @@ export function Form<Values extends Dict>({
144
if (saveBtn && !saveBtn.disabled && (ev.ctrlKey || ev.metaKey) && ev.key === 'Enter')
145
pleaseSubmitAndValidate()
146
},
139
- ...rest,
147
+ // maxWidth is a layout hint, so keep it in sx instead of forwarding it to the form DOM node
148
+ ...boxProps,
149
},
150
h(Grid, { container:true, rowSpacing:3, columnSpacing:1, ...gridProps },
151
fields.map((row, idx) => {
@@ -182,11 +191,11 @@ export function Form<Values extends Dict>({
191
field.helperText = h(Fragment, {}, ...field.helperText)
192
if (errMsg) // special rendering when we have both error and helperText. "hr" would be nice but issues a warning because contained in a <p>
193
field.helperText = !field.helperText ? errMsg
185
- : h(Box as any, { color: 'text.primary', component: 'span' },
194
+ : h(Box as any, { sx: { color: 'text.primary' }, component: 'span' },
195
h(Box as any, {
187
- color: 'error.main',
196
+ sx: { color: 'error.main', display: 'block' },
197
style: { borderBottom: '1px solid' },
189
- component: 'span', display: 'block' // avoid console warning, but keep it on separate line
198
+ component: 'span' // avoid console warning, but keep it on separate line
199
}, errMsg),
200
field.helperText,
201
)
@@ -202,14 +211,15 @@ export function Form<Values extends Dict>({
211
})
212
),
213
saveBtn && h(Box, {
205
- display: 'flex',
206
- alignItems: 'center',
207
- sx: Object.assign({},
208
- stickyBar && {
214
+ sx: {
215
+ display: 'flex',
216
+ alignItems: 'center',
217
+ ...stickyBar && {
218
width: 'fit-content', zIndex: 2, backgroundColor: 'background.paper', borderRadius: 1,
219
position: 'sticky', bottom: 0, p: 1, m: -1, boxShadow: '0px 0px 15px #000',
220
},
212
- barSx)
221
+ ...barSx,
222
+ }
223
}, h(Tooltip, { title: "ctrl + enter", children: h(Button as any, {
224
// mui v6 moved LoadingButton behavior into Button, but current typings here still miss loading props
225
variant: 'contained',
mui-grid-form/misc-fields.ts
+1
-1
@@ -72,7 +72,7 @@ export function BoolField({ label='', value, onChange, setApi, helperText, error
72
onChange((event.target as any).checked, { event, was: value })
73
}
74
})
75
- return h(Box, { ml: 1, sx: error ? { color: 'error.main', outlineOffset: 6, outline: '1px solid' } : undefined },
75
+ return h(Box, { sx: { ml: 1, ...error && { color: 'error.main', outlineOffset: 6, outline: '1px solid' } } },
76
h(FormControlLabel, { label, control, labelPlacement: 'end', sx: { mr: 0, ...props.size==='small' && { '& .MuiFormControlLabel-label': { fontSize: '.9rem' } } } }),
77
helperText && h(FormHelperText, { sx: { mt: 0 }, error }, helperText)
78
)
mui-grid-form/package.json
+3
-3
@@ -2,9 +2,9 @@
2
"name": "@hfs/mui-grid-form",
3
"main": "index.ts",
4
"peerDependencies": {
5
- "@mui/icons-material": "^7",
6
- "@mui/lab": "^7",
7
- "@mui/material": "^7",
5
+ "@mui/icons-material": "^9",
6
+ "@mui/lab": "^9.0.0-beta.2",
7
+ "@mui/material": "^9",
8
"lodash": "^4.18.1"
9
},
10
"devDependencies": {