admin/lang
Massimo Melina committed
Feb 20, 2023 at 10:58 UTC
f1138a962c7c6a4c2da73ac3c3fc34750d4e8707
8 files changed
+192
-16
README.md
+10
@@ -42,6 +42,7 @@ You won't find all previous features here (yet), but still we got:
42
- log file
43
- speed throttler
44
- admin web interface
45
+- multi-language front-end
46
- virtual hosting (plug-in)
47
- anti-brute-force (plug-in)
48
@@ -81,6 +82,15 @@ If you want to run HFS as a service
82
- run `npm -g update hfs`
83
- run the service installation again
84
85
+## Internationalization
86
+
87
+It is possible to show the Front-end in other languages.
88
+In the Languages section of the Admin-panel you'll be able to install lang files.
89
+You can find some of these files at https://github.com/rejetto/hfs/tree/main/langs
90
+To download a file: open it, right-click on the "Raw" button, Save.
91
+
92
+Files must be named `hfs-lang-CODE.json` (lowercase), where `CODE` is the ISO code for your language (e.g. pt-br for Brazilian).
93
+
94
## Plug-ins
95
96
To install a plugin you just copy its folder inside `plugins` folder.
admin/src/LangPage.ts
new
+76
@@ -0,0 +1,76 @@
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 } from 'react';
4
+import { apiCall, useApiList } from './api'
5
+import { DataGrid } from '@mui/x-data-grid'
6
+import { Alert, Box, Button } from '@mui/material'
7
+import { Delete, Upload } from '@mui/icons-material'
8
+import { IconBtn, readFile, selectFiles } from './misc'
9
+import _ from 'lodash'
10
+import { toast } from './dialog'
11
+
12
+export default function LangPage() {
13
+ const { list, error, connecting, reload } = useApiList('get_langs', undefined, { addId: true })
14
+ if (error)
15
+ return error
16
+ return h(Fragment, {},
17
+ h(Alert, { severity: 'info' }, "Translation is limited to Front-end, it doesn't apply to Admin-panel"),
18
+ h(Alert, { severity: 'info' }, "The front-end will automatically apply translation based on the language of the browser. You can force loading of a specific language by appending ?lang=CODE to the URL."),
19
+ h(Box, { mb: 1 },
20
+ h(Button, { variant: 'contained', startIcon: h(Upload), onClick: add }, "Add"),
21
+ ),
22
+ h(DataGrid, {
23
+ loading: connecting,
24
+ rows: list as any,
25
+ hideFooter: true,
26
+ sx: { maxWidth: '40em' },
27
+ columns: [
28
+ {
29
+ field: 'code',
30
+ width: 80,
31
+ },
32
+ {
33
+ field: 'version',
34
+ width: 80,
35
+ },
36
+ {
37
+ field: 'author',
38
+ flex: 1,
39
+ },
40
+ {
41
+ field: "actions",
42
+ width: 80,
43
+ align: 'center',
44
+ hideSortIcons: true,
45
+ disableColumnMenu: true,
46
+ renderCell({ row }) {
47
+ return h('div', {},
48
+ h(IconBtn, {
49
+ icon: Delete,
50
+ title: "Delete",
51
+ confirm: "Delete?",
52
+ async onClick() {
53
+ await apiCall('del_lang', _.pick(row, 'code'))
54
+ reload()
55
+ toast("Deleted")
56
+ }
57
+ }),
58
+ )
59
+ }
60
+ }
61
+ ]
62
+ })
63
+ )
64
+
65
+ function add() {
66
+ selectFiles(async list => {
67
+ if (!list) return
68
+ const langs: any = {}
69
+ for (const f of list)
70
+ langs[f.name] = await readFile(f)
71
+ await apiCall('add_langs', { langs })
72
+ reload()
73
+ toast("Loaded")
74
+ })
75
+ }
76
+}
admin/src/MainMenu.ts
+3
@@ -11,6 +11,7 @@ import {
11
Monitor,
12
Public,
13
Settings,
14
+ Translate,
15
SvgIconComponent
16
} from '@mui/icons-material'
17
import _ from 'lodash'
@@ -21,6 +22,7 @@ import VfsPage from './VfsPage';
22
import AccountsPage from './AccountsPage';
23
import HomePage from './HomePage'
24
import LogoutPage from './LogoutPage';
25
+import LangPage from './LangPage'
26
import LogsPage from './LogsPage';
27
import PluginsPage from './PluginsPage';
28
import { useApi } from './api'
@@ -40,6 +42,7 @@ export const mainMenu: MenuEntry[] = [
42
{ path: 'configuration', icon: Settings, comp: ConfigPage },
43
{ path: 'monitoring', icon: Monitor, comp: MonitorPage },
44
{ path: 'logs', icon: History, comp: LogsPage },
45
+ { path: 'language', icon: Translate, comp: LangPage },
46
{ path: 'plugins', icon: Extension, comp: PluginsPage },
47
{ path: 'logout', icon: Logout, comp: LogoutPage }
48
]
admin/src/api.ts
+8
-3
@@ -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, useCallback, useEffect, useMemo, useRef } from 'react'
3
+import { createElement as h, useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
import { Dict, err2msg, Falsy, getCookie, IconBtn, spinner, useStateMounted, wantArray } from './misc'
5
import { Alert } from '@mui/material'
6
import _ from 'lodash'
@@ -165,6 +165,7 @@ export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=fal
165
const [connecting, setConnecting] = useStateMounted(true)
166
const [loading, setLoading] = useStateMounted(false)
167
const [initializing, setInitializing] = useStateMounted(true)
168
+ const [reloader, setReloader] = useState(0)
169
const idRef = useRef(0)
170
useEffect(() => {
171
if (!cmd) return
@@ -257,8 +258,12 @@ export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=fal
258
setLoading(false)
259
apply.flush()
260
}
260
- }, [cmd, JSON.stringify(params)]) //eslint-disable-line
261
- return { list, props, loading, error, initializing, connecting, setList, updateList }
261
+ }, [reloader, cmd, JSON.stringify(params)]) //eslint-disable-line
262
+ return { list, props, loading, error, initializing, connecting, setList, updateList, reload }
263
+
264
+ function reload() {
265
+ setReloader(x => x + 1)
266
+ }
267
268
function updateList(cb: (toModify: Draft<typeof list>) => void) {
269
setList(produce(list, x => {
frontend/src/upload.ts
+5
-13
@@ -2,7 +2,7 @@
2
3
import { createElement as h, useMemo, useState } from 'react'
4
import { Flex, FlexV } from './components'
5
-import { closeDialog, DialogCloser, formatBytes, hIcon, newDialog, prefix } from './misc'
5
+import { closeDialog, DialogCloser, formatBytes, hIcon, newDialog, prefix, selectFiles } from './misc'
6
import _ from 'lodash'
7
import { proxy, ref, subscribe, useSnapshot } from 'valtio'
8
import { alertDialog, confirmDialog, promptDialog } from './dialog'
@@ -86,8 +86,8 @@ export function showUpload() {
86
h(FlexV, { position: 'sticky', top: -4, background: 'var(--bg)' },
87
!can_upload ? t('no_upload_here', "No upload permission for the current folder")
88
: h(Flex, { justifyContent: 'center', flexWrap: 'wrap', },
89
- h('button', { onClick: () => selectFiles() }, t`Pick files`),
90
- h('button', { onClick: () => selectFiles(true) }, t`Pick folder`),
89
+ h('button', { onClick: () => pickFiles() }, t`Pick files`),
90
+ h('button', { onClick: () => pickFiles(true) }, t`Pick folder`),
91
files.length > 0 && h('button', {
92
onClick() {
93
enqueue(files)
@@ -134,16 +134,8 @@ export function showUpload() {
134
)
135
)
136
137
- function selectFiles(folder=false) {
138
- const el = Object.assign(document.createElement('input'), {
139
- type: 'file',
140
- name: 'file',
141
- multiple: true,
142
- webkitdirectory: folder,
143
- })
144
- el.addEventListener('change', () =>
145
- setFiles([ ...files, ...el.files ||[] ] ))
146
- el.click()
137
+ function pickFiles(folder=false) {
138
+ selectFiles(list => setFiles([ ...files, ...list ||[] ] ), { folder })
139
}
140
}
141
shared/index.ts
+24
@@ -99,4 +99,28 @@ export function findFirst<I=any, O=any>(a: I[], cb:(v:I)=>O): any {
99
if (ret !== undefined)
100
return ret
101
}
102
+}
103
+
104
+export function selectFiles(cb: (list: FileList | null)=>void, { multiple=true, folder=false }={}) {
105
+ const el = Object.assign(document.createElement('input'), {
106
+ type: 'file',
107
+ name: 'file',
108
+ multiple: multiple,
109
+ webkitdirectory: folder,
110
+ })
111
+ el.addEventListener('change', () =>
112
+ cb(el.files))
113
+ el.click()
114
+}
115
+
116
+export function readFile(f: File) {
117
+ return new Promise((resolve, reject) => {
118
+ const reader = new FileReader()
119
+ reader.addEventListener('load', (event) => {
120
+ if (!event.target)
121
+ return reject()
122
+ resolve(event.target.result)
123
+ })
124
+ reader.readAsText(f)
125
+ })
126
}
\ No newline at end of file
src/adminApis.ts
+2
@@ -19,6 +19,7 @@ import vfsApis from './api.vfs'
19
import accountsApis from './api.accounts'
20
import pluginsApis from './api.plugins'
21
import monitorApis from './api.monitor'
22
+import langApis from './api.lang'
23
import { getConnections } from './connections'
24
import { debounceAsync, isLocalHost, onOff, wait } from './misc'
25
import _ from 'lodash'
@@ -39,6 +40,7 @@ export const adminApis: ApiHandlers = {
40
...accountsApis,
41
...pluginsApis,
42
...monitorApis,
43
+ ...langApis,
44
45
async set_config({ values: v }) {
46
if (v) {
src/api.lang.ts
new
+64
@@ -0,0 +1,64 @@
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 { ApiError, ApiHandlers, SendListReadable } from './apiMiddleware'
4
+import _ from 'lodash'
5
+import glob from 'fast-glob'
6
+import { readFile, rm, writeFile } from 'fs/promises'
7
+import { dirTraversal, isValidFileName } from './util-files'
8
+import { HTTP_BAD_REQUEST, HTTP_SERVER_ERROR } from './const'
9
+
10
+const PREFIX = 'hfs-lang-'
11
+const SUFFIX = '.json'
12
+
13
+const apis: ApiHandlers = {
14
+
15
+ get_langs() {
16
+ return new SendListReadable({
17
+ doAtStart: async list => {
18
+ for await (let name of glob.stream(code2file('*'))) {
19
+ name = String(name)
20
+ const code = name.slice(PREFIX.length, -SUFFIX.length)
21
+ try {
22
+ const data = JSON.parse(await readFile(name, 'utf8'))
23
+ list.add({ code, ..._.omit(data, 'translate') })
24
+ }
25
+ catch {}
26
+ }
27
+ list.close()
28
+ }
29
+ })
30
+ },
31
+
32
+ async del_lang({ code }) {
33
+ validateCode(code)
34
+ try {
35
+ await rm(code2file(code))
36
+ return {}
37
+ }
38
+ catch (e: any) {
39
+ return new ApiError(e.code || HTTP_SERVER_ERROR, e)
40
+ }
41
+ },
42
+
43
+ async add_langs({ langs }) {
44
+ for (let [code, content] of Object.entries(langs)) {
45
+ if (code.endsWith(SUFFIX)) // filename, actually
46
+ code = code.slice(PREFIX.length, -SUFFIX.length)
47
+ validateCode(code)
48
+ await writeFile(code2file(code), String(content), 'utf8')
49
+ }
50
+ return {}
51
+ }
52
+
53
+}
54
+
55
+export default apis
56
+
57
+function code2file(code: string) {
58
+ return PREFIX + code.toLowerCase() + SUFFIX
59
+}
60
+
61
+function validateCode(code: string) {
62
+ if (!isValidFileName(code) || dirTraversal(code))
63
+ throw new ApiError(HTTP_BAD_REQUEST, 'bad code')
64
+}
\ No newline at end of file