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*/}')