ux: mobile-friendly admin tables
Massimo Melina committed
Jul 10, 2023 at 01:20 UTC
5942345cd7d992f393b4a44796d05cd021dffa55
10 files changed
+318
-190
admin/package.json
+1
-1
@@ -15,7 +15,7 @@
15
"@mui/icons-material": "^5.8.4",
16
"@mui/lab": "^5.0.0-alpha.94",
17
"@mui/material": "^5.10.0",
18
- "@mui/x-data-grid": "^6.8.0",
18
+ "@mui/x-data-grid": "^6.9.2",
19
"@gregoranders/csv": "^0.0.12",
20
"react": "^18.2.0",
21
"react-dom": "^18.2.0",
admin/src/App.ts
+1
-1
@@ -47,7 +47,7 @@ function Routed() {
47
sx: {
48
background: 'url(cup.svg) no-repeat right fixed',
49
backgroundSize: 'contain',
50
- px: { xs: 2, md: 3 },
50
+ px: { xs: 1, sm: 2, md: 3 },
51
pb: '1em',
52
position: 'relative',
53
display: 'flex',
admin/src/DataTable.ts
new
+142
@@ -0,0 +1,142 @@
1
+import { DataGrid, DataGridProps, GridColDef, GridValidRowModel, useGridApiRef } from '@mui/x-data-grid'
2
+import { Box, BoxProps, Breakpoint, LinearProgress, useTheme } from '@mui/material'
3
+import { useWindowSize } from 'usehooks-ts'
4
+import { createElement as h, Fragment, ReactNode, useEffect, useMemo, useRef, useState } from 'react'
5
+import { newDialog, onlyTruthy } from '@hfs/shared'
6
+import _ from 'lodash'
7
+import { Flex } from './misc'
8
+
9
+const ACTIONS = 'Actions'
10
+
11
+interface DataTableProps<R extends GridValidRowModel=any> extends Omit<DataGridProps<R>, 'columns'> {
12
+ columns: Array<GridColDef<R> & {
13
+ hideUnder?: Breakpoint | number
14
+ mergeRender?: { other: string, override?: Partial<GridColDef<R>> } & BoxProps
15
+ }>
16
+ actions?: ({ row, id }: any) => ReactNode[]
17
+ actionsProps?: Partial<GridColDef<R>> & { hideUnder?: Breakpoint | number }
18
+ initializing?: boolean
19
+}
20
+export function DataTable({ columns, initialState={}, actions, actionsProps, initializing, ...rest }: DataTableProps) {
21
+ const { width } = useWindowSize()
22
+ const theme = useTheme()
23
+ const apiRef = useGridApiRef()
24
+ const [actionsLength, setActionsLength] = useState(0)
25
+ const manipulatedColumns = useMemo(() => {
26
+ const ret = columns.map(col => {
27
+ const { mergeRender } = col
28
+ if (!mergeRender)
29
+ return col
30
+ const { other, override, ...props } = mergeRender
31
+ return {
32
+ ...col,
33
+ originalRenderCell: col.renderCell || true,
34
+ renderCell(params: any) {
35
+ const { columns } = params.api.store.getSnapshot()
36
+ const showOther = columns.columnVisibilityModel[other] === false
37
+ return h(Box, {}, col.renderCell ? col.renderCell(params) : params.formattedValue,
38
+ showOther && h(Box, props, renderCell({ ...columns.lookup[other], ...override }, params.row)))
39
+ }
40
+ }
41
+ })
42
+ if (actions)
43
+ ret.push({
44
+ field: ACTIONS,
45
+ width: 40 * actionsLength,
46
+ headerName: '',
47
+ align: 'center',
48
+ headerAlign: 'center',
49
+ hideSortIcons: true,
50
+ disableColumnMenu: true,
51
+ renderCell(params: any) {
52
+ const ret = actions({ ...params.row, ...params })
53
+ setTimeout(() => setActionsLength(ret.length)) // cannot update state during rendering
54
+ return h(Box, { whiteSpace: 'nowrap' }, ...ret)
55
+ },
56
+ ...actionsProps
57
+ })
58
+ return ret
59
+ }, [columns, actions, actionsLength])
60
+ const hideCols = useMemo(() => {
61
+ if (!width) return
62
+ const fields = onlyTruthy(manipulatedColumns.map(({ field, hideUnder }) =>
63
+ hideUnder
64
+ && width < (typeof hideUnder === 'number' ? hideUnder : theme.breakpoints.values[hideUnder])
65
+ && field))
66
+ const o = Object.fromEntries(fields.map(x => [x, false]))
67
+ _.merge(initialState, { columns: { columnVisibilityModel: o } })
68
+ return fields
69
+ }, [manipulatedColumns])
70
+ const [vis, setVis] = useState({})
71
+
72
+ const displayingDetails = useRef<any>({})
73
+ useEffect(() => {
74
+ const { current: { id, setCurRow } } = displayingDetails
75
+ setCurRow?.(_.find(rest.rows, { id }))
76
+ })
77
+
78
+ if (!hideCols) // only first time we render, initialState is considered, so wait
79
+ return null
80
+
81
+ return h(Fragment, {},
82
+ initializing && h(Box, { position: 'relative' },
83
+ h(LinearProgress, { // differently from "loading", this is not blocking user interaction
84
+ sx: { position: 'absolute', width: 'calc(100% - 2px)', borderRadius: 1, m: '1px 1px' }
85
+ }) ),
86
+ h(DataGrid, {
87
+ initialState,
88
+ columns: manipulatedColumns,
89
+ apiRef,
90
+ onCellClick({ field, row }) {
91
+ if (field === ACTIONS) return
92
+ const n = apiRef.current.getVisibleColumns().length
93
+ const showCols = manipulatedColumns.filter(x =>
94
+ x.renderCell || x.field === ACTIONS || row[x.field] !== undefined)
95
+ if (showCols.length <= n) return
96
+ newDialog({
97
+ title: "Details",
98
+ onClose() {
99
+ displayingDetails.current = {}
100
+ },
101
+ Content() {
102
+ const [curRow, setCurRow] = useState(row)
103
+ const keepRow = useRef(row)
104
+ if (curRow)
105
+ keepRow.current = curRow
106
+ const rowToShow = keepRow.current
107
+ displayingDetails.current = { id: rowToShow.id, setCurRow }
108
+ return h(Box, {
109
+ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(8em,1fr))', gap: '1em',
110
+ gridAutoFlow: 'dense',
111
+ minWidth: 'max(16em, 40vw)',
112
+ sx: { opacity: curRow ? undefined : .5 },
113
+ }, showCols.map(col =>
114
+ h(Box, { key: col.field, gridColumn: col.flex && '1/-1' },
115
+ h(Box, { bgcolor: '#0003', p: 1 }, col.headerName || col.field),
116
+ h(Flex, { minHeight: '2.5em', px: 1, alignItems: 'center', wordBreak: 'break-word' },
117
+ renderCell(col, rowToShow) )
118
+ ) ))
119
+ }
120
+ })
121
+ },
122
+ ...rest,
123
+ onColumnVisibilityModelChange: x => setVis(x),
124
+ columnVisibilityModel: {
125
+ ...Object.fromEntries(hideCols.map(x => [x, false])),
126
+ ...rest.columnVisibilityModel,
127
+ ...vis,
128
+ }
129
+ })
130
+ )
131
+
132
+ function renderCell(col: GridColDef, row: any) {
133
+ const api = apiRef.current
134
+ let value = row[col.field]
135
+ if (col.valueGetter)
136
+ value = col.valueGetter({ value, api, row, field: col.field, id: row.id } as any)
137
+ const render = (col as any).originalRenderCell || col.renderCell
138
+ return render && render !== true ? render({ value, row, api, ...row })
139
+ : col.valueFormatter ? col.valueFormatter({ value, ...row })
140
+ : value
141
+ }
142
+}
admin/src/InstalledPlugins.ts
+60
-65
@@ -3,7 +3,7 @@
3
import { apiCall, useApiList } from './api'
4
import { createElement as h, Fragment, ReactNode } from 'react'
5
import { Alert, Box, Link, Tooltip } from '@mui/material'
6
-import { DataGrid } from '@mui/x-data-grid'
6
+import { DataTable } from './DataTable'
7
import { Delete, Error, PlayCircle, Settings, StopCircle, Upgrade } from '@mui/icons-material'
8
import { IconBtn, xlate } from './misc'
9
import { formDialog, toast } from './dialog'
@@ -16,14 +16,12 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
16
const { list, updateEntry, error, initializing } = useApiList(updates ? 'get_plugin_updates' : 'get_plugins')
17
if (error)
18
return showError(error)
19
- return h(DataGrid, {
19
+ return h(DataTable, {
20
rows: list.length ? list : [], // workaround for DataGrid bug causing 'no rows' message to be not displayed after 'loading' was also used
21
- loading: initializing,
21
+ initializing,
22
disableColumnSelector: true,
23
disableColumnMenu: true,
24
- columnVisibilityModel: {
25
- started: !updates,
26
- },
24
+ hideFooter: true,
25
localeText: updates && { noRowsLabel: `No updates available. Only plugins available on "search online" are checked.` },
26
columns: [
27
{
@@ -31,75 +29,72 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
29
headerName: "name",
30
flex: .3,
31
minWidth: 150,
34
- renderCell: renderName
32
+ renderCell: renderName,
33
+ mergeRender: { other: 'description', fontSize: 'x-small' }
34
},
35
{
36
field: 'version',
37
width: 70,
38
+ hideUnder: 'sm',
39
},
40
{
41
field: 'description',
42
flex: 1,
43
+ hideUnder: 'sm',
44
},
44
- {
45
- field: "actions",
46
- width: 120,
47
- align: 'center',
48
- headerAlign: 'center',
49
- hideSortIcons: true,
50
- disableColumnMenu: true,
51
- renderCell({ row }) {
52
- const { config, id, updated } = row
53
- if (updates)
54
- return h(UpdateButton, { id, updated, then: () => updateEntry({ id }, { updated: true }) })
55
- return h('div', {},
56
- h(IconBtn, row.started ? {
57
- icon: StopCircle,
58
- title: h(Box, {}, `Stop ${id}`, h('br'), `Started ` + new Date(row.started as string).toLocaleString()),
59
- color: 'success',
60
- async onClick() {
61
- await apiCall('stop_plugin', { id })
62
- toast("Plugin stopped", h(StopCircle, { color: 'warning' }))
63
- }
64
- } : {
65
- icon: PlayCircle,
66
- title: `Start ${id}`,
67
- onClick: () => startPlugin(id),
68
- }),
69
- h(IconBtn, {
70
- icon: Settings,
71
- title: "Options",
72
- disabled: !row.started && "Start plugin to access options"
73
- || !config && "No options available for this plugin",
74
- progress: false,
75
- async onClick() {
76
- const pl = await apiCall('get_plugin', { id })
77
- const values = await formDialog({
78
- title: `${id} options`,
79
- form: {
80
- before: h(Box, { mx: 2, mb: 3 }, row.description),
81
- fields: makeFields(config),
82
- },
83
- values: pl.config,
84
- dialogProps: row.configDialog,
85
- })
86
- if (!values || _.isEqual(pl.config, values)) return
87
- await apiCall('set_plugin', { id, config: values })
88
- toast("Configuration saved")
89
- }
90
- }),
91
- h(IconBtn, {
92
- icon: Delete,
93
- title: "Uninstall",
94
- confirm: "Remove?",
95
- async onClick() {
96
- await apiCall('uninstall_plugin', { id })
97
- toast("Plugin uninstalled")
98
- }
99
- }),
100
- )
45
+ ],
46
+ actions: ({ row, id }) => updates ? [
47
+ h(UpdateButton, { id, updated: row.updated, then: () => updateEntry({ id }, { updated: true }) })
48
+ ] : [
49
+ h(IconBtn, row.started ? {
50
+ icon: StopCircle,
51
+ title: h(Box, {}, `Stop ${id}`, h('br'), `Started ` + new Date(row.started as string).toLocaleString()),
52
+ size: 'small',
53
+ color: 'success',
54
+ async onClick() {
55
+ await apiCall('stop_plugin', { id })
56
+ toast("Plugin stopped", h(StopCircle, { color: 'warning' }))
57
}
102
- },
58
+ } : {
59
+ icon: PlayCircle,
60
+ title: `Start ${id}`,
61
+ size: 'small',
62
+ onClick: () => startPlugin(id),
63
+ }),
64
+ h(IconBtn, {
65
+ icon: Settings,
66
+ title: "Options",
67
+ size: 'small',
68
+ disabled: !row.started && "Start plugin to access options"
69
+ || !row.config && "No options available for this plugin",
70
+ progress: false,
71
+ async onClick() {
72
+ const pl = await apiCall('get_plugin', { id })
73
+ const values = await formDialog({
74
+ title: `${id} options`,
75
+ form: {
76
+ before: h(Box, { mx: 2, mb: 3 }, row.description),
77
+ fields: makeFields(row.config),
78
+ },
79
+ values: pl.config,
80
+ dialogProps: _.merge({ sx: { m: 'auto' } }, // center content when it is smaller than mobile (because of full-screen)
81
+ row.configDialog),
82
+ })
83
+ if (!values || _.isEqual(pl.config, values)) return
84
+ await apiCall('set_plugin', { id, config: values })
85
+ toast("Configuration saved")
86
+ }
87
+ }),
88
+ h(IconBtn, {
89
+ icon: Delete,
90
+ title: "Uninstall",
91
+ size: 'small',
92
+ confirm: "Remove?",
93
+ async onClick() {
94
+ await apiCall('uninstall_plugin', { id })
95
+ toast("Plugin uninstalled")
96
+ }
97
+ }),
98
]
99
})
100
}
admin/src/LangPage.ts
+22
-27
@@ -2,7 +2,7 @@
2
3
import { createElement as h, Fragment, useEffect, useMemo, useState } from 'react';
4
import { apiCall, useApiEx, useApiList } from './api'
5
-import { DataGrid } from '@mui/x-data-grid'
5
+import { DataTable } from './DataTable';
6
import { Alert, Box, Button } from '@mui/material'
7
import { Delete, Upload } from '@mui/icons-material'
8
import { IconBtn, readFile, selectFiles, useBreakpoint } from './misc'
@@ -11,7 +11,7 @@ import { alertDialog, toast } from './dialog'
11
import { Field, SelectField } from '@hfs/mui-grid-form';
12
13
export default function LangPage() {
14
- const { list, error, connecting, reload } = useApiList('list_langs', undefined, { addId: true })
14
+ const { list, error, connecting, reload } = useApiList('list_langs')
15
const langs = useMemo(() => ['en', ..._.uniq(list.map(x => x.code))], [list])
16
const large = useBreakpoint('md')
17
return error || h(Fragment, {},
@@ -22,7 +22,7 @@ export default function LangPage() {
22
h(Box, { flex: 1 }),
23
h(ForceLang, { langs }),
24
),
25
- h(DataGrid, {
25
+ h(DataTable, {
26
loading: connecting,
27
rows: list as any,
28
hideFooter: true,
@@ -30,41 +30,37 @@ export default function LangPage() {
30
columns: [
31
{
32
field: 'code',
33
- width: 80,
33
+ width: 110,
34
+ valueFormatter: ({ value }) => value?.toUpperCase(),
35
},
36
{
37
field: 'version',
37
- width: 80,
38
+ width: 120,
39
+ hideUnder: 'sm',
40
},
41
{
42
field: 'hfs_version',
43
headerName: "HFS version",
44
+ width: 110,
45
},
46
{
47
field: 'author',
48
flex: 1,
46
- },
47
- {
48
- field: "actions",
49
- width: 80,
50
- align: 'center',
51
- hideSortIcons: true,
52
- disableColumnMenu: true,
53
- renderCell({ row }) {
54
- return row.embedded ? "Embedded" : h('div', {},
55
- h(IconBtn, {
56
- icon: Delete,
57
- title: "Delete",
58
- confirm: "Delete?",
59
- async onClick() {
60
- await apiCall('del_lang', _.pick(row, 'code'))
61
- reload()
62
- toast("Deleted")
63
- }
64
- }),
65
- )
66
- }
49
+ hideUnder: 'sm',
50
}
51
+ ],
52
+ actions: ({ row }) => [
53
+ h(IconBtn, {
54
+ icon: Delete,
55
+ title: row.embedded ? "Cannot delete (embedded)" : "Delete",
56
+ confirm: "Delete?",
57
+ disabled: row.embedded,
58
+ async onClick() {
59
+ await apiCall('del_lang', _.pick(row, 'code'))
60
+ reload()
61
+ toast("Deleted")
62
+ }
63
+ }),
64
]
65
})
66
)
@@ -88,7 +84,6 @@ export default function LangPage() {
84
}
85
}
86
91
-
87
function ForceLang({ langs }: { langs: string[] }) {
88
const K = 'force_lang'
89
const { data, reload, loading } = useApiEx('get_config', { only: [K] })
admin/src/LogsPage.ts
+13
-6
@@ -1,9 +1,9 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import { createElement as h, Fragment, useState } from 'react';
4
-import { Tab, Tabs } from '@mui/material'
4
+import { Box, Tab, Tabs } from '@mui/material'
5
import { API_URL, useApiList } from './api'
6
-import { DataGrid } from '@mui/x-data-grid'
6
+import { DataTable } from './DataTable'
7
import { formatBytes, tryJson } from '@hfs/shared'
8
import { logLabels } from './OptionsPage'
9
import { typedKeys } from './misc';
@@ -19,10 +19,10 @@ export default function LogsPage() {
19
}
20
21
function LogFile({ file }: { file: string }) {
22
- const { list, error, connecting } = useApiList('get_log', { file }, { addId: true })
22
+ const { list, error, connecting } = useApiList('get_log', { file })
23
if (error)
24
return error
25
- return h(DataGrid, {
25
+ return h(DataTable, {
26
loading: connecting,
27
rows: list as any,
28
componentsProps: {
@@ -38,35 +38,41 @@ function LogFile({ file }: { file: string }) {
38
flex: .6,
39
minWidth: 100,
40
maxWidth: 230,
41
+ mergeRender: { other: 'user' },
42
},
43
{
44
field: 'user',
45
headerName: "Username",
46
flex: .4,
47
maxWidth: 200,
48
+ hideUnder: 'lg',
49
},
50
{
51
field: 'ts',
52
headerName: "Timestamp",
53
type: 'dateTime',
52
- width: 170,
53
- valueFormatter: ({ value }) => new Date(value as string).toLocaleString()
54
+ width: 90,
55
+ valueGetter: ({ value }) => new Date(value as string),
56
+ renderCell: ({ value }) => h(Box, {}, value.toLocaleDateString(), h('br'), value.toLocaleTimeString())
57
},
58
{
59
field: 'method',
60
headerName: "Method",
61
width: 80,
62
+ hideUnder: 'lg',
63
},
64
{
65
field: 'status',
66
headerName: "Code",
67
type: 'number',
68
width: 70,
69
+ hideUnder: 'lg',
70
},
71
{
72
field: 'length',
73
headerName: "Size",
74
type: 'number',
75
+ hideUnder: 'md',
76
valueFormatter: ({ value }) => formatBytes(value as number)
77
},
78
{
@@ -74,6 +80,7 @@ function LogFile({ file }: { file: string }) {
80
headerName: "URI",
81
flex: 2,
82
minWidth: 100,
83
+ mergeRender: { other: 'method', fontSize: 'small' },
84
valueFormatter: ({ value }) => {
85
if (!value.startsWith(API_URL))
86
return value
admin/src/MonitorPage.ts
+40
-43
@@ -5,7 +5,7 @@ import { createElement as h, useMemo, Fragment, useState } from "react"
5
import { apiCall, useApiEvents, useApiEx, useApiList } from "./api"
6
import { PauseCircle, PlayCircle, Delete, Lock, Block, FolderZip, Upload, Download } from '@mui/icons-material'
7
import { Alert, Box, Chip, ChipProps } from '@mui/material'
8
-import { DataGrid } from "@mui/x-data-grid"
8
+import { DataTable } from './DataTable'
9
import { formatBytes, IconBtn, IconProgress, iconTooltip, manipulateConfig, useBreakpoint } from "./misc"
10
import { Field, SelectField } from '@hfs/mui-grid-form'
11
import { StandardCSSProperties } from '@mui/system/styleFunctionSx/StandardCssProperties'
@@ -104,31 +104,29 @@ function Connections() {
104
}),
105
),
106
error ? h(Alert, { severity: 'error' }, error)
107
- : h(DataGrid, {
107
+ : h(DataTable, {
108
rows,
109
localeText: filtered ? { noRowsLabel: "No downloads at the moment" } : undefined,
110
- initialState: {
111
- columns: {
112
- columnVisibilityModel: { v: false },
113
- }
114
- },
110
columns: [
111
{
112
field: 'ip',
113
headerName: "Address",
114
flex: 1,
115
maxWidth: 400,
121
- valueGetter: ({ row, value }) => (row.v === 6 ? `[${value}]` : value) + ' :' + row.port
116
+ renderCell: ({ row, value }) => (row.v === 6 ? `[${value}]` : value) + ' :' + row.port,
117
+ mergeRender: { other: 'user', fontSize: 'small' },
118
},
119
{
120
field: 'user',
121
headerName: "User",
122
+ hideUnder: 'md',
123
},
124
{
125
field: 'started',
126
headerName: "Started",
127
type: 'dateTime',
131
- width: 130,
128
+ width: 100,
129
+ hideUnder: 'lg',
130
valueFormatter: ({ value }) => new Date(value as string).toLocaleTimeString()
131
},
132
{
@@ -152,58 +150,57 @@ function Connections() {
150
addTitle: row.opTotal && h('div', {}, "Total: " + formatBytes(row.opTotal)),
151
sx: { mr: 1 }
152
}),
155
- value.slice(i + 1),
156
- i > 0 && h(Box, { ml: 2, color: 'text.secondary' }, value.slice(0, i))
153
+ h(Box, {}, value.slice(i + 1),
154
+ i > 0 && h(Box, { ml: 2, fontSize: 'x-small', color: 'text.secondary' }, value.slice(0, i))
155
+ ),
156
)
157
}
158
},
160
- {
161
- field: 'v',
162
- headerName: "Protocol",
163
- align: 'center',
164
- renderCell: ({ value, row }) => h(Fragment, {},
165
- "IPv" + value,
166
- row.secure && iconTooltip(Lock, "HTTPS", { opacity: .5 })
167
- )
168
- },
159
{
160
field: 'outSpeed',
161
headerName: "Speed",
162
+ width: 110,
163
+ hideUnder: 'sm',
164
type: 'number',
173
- renderCell: ({ value, row }) => formatSpeed(Math.max(value||0, row.inSpeed||0))
165
+ renderCell: ({ value, row }) => formatSpeed(Math.max(value||0, row.inSpeed||0)),
166
+ mergeRender: { other: 'sent', fontSize: 'small', textAlign: 'right' }
167
},
168
{
169
field: 'sent',
170
headerName: "Sent",
171
type: 'number',
172
+ hideUnder: 'md',
173
renderCell: ({ value, row}) => formatBytes(Math.max(value||0, row.got||0))
174
},
175
+ {
176
+ field: 'v',
177
+ headerName: "Protocol",
178
+ align: 'center',
179
+ hideUnder: Infinity,
180
+ renderCell: ({ value, row }) => h(Fragment, {},
181
+ "IPv" + value,
182
+ row.secure && iconTooltip(Lock, "HTTPS", { opacity: .5 })
183
+ )
184
+ },
185
{
186
field: 'agent',
187
headerName: "Agent",
188
+ hideUnder: 'lg',
189
},
185
- {
186
- field: "Actions",
187
- width: 80,
188
- align: 'center',
189
- hideSortIcons: true,
190
- disableColumnMenu: true,
191
- renderCell({ row }) {
192
- return h('div', {},
193
- h(IconBtn, {
194
- icon: Delete,
195
- title: "Disconnect",
196
- onClick: () => apiCall('disconnect', _.pick(row, ['ip', 'port'])),
197
- }),
198
- h(IconBtn, {
199
- icon: Block,
200
- title: "Block IP",
201
- disabled: row.ip === props?.you,
202
- onClick: () => blockIp(row.ip),
203
- }),
204
- )
205
- }
206
- }
190
+ ],
191
+ actionsProps: { hideUnder: 'sm' },
192
+ actions: ({ row }) => [
193
+ h(IconBtn, {
194
+ icon: Delete,
195
+ title: "Disconnect",
196
+ onClick: () => apiCall('disconnect', _.pick(row, ['ip', 'port'])),
197
+ }),
198
+ h(IconBtn, {
199
+ icon: Block,
200
+ title: "Block IP",
201
+ disabled: row.ip === props?.you,
202
+ onClick: () => blockIp(row.ip),
203
+ }),
204
]
205
})
206
)
admin/src/OnlinePlugins.ts
+35
-42
@@ -2,7 +2,7 @@
2
3
import { apiCall, useApiList } from './api'
4
import { Fragment, createElement as h, useState } from 'react'
5
-import { DataGrid } from '@mui/x-data-grid'
5
+import { DataTable } from './DataTable'
6
import { IconBtn } from './misc'
7
import { Download, Search } from '@mui/icons-material'
8
import { StringField } from '@hfs/mui-grid-form'
@@ -11,6 +11,7 @@ import { renderName, showError, startPlugin, UpdateButton } from './InstalledPlu
11
import { state, useSnapState } from './state'
12
import _ from 'lodash'
13
import { alertDialog } from './dialog'
14
+import { LinearProgress } from '@mui/material'
15
16
export default function OnlinePlugins() {
17
const [search, setSearch] = useState('')
@@ -27,10 +28,10 @@ export default function OnlinePlugins() {
28
typing: true,
29
label: "Search text"
30
}),
30
- h(DataGrid, {
31
+ h(DataTable, {
32
rows: list.length ? list : [], // workaround for DataGrid bug causing 'no rows' message to be not displayed after 'loading' was also used
33
localeText: { noRowsLabel: "No compatible plugins have been found" },
33
- loading: initializing,
34
+ initializing,
35
columnVisibilityModel: snap.onlinePluginsColumns,
36
onColumnVisibilityModelChange: newModel => Object.assign(state.onlinePluginsColumns, newModel),
37
columns: [
@@ -39,6 +40,7 @@ export default function OnlinePlugins() {
40
headerName: "name",
41
flex: 1,
42
renderCell: renderName,
43
+ mergeRender: { other: 'description', fontSize: 'x-small' },
44
},
45
{
46
field: 'version',
@@ -56,53 +58,44 @@ export default function OnlinePlugins() {
58
{
59
field: 'description',
60
flex: 3,
61
+ hideUnder: 'sm',
62
},
63
{
64
field: 'stargazers_count',
65
width: 50,
66
headerName: "stars",
67
align: 'center',
68
+ hideUnder: 'sm',
69
},
66
- {
67
- field: "actions",
68
- width: 80,
69
- align: 'center',
70
- hideSortIcons: true,
71
- disableColumnMenu: true,
72
- hideable: false,
73
- renderCell({ row }) {
74
- const { id } = row
75
- return h('div', {},
76
- row.update ? h(UpdateButton, {
77
- id,
78
- then() {
79
- updateList(list =>
80
- _.find(list, { id }).update = false )
81
- }
82
- }) : h(IconBtn, {
83
- icon: Download,
84
- title: "Install",
85
- progress: row.downloading,
86
- disabled: row.installed && "Already installed",
87
- tooltipProps: { placement:'bottom-end' }, // workaround problem with horizontal scrolling by moving the tooltip leftward
88
- confirm: "WARNING - Proceed only if you trust this author and this plugin",
89
- async onClick() {
90
- const branch = row.branch || row.default_branch
91
- try {
92
- const res = await apiCall('download_plugin', { id, branch }, { timeout: false })
93
- await startPlugin(res.id)
94
- }
95
- catch(e: any) {
96
- if (e.code !== 424) throw e
97
- const msg = h(Fragment, {}, "This plugin has some dependencies unmet:",
98
- e.data.map((x: any) => h('li', {}, x.repo + ': ' + x.error)) )
99
- return alertDialog(msg, 'error')
100
- }
101
- }
102
- })
103
- )
70
+ ],
71
+ actions: ({ row, id }) => [
72
+ row.update ? h(UpdateButton, {
73
+ id,
74
+ then() {
75
+ updateList(list =>
76
+ _.find(list, { id }).update = false )
77
}
105
- },
78
+ }) : h(IconBtn, {
79
+ icon: Download,
80
+ title: "Install",
81
+ progress: row.downloading,
82
+ disabled: row.installed && "Already installed",
83
+ tooltipProps: { placement:'bottom-end' }, // workaround problem with horizontal scrolling by moving the tooltip leftward
84
+ confirm: "WARNING - Proceed only if you trust this author and this plugin",
85
+ async onClick() {
86
+ const branch = row.branch || row.default_branch
87
+ try {
88
+ const res = await apiCall('download_plugin', { id, branch }, { timeout: false })
89
+ await startPlugin(res.id)
90
+ }
91
+ catch(e: any) {
92
+ if (e.code !== 424) throw e
93
+ const msg = h(Fragment, {}, "This plugin has some dependencies unmet:",
94
+ e.data.map((x: any) => h('li', {}, x.repo + ': ' + x.error)) )
95
+ return alertDialog(msg, 'error')
96
+ }
97
+ }
98
+ })
99
]
100
})
101
)
admin/src/api.ts
+3
-4
@@ -32,7 +32,7 @@ export function useApiEx<T=any>(...args: Parameters<typeof useApi>) {
32
return { data, error, reload, loading, element }
33
}
34
35
-export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=false, map=((x:any)=>x) }={}) {
35
+export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { map=((x:any)=>x) }={}) {
36
const [list, setList] = useStateMounted<T[]>([])
37
const [props, setProps] = useStateMounted<any>(undefined)
38
const [error, setError] = useStateMounted<any>(undefined)
@@ -40,7 +40,7 @@ export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=fal
40
const [loading, setLoading] = useStateMounted(false)
41
const [initializing, setInitializing] = useStateMounted(true)
42
const [reloader, setReloader] = useState(0)
43
- const idRef = useRef(0)
43
+ const idGenerator = useRef(0)
44
useEffect(() => {
45
if (!cmd) return
46
const buffer: T[] = []
@@ -82,8 +82,7 @@ export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=fal
82
return setProps(entry.props)
83
if (entry.add) {
84
const rec = map(entry.add)
85
- if (addId)
86
- rec.id = ++idRef.current
85
+ rec.id ??= idGenerator.current = Math.max(idGenerator.current, Date.now()) + .001
86
buffer.push(rec)
87
apply()
88
return
admin/src/misc.ts
+1
-1
@@ -194,7 +194,7 @@ export function IconProgress({ icon, progress, offset, addTitle, sx }: IconProgr
194
value: (offset || 1e-7) * 100,
195
variant: 'determinate',
196
size: 32,
197
- sx,
197
+ sx: { display: 'flex', ...sx }, // workaround: without this the element is has 0 width when the space is crammy (monitor/file)
198
}),
199
})
200
)