admin/custom
Massimo Melina committed
Mar 1, 2023 at 15:21 UTC
dc9f150dae5a7e38ca2ae0b6dc141a0bc4f27747
8 files changed
+121
-7
admin/package.json
+1
@@ -20,6 +20,7 @@
20
"react": "^18.2.0",
21
"react-dom": "^18.2.0",
22
"react-router-dom": "^6.2.1",
23
+ "react-simple-code-editor": "^0.13.1",
24
"react-window": "^1.8.6",
25
"tssrp6a": "^3.0.0",
26
"valtio": "^1.2.9",
admin/src/AccountsPage.ts
+2
-2
@@ -4,7 +4,7 @@ import { createElement as h, useState, useEffect, Fragment } from "react"
4
import { apiCall, useApiEx } from './api'
5
import { Alert, Box, Button, Card, CardContent, Grid, List, ListItem, ListItemText, Typography } from '@mui/material'
6
import { Close, Delete, Group, MilitaryTech, Person, PersonAdd, Refresh } from '@mui/icons-material'
7
-import { IconBtn, iconTooltip, newDialog, useBreakpoint } from './misc'
7
+import { IconBtn, iconTooltip, newDialog, reloadBtn, useBreakpoint } from './misc'
8
import { TreeItem, TreeView } from '@mui/lab'
9
import MenuButton from './MenuButton'
10
import AccountForm from './AccountForm'
@@ -103,7 +103,7 @@ export default function AccountsPage() {
103
{ children: "group", onClick: () => setSel('new-group') }
104
]
105
}, "Add"),
106
- h(IconBtn, { icon: Refresh, title: "Reload", onClick: reload }),
106
+ reloadBtn(reload),
107
list?.length! > 0 && h(Typography, { p: 1 }, `${list!.length} account(s)`),
108
) ),
109
h(Grid, { item: true, md: 5 },
admin/src/CustomHtmlPage.ts
new
+73
@@ -0,0 +1,73 @@
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, useEffect, useMemo, useState } from 'react';
4
+import { Field, SelectField } from '@hfs/mui-grid-form'
5
+import { apiCall, useApiEx } from './api'
6
+import { Alert, Box, Link } from '@mui/material'
7
+import Editor from 'react-simple-code-editor'
8
+import { Dict, IconBtn, isCtrlKey, modifiedSx, reloadBtn } from './misc';
9
+import { Save } from '@mui/icons-material'
10
+import _ from 'lodash'
11
+import { useDebounce } from 'usehooks-ts'
12
+import md from './md'
13
+
14
+export default function CustomHtmlPage() {
15
+ const { data, reload } = useApiEx<{ sections: Dict<string> }>('get_custom_html')
16
+ const [sec, setSec] = useState('')
17
+ const [all, setAll] = useState<Dict<string>>({})
18
+ const [saved, setSaved] = useState({})
19
+ useEffect(() => data && setSaved(data?.sections), [data])
20
+ useEffect(() => setAll(saved), [saved])
21
+ const options = useMemo(() => {
22
+ const keys = Object.keys(all)
23
+ if (!keys.includes(sec))
24
+ setSec(keys?.[0] || '')
25
+ return keys.map(x => ({ value: x, label: _.startCase(x) + (all[x]?.trim() ? ' *' : '') }))
26
+ }, [useDebounce(all, 500)])
27
+ const anyChange = useMemo(() => !_.isEqualWith(saved, all, (a,b) => !a && !b || undefined),
28
+ [saved, all])
29
+ return h(Fragment, {},
30
+ h(Alert, { severity: 'info' }, md("Add HTML code to some parts of the Front-end. It's saved to file `custom.html`, that you can edit directly with your editor of choice."), h(Link, { href: "https://github.com/rejetto/hfs/wiki/customization" })),
31
+ h(Box, { display: 'flex', alignItems: 'center', gap: 1, mb: 1 },
32
+ h(SelectField as Field<string>, { label: "Section", value: sec, options, onChange: setSec }),
33
+ reloadBtn(reload),
34
+ h(IconBtn, {
35
+ icon: Save,
36
+ title: "Save\n(ctrl+s)",
37
+ sx: modifiedSx(anyChange),
38
+ onClick: save,
39
+ }),
40
+ ),
41
+ h(Editor, {
42
+ value: all?.[sec] || '',
43
+ onValueChange: (v: string) =>
44
+ setAll(all => ({ ...all, [sec]: v })),
45
+ highlight: escapeHTML,
46
+ onKeyDown: ev => {
47
+ if (isCtrlKey(ev) === 's') {
48
+ save().then()
49
+ ev.preventDefault()
50
+ }
51
+ },
52
+ padding: 10,
53
+ tabSize: 4,
54
+ insertSpaces: true,
55
+ ignoreTabKey: false,
56
+ style: {
57
+ fontFamily: 'ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace',
58
+ fontSize: '1em',
59
+ flex: 1,
60
+ background: '#8881',
61
+ }
62
+ }),
63
+ )
64
+
65
+ function save() {
66
+ return apiCall('set_custom_html', { sections: all }).then(() => setSaved(all))
67
+ }
68
+}
69
+
70
+function escapeHTML(unsafe: string) {
71
+ return unsafe.replace(/[\u0000-\u002F\u003A-\u0040\u005B-\u0060\u007B-\u00FF]/g,
72
+ c => '&#' + ('000' + c.charCodeAt(0)).slice(-4) + ';')
73
+}
\ No newline at end of file
admin/src/MainMenu.ts
+5
-2
@@ -12,6 +12,7 @@ import {
12
Public,
13
Settings,
14
Translate,
15
+ Code,
16
SvgIconComponent
17
} from '@mui/icons-material'
18
import _ from 'lodash'
@@ -26,6 +27,7 @@ import LangPage from './LangPage'
27
import LogsPage from './LogsPage';
28
import PluginsPage from './PluginsPage';
29
import { useApi } from './api'
30
+import CustomHtmlPage from './CustomHtmlPage';
31
32
interface MenuEntry {
33
path: string
@@ -44,6 +46,7 @@ export const mainMenu: MenuEntry[] = [
46
{ path: 'logs', icon: History, comp: LogsPage },
47
{ path: 'language', icon: Translate, comp: LangPage },
48
{ path: 'plugins', icon: Extension, comp: PluginsPage },
49
+ { path: 'html', icon: Code, label: "Custom HTML", comp: CustomHtmlPage },
50
{ path: 'logout', icon: Logout, comp: LogoutPage }
51
]
52
@@ -75,8 +78,8 @@ export default function Menu({ onSelect }: { onSelect: ()=>void }) {
78
style: ({ isActive }) => isActive ? { textDecoration: 'underline' } : {},
79
children: undefined, // shut up ts
80
},
78
- it.icon && h(ListItemIcon, { sx:{ color: 'primary.contrastText' } }, h(it.icon)),
79
- h(ListItemText, { primary: getMenuLabel(it) })
81
+ it.icon && h(ListItemIcon, { sx:{ color: 'primary.contrastText', minWidth: 48 } }, h(it.icon)),
82
+ h(ListItemText, { sx: { whiteSpace: 'nowrap' }, primary: getMenuLabel(it) })
83
) ),
84
h(Box, { sx: { flex: 1, opacity: .7, background: 'url(hfs-logo.svg) no-repeat bottom', backgroundSize: 'contain', margin: 2 } }),
85
)
admin/src/VfsMenuBar.ts
+2
-2
@@ -6,7 +6,7 @@ import { Add, Refresh } from '@mui/icons-material'
6
import { reloadVfs } from './VfsPage'
7
import addFiles, { addVirtual } from './addFiles'
8
import MenuButton from './MenuButton'
9
-import { IconBtn } from './misc'
9
+import { IconBtn, reloadBtn } from './misc'
10
11
export default function VfsMenuBar() {
12
return h(Box, {
@@ -29,6 +29,6 @@ export default function VfsMenuBar() {
29
{ children: "virtual folder", onClick: addVirtual }
30
]
31
}, "Add"),
32
- h(IconBtn, { icon: Refresh, title: "Reload", onClick(){ reloadVfs() } }),
32
+ reloadBtn(() => reloadVfs()),
33
)
34
}
admin/src/misc.ts
+10
-1
@@ -4,7 +4,7 @@ import { createElement as h, FC, ReactNode } from 'react'
4
import { Box, Breakpoint, CircularProgress, IconButton, Link, Tooltip, useMediaQuery } from '@mui/material'
5
import { Link as RouterLink } from 'react-router-dom'
6
import { SxProps } from '@mui/system'
7
-import { SvgIconComponent } from '@mui/icons-material'
7
+import { Refresh, SvgIconComponent } from '@mui/icons-material'
8
import { alertDialog, confirmDialog } from './dialog'
9
import { apiCall } from './api'
10
import { findFirst, onlyTruthy, useStateMounted } from '@hfs/shared'
@@ -131,3 +131,12 @@ export function wantArray<T>(x?: void | T | T[]) {
131
return x == null ? [] : Array.isArray(x) ? x : [x]
132
}
133
134
+export function reloadBtn(onClick: any, props?: any) {
135
+ return h(IconBtn, { icon: Refresh, title: "Reload", onClick, ...props })
136
+}
137
+
138
+const isMac = navigator.platform.match('Mac')
139
+export function isCtrlKey(ev: React.KeyboardEvent) {
140
+ return (ev.ctrlKey || isMac && ev.metaKey) && ev.key
141
+}
142
+
src/adminApis.ts
+15
@@ -31,6 +31,7 @@ import * as readline from 'readline'
31
import { loggers } from './log'
32
import { execFile } from 'child_process'
33
import { promisify } from 'util'
34
+import { customHtmlSections, customHtmlState, saveCustomHtml } from './customHtml'
35
36
export const adminApis: ApiHandlers = {
37
@@ -58,6 +59,20 @@ export const adminApis: ApiHandlers = {
59
60
get_config: getWholeConfig,
61
62
+ get_custom_html() {
63
+ return {
64
+ sections: Object.fromEntries([
65
+ ...customHtmlSections.map(k => [k,'']),
66
+ ...customHtmlState.sections
67
+ ])
68
+ }
69
+ },
70
+
71
+ async set_custom_html({ sections }) {
72
+ await saveCustomHtml(sections)
73
+ return {}
74
+ },
75
+
76
async get_status() {
77
return {
78
started: HFS_STARTED,
src/customHtml.ts
+13
@@ -4,6 +4,11 @@ import { prefix } from './misc'
4
import { customHeader } from './frontEndApis'
5
import { watchLoad } from './watchLoad'
6
import { proxy } from 'valtio'
7
+import Dict = NodeJS.Dict
8
+import { writeFile } from 'fs/promises'
9
+
10
+export const customHtmlSections: ReadonlyArray<string> = ['top', 'bottom', 'beforeHeader', 'afterHeader',
11
+ 'afterMenuBar', 'afterEntryName']
12
13
export const customHtmlState = proxy<{
14
sections: Map<string,string>
@@ -38,3 +43,11 @@ export function getSection(name: string) {
43
return customHtmlState.sections.get(name) || ''
44
}
45
46
+export async function saveCustomHtml(sections: Dict<string>) {
47
+ const text = Object.entries(sections).filter(([k,v]) => v?.trim()).map(([k,v]) => `[${k}]\n${v}\n\n`).join('')
48
+ await writeFile(FILE, text)
49
+ customHtmlState.sections.clear()
50
+ for (const [k,v] of Object.entries(sections))
51
+ if (v)
52
+ customHtmlState.sections.set(k, v)
53
+}
\ No newline at end of file