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