@samitouri / QOSami-HFS / commits / 5942345c

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 )