admin/lang: force-language
Massimo Melina committed
Apr 4, 2023 at 22:03 UTC
7ee058bbb7dbe1b207a676f570a4754c7177f25e
7 files changed
+124
-55
admin/src/LangPage.ts
+81
-48
@@ -1,65 +1,71 @@
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'
3
+import { createElement as h, Fragment, useEffect, useState } from 'react';
4
+import { apiCall, useApiEx, 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'
8
+import { IconBtn, readFile, selectFiles, useBreakpoint } from './misc'
9
import _ from 'lodash'
10
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 })
15
if (error)
16
return error
17
+ const large = useBreakpoint('md')
18
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 a specific language to load by appending 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
- )
19
+ large && h(Alert, { severity: 'info' }, "Translation is limited to Front-end, it doesn't apply to Admin-panel"),
20
+ large && h(Alert, { severity: 'info' }, "You can force a specific language to load by appending by appending ?lang=CODE to the URL."),
21
+ h(Box, { mt: 1, maxWidth: '40em', flex: 1, display: 'flex', flexDirection: 'column' },
22
+ h(Box, { mb: 1, display: 'flex' },
23
+ h(Button, { variant: 'contained', startIcon: h(Upload), onClick: add }, "Add"),
24
+ h(Box, { flex: 1 }),
25
+ h(ForceLang, { langs: list.map(x => x.code) }),
26
+ ),
27
+ h(DataGrid, {
28
+ loading: connecting,
29
+ rows: list as any,
30
+ hideFooter: true,
31
+ sx: { flex: 1 },
32
+ columns: [
33
+ {
34
+ field: 'code',
35
+ width: 80,
36
+ },
37
+ {
38
+ field: 'version',
39
+ width: 80,
40
+ },
41
+ {
42
+ field: 'author',
43
+ flex: 1,
44
+ },
45
+ {
46
+ field: "actions",
47
+ width: 80,
48
+ align: 'center',
49
+ hideSortIcons: true,
50
+ disableColumnMenu: true,
51
+ renderCell({ row }) {
52
+ return h('div', {},
53
+ h(IconBtn, {
54
+ icon: Delete,
55
+ title: "Delete",
56
+ confirm: "Delete?",
57
+ async onClick() {
58
+ await apiCall('del_lang', _.pick(row, 'code'))
59
+ reload()
60
+ toast("Deleted")
61
+ }
62
+ }),
63
+ )
64
+ }
65
}
60
- }
61
- ]
62
- })
66
+ ]
67
+ })
68
+ )
69
)
70
71
function add() {
@@ -79,3 +85,30 @@ export default function LangPage() {
85
}, { accept: '.json' })
86
}
87
}
88
+
89
+
90
+function ForceLang({ langs }: { langs: string[] }) {
91
+ const K = 'force_lang'
92
+ const { data, reload, loading } = useApiEx('get_config', { only: [K] })
93
+ const [lang, setLang] = useState()
94
+ useEffect(() => setLang(loading ? lang : data[K]), [loading])
95
+ const [saving, setSaving] = useState<string>()
96
+
97
+ return h(SelectField as Field<string>, {
98
+ fullWidth: false,
99
+ disabled: loading || typeof saving === 'string',
100
+ value: saving ?? lang,
101
+ async onChange(v) {
102
+ setSaving(v)
103
+ try {
104
+ await apiCall('set_config', { values: { [K]: v } })
105
+ await reload()
106
+ }
107
+ finally { setSaving(undefined) }
108
+ },
109
+ options: [
110
+ { label: "Respect browser language", value: '' },
111
+ ...langs.map(x => ({ value: x, label: "Force language: " + x }))
112
+ ]
113
+ })
114
+}
admin/src/api.ts
+6
-2
@@ -1,7 +1,7 @@
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, useState } from 'react'
4
-import { Dict, err2msg, Falsy, getCookie, IconBtn, spinner, useStateMounted, wantArray } from './misc'
4
+import { Dict, err2msg, Falsy, getCookie, IconBtn, pendingPromise, spinner, useStateMounted, wantArray } from './misc'
5
import { Alert } from '@mui/material'
6
import _ from 'lodash'
7
import { state } from './state'
@@ -76,6 +76,7 @@ export function useApi<T=any>(cmd: string | Falsy, params?: object) : [T | undef
76
const [err, setErr] = useStateMounted<Error | undefined>(undefined)
77
const [forcer, setForcer] = useStateMounted(0)
78
const loadingRef = useRef<ReturnType<typeof apiCall>>()
79
+ const reloadingRef = useRef<any>()
80
useEffect(()=>{
81
loadingRef.current?.abort()
82
setRet(undefined)
@@ -91,8 +92,11 @@ export function useApi<T=any>(cmd: string | Falsy, params?: object) : [T | undef
92
req.abort()
93
}
94
})
95
+ reloadingRef.current?.resolve(wholePromise)
96
}, [cmd, JSON.stringify(params), forcer]) //eslint-disable-line -- json-ize to detect deep changes
95
- const reload = useCallback(()=> loadingRef.current || setForcer(v => v+1), [setForcer])
97
+ const reload = useCallback(() => loadingRef.current
98
+ || setForcer(v => v+1) || (reloadingRef.current = pendingPromise()),
99
+ [setForcer])
100
return [ret, err, reload]
101
}
102
config.md
+1
@@ -32,6 +32,7 @@ This file contains details the configuration files.
32
- `proxies` number of proxies between server and clients to be trusted about providing clients' IP addresses. Default is 0.
33
- `keep_unfinished_uploads` should unfinished uploads be deleted immediately when interrupted. Default is true.
34
- `favicon` path to file to be used as favicon. Default is none.
35
+- `force_lang` force translation for frontend. Default is none, meaning *let browser decide*.
36
37
#### Virtual File System (VFS)
38
frontend/src/i18n.ts
+3
-2
@@ -1,4 +1,4 @@
1
-import { findFirst, urlParams } from './misc'
1
+import { findFirst, getHFS, urlParams } from './misc'
2
import { createElement as h, Fragment, useEffect } from 'react'
3
import { useApi } from './api'
4
import { proxy, useSnapshot } from 'valtio'
@@ -16,7 +16,8 @@ export function useI18N() {
16
export function I18Nprovider({ embedded='en', ...props }) {
17
const langs = urlParams.lang?.split(',') || navigator.languages
18
state.embedded = embedded
19
- let all = useApi(langs[0] !== embedded && 'load_lang', { embedded, lang: langs }, { noModal: true })
19
+ let all = useApi(!getHFS().lang && langs[0] !== embedded && 'load_lang', { embedded, lang: langs }, { noModal: true })
20
+ ?? getHFS().lang
21
useEffect(() => {
22
if (all instanceof Error)
23
all = undefined
shared/index.ts
+9
-1
@@ -143,4 +143,12 @@ export function wantArray<T>(x?: void | T | T[]) {
143
export function _log(...args: any[]) {
144
console.log('**', ...args)
145
return args[args.length-1]
146
-}
\ No newline at end of file
146
+}
147
+
148
+type PendingPromise<T> = Promise<T> & { resolve: (value: T) => void, reject: (reason?: any) => void }
149
+export function pendingPromise<T>() {
150
+ let takeOut
151
+ const ret = new Promise<T>((resolve, reject) =>
152
+ takeOut = { resolve, reject })
153
+ return Object.assign(ret, takeOut) as PendingPromise<T>
154
+}
src/api.lang.ts
+20
-1
@@ -6,6 +6,8 @@ import glob from 'fast-glob'
6
import { readFile, rm, writeFile } from 'fs/promises'
7
import { HTTP_BAD_REQUEST, HTTP_NOT_ACCEPTABLE, HTTP_SERVER_ERROR } from './const'
8
import { tryJson } from './misc'
9
+import { defineConfig } from './config'
10
+import { watchLoad } from './watchLoad'
11
12
const PREFIX = 'hfs-lang-'
13
const SUFFIX = '.json'
@@ -65,4 +67,21 @@ function code2file(code: string) {
67
function validateCode(code: string) {
68
if (!/^(\w\w)(-\w\w)*$/.test(code))
69
throw new ApiError(HTTP_BAD_REQUEST, 'bad code/filename')
68
-}
\ No newline at end of file
70
+}
71
+
72
+export function getForceLangData() {
73
+ return forceLangData
74
+}
75
+
76
+let forceLangData: any
77
+let undo: any
78
+defineConfig('force_lang', '', v => {
79
+ undo?.()
80
+ forceLangData = undefined
81
+ if (!v) return
82
+ const res = watchLoad(code2file(v), data => {
83
+ forceLangData = { [v]: JSON.parse(data) }
84
+ })
85
+ undo = res.unwatch
86
+})
87
+
src/serveGuiFiles.ts
+4
-1
@@ -23,6 +23,8 @@ import { subscribe } from 'valtio'
23
import { customHtmlState, getSection } from './customHtml'
24
import _ from 'lodash'
25
import { defineConfig } from './config'
26
+import { watchLoad } from './watchLoad'
27
+import { getForceLangData } from './api.lang'
28
29
// in case of dev env we have our static files within the 'dist' folder'
30
const DEV_STATIC = process.env.DEV ? 'dist/' : ''
@@ -105,7 +107,8 @@ async function treatIndex(ctx: Koa.Context, filesUri: string, body: string) {
107
prefixUrl: ctx.state.revProxyPath,
108
customHtml: _.omit(Object.fromEntries(customHtmlState.sections),
109
['top','bottom']), // excluding sections we apply in this phase
108
- fileMenuOnLink: fileMenuOnLink.get()
110
+ fileMenuOnLink: fileMenuOnLink.get(),
111
+ lang: getForceLangData()
112
}, null, 4)
113
.replace(/<(\/script)/g, '<"+"$1') /*avoid breaking our script container*/}
114
document.documentElement.setAttribute('ver', '${VERSION.split('-')[0] /*for style selectors*/}')