@samitouri / QOSami-HFS / commits / cc3d2425

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": {