frontend: i18n
Massimo Melina committed
Feb 18, 2023 at 20:52 UTC
3d767e897bd32fd198ae1d15587aa9dc0976731e
23 files changed
+329
-151
admin/src/MainMenu.ts
+2
-2
@@ -22,8 +22,8 @@ import AccountsPage from './AccountsPage';
22
import HomePage from './HomePage'
23
import LogoutPage from './LogoutPage';
24
import LogsPage from './LogsPage';
25
-import { useApi } from './api'
25
import PluginsPage from './PluginsPage';
26
+import { useApi } from './api'
27
28
interface MenuEntry {
29
path: string
@@ -34,7 +34,7 @@ interface MenuEntry {
34
}
35
36
export const mainMenu: MenuEntry[] = [
37
- { path: '', icon: Public, label: 'Home', title: "Admin panel", comp: HomePage },
37
+ { path: '', icon: Public, label: "Home", title: "Admin panel", comp: HomePage },
38
{ path: 'fs', icon: AccountTree, label: "Shared files", comp: VfsPage },
39
{ path: 'accounts', icon: ManageAccounts, comp: AccountsPage },
40
{ path: 'configuration', icon: Settings, comp: ConfigPage },
admin/src/misc.ts
+1
-9
@@ -7,7 +7,7 @@ import { SxProps } from '@mui/system'
7
import { SvgIconComponent } from '@mui/icons-material'
8
import { alertDialog, confirmDialog } from './dialog'
9
import { apiCall } from './api'
10
-import { onlyTruthy, useStateMounted } from '@hfs/shared'
10
+import { findFirst, onlyTruthy, useStateMounted } from '@hfs/shared'
11
export * from '@hfs/shared'
12
13
export function spinner() {
@@ -111,14 +111,6 @@ export function pathJoin(...args: any[]) {
111
.join('')
112
}
113
114
-export function findFirst<I=any, O=any>(a: I[], cb:(v:I)=>O): any {
115
- for (const x of a) {
116
- const ret = cb(x)
117
- if (ret !== undefined)
118
- return ret
119
- }
120
-}
121
-
114
export function xlate(input: any, table: Record<string, any>) {
115
return table[input] ?? input
116
}
dev-plugins.md
+1
@@ -156,6 +156,7 @@ Tools are extra data and functions to help you:
156
- `React` whole React object, as for require('react')
157
- `h` shortcut for React.createElement
158
- `state` object with many values in it. [Refer here for details](https://github.com/rejetto/hfs/blob/main/frontend/src/state.ts).
159
+- `t` translator function
160
161
Some frontend-events can return Html, which can be expressed in several ways
162
- as string, containing markup
frontend/package.json
+1
@@ -14,6 +14,7 @@
14
"react": "^18.2.0",
15
"react-dom": "^18.2.0",
16
"react-router-dom": "^6.1.1",
17
+ "talkr": "^3.3.9",
18
"tssrp6a": "^3.0.0",
19
"use-debounce": "^7.0.1",
20
"usehooks-ts": "^2.6.0",
frontend/src/App.ts
+4
-3
@@ -1,24 +1,25 @@
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 { BrowserRouter, Route, Routes } from "react-router-dom"
4
-import { createElement as h, Fragment } from 'react'
4
+import { createElement as h } from 'react'
5
import { BrowseFiles } from "./BrowseFiles"
6
import { Dialogs } from './dialog'
7
import useTheme from "./useTheme"
8
import { useSnapState } from './state'
9
+import { I18Nprovider } from './i18n'
10
11
function App() {
12
useTheme()
13
const { messageOnly } = useSnapState()
14
if (messageOnly)
15
return h('h1', { style: { textAlign: 'center'} }, messageOnly)
15
- return h(Fragment, {},
16
+ return h(I18Nprovider, {},
17
h(BrowserRouter, {},
18
h(Routes, {},
19
h(Route, { path:'*', element: h(BrowseFiles) })
20
),
21
h(Dialogs),
21
- ),
22
+ )
23
)
24
}
25
frontend/src/Breadcrumbs.ts
+4
-2
@@ -5,15 +5,17 @@ import { createElement as h, Fragment, ReactElement } from 'react'
5
import { hIcon } from './misc'
6
import { state } from './state'
7
import { reloadList } from './useFetchList'
8
+import { useI18N } from './i18n'
9
10
export function Breadcrumbs() {
11
const currentPath = useLocation().pathname.slice(1,-1)
12
let prev = ''
13
const parent = currentPath.split('/').slice(0,-1).join('/')+'/'
14
const breadcrumbs = currentPath ? currentPath.split('/').map(x => [prev = prev + x + '/', decodeURIComponent(x)]) : []
15
+ const {t} = useI18N()
16
return h(Fragment, {},
15
- h(Breadcrumb, { label: hIcon('parent', { alt:'parent folder' }), path: parent }),
16
- h(Breadcrumb, { current: !currentPath, label: hIcon('home', { alt:'home' }) }),
17
+ h(Breadcrumb, { label: hIcon('parent', { alt: t`parent folder` }), path: parent }),
18
+ h(Breadcrumb, { current: !currentPath, label: hIcon('home', { alt: t`home` }) }),
19
breadcrumbs.map(([path,label]) =>
20
h(Breadcrumb, {
21
key: path,
frontend/src/BrowseFiles.ts
+11
-7
@@ -20,6 +20,7 @@ import useFetchList from './useFetchList'
20
import useAuthorized from './useAuthorized'
21
import { acceptDropFiles, enqueue } from './upload'
22
import _ from 'lodash'
23
+import { useI18N } from './i18n'
24
25
export function usePath() {
26
return decodeURI(useLocation().pathname)
@@ -92,8 +93,10 @@ function FilesList() {
93
el?.scrollIntoView({ block: 'center' })
94
}, [page, extraPages])
95
95
- const msgInstead = !list.length ? (!loading && (stoppedSearch ? "Stopped before finding anything" : "Nothing here"))
96
- : filteredList && !filteredList.length && "No match for this filter"
96
+ const {t} = useI18N()
97
+
98
+ const msgInstead = !list.length ? (!loading && (stoppedSearch ? t('stopped_before', "Stopped before finding anything") : t('empty_list', "Nothing here")))
99
+ : filteredList && !filteredList.length && t('filter_none', "No match for this filter")
100
101
return h(Fragment, {},
102
h('ul', { ref, className: 'dir', ...acceptDropFiles(can_upload && enqueue) },
@@ -202,20 +205,21 @@ function fixUrl(s:string) {
205
206
207
const EntryProps = memo((entry: DirEntry & { midnight: Date }) => {
205
- const { t, s } = entry
206
- const today = t && t > entry.midnight
208
+ const { t: time, s } = entry
209
+ const today = time && time > entry.midnight
210
const shortTs = isMobile()
211
+ const {t} = useI18N()
212
return h('div', { className: 'entry-props' },
213
useCustomCode('additionalEntryProps', { entry }),
214
h(EntrySize, { s }),
211
- t && h('span', {
215
+ time && h('span', {
216
className: 'entry-ts',
217
title: today || !shortTs ? null : t.toLocaleString(),
218
onClick() { // mobile has no hover
219
if (shortTs)
216
- alertDialog("Full timestamp:\n" + t.toLocaleString()).then()
220
+ alertDialog(t`Full timestamp:` + "\n" + t.toLocaleString()).then()
221
}
218
- }, !shortTs ? t.toLocaleString() : today ? t.toLocaleTimeString() : t.toLocaleDateString()),
222
+ }, !shortTs ? time.toLocaleString() : today ? time.toLocaleTimeString() : time.toLocaleDateString()),
223
)
224
})
225
frontend/src/FilterBar.ts
+5
-3
@@ -2,12 +2,14 @@ import { state, useSnapState } from './state'
2
import { createElement as h, useEffect, useState } from 'react'
3
import { useDebounce } from 'use-debounce'
4
import { Checkbox } from './components'
5
+import { useI18N } from './i18n'
6
7
export function FilterBar() {
8
const { list, filteredList, selected, patternFilter, showFilter } = useSnapState()
9
const [all, setAll] = useState(false)
10
const [filter, setFilter] = useState(patternFilter)
11
useEffect(() => setAll(false), [patternFilter]) // reset on change
12
+ const {t} = useI18N()
13
14
;[state.patternFilter] = useDebounce(showFilter ? filter : '', 300)
15
@@ -33,7 +35,7 @@ export function FilterBar() {
35
}),
36
h('input', {
37
id: 'filter',
36
- placeholder: "Type here to filter the list below",
38
+ placeholder: t('filter_placeholder', "Type here to filter the list below"),
39
autoComplete: 'off',
40
value: filter,
41
autoFocus: true,
@@ -42,8 +44,8 @@ export function FilterBar() {
44
}
45
}),
46
h('span', {}, [
45
- sel && `${sel} selected`,
46
- fil !== undefined && fil < list.length && `${fil} filtered`,
47
+ sel && t('select_count', { n:sel }, "{n} selected"),
48
+ fil !== undefined && fil < list.length && t('filter_count', {n:fil}, "{n} filtered"),
49
].filter(Boolean).join(', ') ),
50
)
51
}
\ No newline at end of file
frontend/src/Head.ts
+11
-9
@@ -1,12 +1,13 @@
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, useMemo } from 'react'
4
-import { formatBytes, hIcon, prefix } from './misc'
4
+import { formatBytes, hIcon } from './misc'
5
import { Spinner, useCustomCode } from './components'
6
import { useSnapState } from './state'
7
import { MenuPanel } from './menu'
8
import { Breadcrumbs } from './Breadcrumbs'
9
import { FilterBar } from './FilterBar'
10
+import { useI18N } from './i18n'
11
12
export function Head() {
13
return h('header', {},
@@ -20,25 +21,26 @@ export function Head() {
21
22
function FolderStats() {
23
const { list, loading, stoppedSearch } = useSnapState()
23
- const stats = useMemo(() =>{
24
+ const { files, folders, size } = useMemo(() => {
25
let files = 0, folders = 0, size = 0
25
- for (const x of list) {
26
- if (x.isFolder)
26
+ for (const { isFolder, s } of list) {
27
+ if (isFolder)
28
++folders
29
else
30
++files
30
- size += x.s||0
31
+ size += s || 0
32
}
33
return { files, folders, size }
34
}, [list])
35
+ const { t } = useI18N()
36
return h(Fragment, {},
37
h('div', { id:'folder-stats' },
36
- stoppedSearch ? hIcon('interrupted', { title:'Search was interrupted' })
38
+ stoppedSearch ? hIcon('interrupted', { title: t`Search was interrupted` })
39
: list.length>0 && loading && h(Spinner),
40
[
39
- prefix('', stats.files,' file(s)'),
40
- prefix('', stats.folders, ' folder(s)'),
41
- stats.size ? formatBytes(stats.size) : '',
41
+ files && t('n_files', { n: files }, '{n,plural,one{# file} other{# files}}'),
42
+ folders && t('n_folders', { n: folders }, '{n,plural,one{# folder} other{# folders}}'),
43
+ size ? formatBytes(size) : '',
44
].filter(Boolean).join(', '),
45
),
46
h('div', { style:{ clear:'both' }}),
frontend/src/UserPanel.ts
+41
-42
@@ -8,51 +8,50 @@ import { apiCall } from './api'
8
import { logout } from './login'
9
import { MenuButton } from './menu'
10
import { hIcon } from './misc'
11
+import { t } from './i18n'
12
13
export default function showUserPanel() {
14
newDialog({
14
- title: "User panel",
15
+ title: t`User panel`,
16
icon: () => hIcon('user'),
16
- Content
17
+ Content() {
18
+ const snap = useSnapState()
19
+ return h('div', { id: 'user-panel' },
20
+ h('div', {}, t`Username`, ': ', snap.username),
21
+ h(MenuButton, {
22
+ icon: 'password',
23
+ label: t`Change password`,
24
+ onClickAnimation: false,
25
+ async onClick() {
26
+ const pwd = await promptDialog(t('enter_pass', "Enter new password"), { type: 'password' })
27
+ if (!pwd) return
28
+ const check = await promptDialog(t('enter_pass2', "RE-enter same new password"), { type: 'password' })
29
+ if (!check) return
30
+ if (check !== pwd)
31
+ return alertDialog(t('pass2_mismatch', "The second password you entered did not match the first. Procedure aborted."), 'warning')
32
+ const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
33
+ const res = await createVerifierAndSalt(srp6aNimbusRoutines, snap.username, pwd)
34
+ try {
35
+ await apiCall('change_srp', { salt: String(res.s), verifier: String(res.v) }).catch(e => {
36
+ if (e.code !== 406) // 406 = server was configured to support clear text authentication
37
+ throw e
38
+ return apiCall('change_password', { newPassword: pwd }) // unencrypted version
39
+ })
40
+ return alertDialog(t`Password changed`)
41
+ }
42
+ catch(e) {
43
+ return alertDialog(e as Error)
44
+ }
45
+ }
46
+ }),
47
+ h(MenuButton, {
48
+ icon: 'logout',
49
+ label: t`Logout`,
50
+ onClick() {
51
+ logout().then(closeDialog, alertDialog)
52
+ }
53
+ })
54
+ )
55
+ }
56
})
57
}
19
-
20
-function Content() {
21
- const snap = useSnapState()
22
- return h('div', { id: 'user-panel' },
23
- h('div', {}, "User: " + snap.username),
24
- h(MenuButton, {
25
- icon: 'password',
26
- label: "Change password",
27
- onClickAnimation: false,
28
- async onClick() {
29
- const pwd = await promptDialog("Enter new password", { type: 'password' })
30
- if (!pwd) return
31
- const check = await promptDialog("RE-enter new password", { type: 'password' })
32
- if (!check) return
33
- if (check !== pwd)
34
- return alertDialog("The second password you entered did not match the first. Procedure aborted.", 'warning')
35
- const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
36
- const res = await createVerifierAndSalt(srp6aNimbusRoutines, snap.username, pwd)
37
- try {
38
- await apiCall('change_srp', { salt: String(res.s), verifier: String(res.v) }).catch(e => {
39
- if (e.code !== 406) // 406 = server was configured to support clear text authentication
40
- throw e
41
- return apiCall('change_password', { newPassword: pwd }) // unencrypted version
42
- })
43
- return alertDialog("Password changed")
44
- }
45
- catch(e) {
46
- return alertDialog(e as Error)
47
- }
48
- }
49
- }),
50
- h(MenuButton, {
51
- icon: 'logout',
52
- label: "Logout",
53
- onClick() {
54
- logout().then(closeDialog, alertDialog)
55
- }
56
- })
57
- )
58
-}
frontend/src/dialog.ts
+5
-4
@@ -5,6 +5,7 @@ import './dialog.css'
5
import { newDialog, closeDialog, DialogOptions, DialogCloser } from '@hfs/shared/dialogs'
6
import _ from 'lodash'
7
import { useInterval } from 'usehooks-ts'
8
+import { t } from './i18n'
9
export * from '@hfs/shared/dialogs'
10
11
interface PromptOptions extends Partial<DialogOptions> { def?:string, type?:string }
@@ -43,7 +44,7 @@ export async function promptDialog(msg: string, { def, type, ...rest }:PromptOpt
44
}
45
}),
46
h('div', { style: { textAlign: 'right', marginTop: '.8em' } },
46
- h('button', { onClick: go }, "Continue")),
47
+ h('button', { onClick: go }, t`Continue`)),
48
)
49
50
function go() {
@@ -61,7 +62,7 @@ export async function alertDialog(msg: ReactElement | string | Error, type:Alert
62
}
63
return new Promise(resolve => newDialog({
64
className: 'dialog-alert dialog-alert-'+type,
64
- title: _.capitalize(type),
65
+ title: t(_.capitalize(type)),
66
icon: '!',
67
onClose: resolve,
68
Content
@@ -114,10 +115,10 @@ export async function confirmDialog(msg: ReactElement | string, options: Confirm
115
h('a', {
116
href,
117
onClick() { closeDialog(true) },
117
- }, h('button', {}, "Confirm", timeoutConfirm && missingText)),
118
+ }, h('button', {}, t`Confirm`, timeoutConfirm && missingText)),
119
h('button', {
120
onClick() { closeDialog(false) },
120
- }, "Don't", !timeoutConfirm && missingText),
121
+ }, t`Don't`, !timeoutConfirm && missingText),
122
afterButtons,
123
)
124
)
frontend/src/i18n.ts
new
+116
@@ -0,0 +1,116 @@
1
+import { findFirst, urlParams } from './misc'
2
+import { createElement as h, Fragment, useEffect } from 'react'
3
+import { useApi } from './api'
4
+import { proxy, useSnapshot } from 'valtio'
5
+import _ from 'lodash'
6
+
7
+const state = proxy<{ langs: string[], embedded: string }>({ embedded: '', langs: [] })
8
+const warns = new Set() // avoid duplicates
9
+let loaded: any // all dictionaries
10
+
11
+// the hook ensures translation is refreshed when language changes
12
+export function useI18N() {
13
+ useSnapshot(state)
14
+ return { t }
15
+}
16
+
17
+// useful for imperative react contexts, that need to be rendered
18
+export function tComponent(...par: Parameters<typeof t>) {
19
+ return () => {
20
+ const {t} = useI18N()
21
+ return h(Fragment, {}, t(...par))
22
+ }
23
+}
24
+
25
+
26
+export function I18Nprovider({ embedded='en', ...props }) {
27
+ const langs = urlParams.lang?.split(',') || navigator.languages
28
+ let all = useApi('load_lang', { lang: langs }, { noModal: true })
29
+ state.embedded = embedded
30
+ useEffect(() => {
31
+ if (all instanceof Error)
32
+ all = undefined
33
+ state.langs = Object.keys(all||{})
34
+ loaded = all
35
+ }, [all])
36
+ return h(Fragment, props)
37
+}
38
+
39
+export function t(keyOrTpl: string | TemplateStringsArray, params?: any, def?: string) {
40
+ // memoize?
41
+ const key = typeof keyOrTpl === 'string' ? keyOrTpl : (def ??= keyOrTpl[0])
42
+ if (typeof params === 'string' && !def) {
43
+ def = params
44
+ params = null
45
+ }
46
+ let found
47
+ let selectedLang = '' // keep track of where we find the translation
48
+ const { langs, embedded } = state
49
+ if (loaded) {
50
+ found = findFirst(langs, lang => loaded[selectedLang=lang]?.translate?.[key])
51
+ if (!found && selectedLang && langs[0] !== embedded && !warns.has(key)) {
52
+ warns.add(key)
53
+ console.debug("miss i18n:", key)
54
+ }
55
+ }
56
+ if (!found) {
57
+ found = def || key
58
+ selectedLang = embedded
59
+ }
60
+ return Array.from(tokenizer(found)).map(([s,inside]) => {
61
+ if (!inside) return s
62
+ const [k,cmd,rest] = s.split(',')
63
+ const v = params[k]
64
+ if (cmd === 'plural')
65
+ return plural(v, rest)
66
+ return v || v === 0 ? v : ''
67
+ }).join('')
68
+
69
+ function plural(v: any, rest: string) {
70
+ const plural = new Intl.PluralRules(selectedLang || embedded).select(Number(v))
71
+ let other = ''
72
+ let pickNext = false
73
+ let collectOther = false
74
+ for (const [s,inside] of tokenizer(rest)) {
75
+ if (pickNext)
76
+ return pick(s)
77
+ if (collectOther) {
78
+ other = s
79
+ collectOther = false
80
+ }
81
+ if (inside) continue
82
+ const selectors = s.trim().split(/\s+/)
83
+ pickNext = selectors.some(sel =>
84
+ sel[0] === '=' && v === Number(sel.slice(1))
85
+ || sel === plural )
86
+ collectOther = !pickNext && selectors.includes('other')
87
+ }
88
+ return pick(other)
89
+
90
+ function pick(s: string) {
91
+ return s.replace('#', String(v))
92
+ }
93
+ }
94
+}
95
+
96
+function* tokenizer(s:string): Generator<[string,boolean]> {
97
+ let ofs = 0
98
+ while (1) {
99
+ const open = s.indexOf('{', ofs)
100
+ if (open < 0) break
101
+ yield [s.slice(ofs, open), false]
102
+ let stack = 1
103
+ ofs = open + 1
104
+ while (stack && ofs < s.length) {
105
+ if (s[ofs] === '{')
106
+ stack++
107
+ else if (s[ofs] === '}')
108
+ stack--
109
+ ofs++
110
+ }
111
+ if (stack)
112
+ return console.debug('tokenizer: unclosed') // invalid, abort
113
+ yield [s.slice(open + 1, ofs-1), true]
114
+ }
115
+ yield [s.slice(ofs), false]
116
+}
\ No newline at end of file
frontend/src/login.ts
+10
-8
@@ -6,6 +6,7 @@ import { alertDialog, newDialog } from './dialog'
6
import { hIcon, srpSequence, working } from './misc'
7
import { useNavigate } from 'react-router-dom'
8
import { createElement as h, useEffect, useRef } from 'react'
9
+import { t, tComponent, useI18N } from './i18n'
10
11
async function login(username:string, password:string) {
12
const stopWorking = working()
@@ -16,10 +17,10 @@ async function login(username:string, password:string) {
17
return res
18
}, (err: any) => {
19
stopWorking()
19
- throw Error(err.message === 'trust' ? "Login aborted: server identity cannot be trusted"
20
- : err.code === 401 ? "Invalid credentials"
21
- : err.code === 409 ? "Cookies not working - login failed"
22
- : err.message)
20
+ throw Error(err.message === 'trust' ? t('login_untrusted', "Login aborted: server identity cannot be trusted")
21
+ : err.code === 401 ? t('login_bad_credentials', "Invalid credentials")
22
+ : err.code === 409 ? t('login_bad_cookies', "Cookies not working - login failed")
23
+ : t(err.message))
24
})
25
}
26
@@ -53,13 +54,14 @@ export async function loginDialog(navigate: ReturnType<typeof useNavigate>) {
54
className: 'dialog-login',
55
icon: () => hIcon('login'),
56
onClose: resolve,
56
- title: "Login",
57
+ title: tComponent("Login"),
58
Content() {
59
const usrRef = useRef<HTMLInputElement>()
60
const pwdRef = useRef<HTMLInputElement>()
61
useEffect(() => {
62
setTimeout(() => usrRef.current?.focus()) // setTimeout workarounds problem due to double-mount while in dev
63
}, [])
64
+ const {t} = useI18N() // this dialog can be displayed before anything else, accessing protected folder, and needs to be rendered after languages loading
65
return h('form', {
66
onSubmit(ev:any) {
67
ev.preventDefault()
@@ -67,7 +69,7 @@ export async function loginDialog(navigate: ReturnType<typeof useNavigate>) {
69
}
70
},
71
h('div', { className: 'field' },
70
- h('label', { htmlFor: 'username' }, "Username"),
72
+ h('label', { htmlFor: 'username' }, t`Username`),
73
h('input', {
74
ref: usrRef,
75
name: 'username',
@@ -77,7 +79,7 @@ export async function loginDialog(navigate: ReturnType<typeof useNavigate>) {
79
}),
80
),
81
h('div', { className: 'field' },
80
- h('label', { htmlFor: 'password' }, "Password"),
82
+ h('label', { htmlFor: 'password' }, t`Password`),
83
h('input', {
84
ref: pwdRef,
85
name: 'password',
@@ -88,7 +90,7 @@ export async function loginDialog(navigate: ReturnType<typeof useNavigate>) {
90
}),
91
),
92
h('div', { style: { textAlign: 'right' } },
91
- h('button', { type: 'submit' }, "Continue")),
93
+ h('button', { type: 'submit' }, t`Continue`)),
94
)
95
96
function onKeyDown(ev: KeyboardEvent) {
frontend/src/menu.ts
+33
-25
@@ -14,6 +14,7 @@ import { showUpload, uploadState } from './upload'
14
import { useSnapshot } from 'valtio'
15
import { apiCall } from './api'
16
import { reloadList } from './useFetchList'
17
+import { t, useI18N } from './i18n'
18
19
export function MenuPanel() {
20
const { showFilter, remoteSearch, stopSearch, stoppedSearch, selected, can_upload, can_delete } = useSnapState()
@@ -23,6 +24,8 @@ export function MenuPanel() {
24
state.selected = {}
25
}, [showFilter])
26
27
+ const {t} = useI18N()
28
+
29
const [started1secAgo, setStarted1secAgo] = useStateMounted(false)
30
useEffect(() => {
31
if (!stopSearch) return
@@ -34,7 +37,7 @@ export function MenuPanel() {
37
useEffect(() => {
38
if (!can_delete || localStorage.warn_can_delete) return
39
localStorage.warn_can_delete = 1
37
- alertDialog("To delete, first click Select").then()
40
+ alertDialog(t('delete_hint', "To delete, first click Select")).then()
41
}, [can_delete])
42
43
// passing files as string in the url should allow 1-2000 items before hitting the url limit of 64KB. Shouldn't be a problem, right?
@@ -52,8 +55,8 @@ export function MenuPanel() {
55
h(MenuButton, {
56
id: 'select-button',
57
icon: 'check',
55
- label: "Select",
56
- tooltip: `Selection applies to "Zip" and "Delete" (when available), but you can also filter the list`,
58
+ label: t`Select`,
59
+ tooltip: t('select_tooltip', 0, `Selection applies to "Zip" and "Delete" (when available), but you can also filter the list`),
60
toggled: showFilter,
61
onClick() {
62
state.showFilter = !showFilter
@@ -62,13 +65,13 @@ export function MenuPanel() {
65
h(MenuButton, changingButton === 'delete' ? {
66
id: 'delete-button',
67
icon: 'trash',
65
- label: "Delete",
68
+ label: t`Delete`,
69
className: 'show-sliding',
70
onClick: () => deleteFiles(Object.keys(selected), pathname)
71
} : changingButton === 'upload' ? {
72
id: 'upload-button',
73
icon: 'upload',
71
- label: "Upload",
74
+ label: t`Upload`,
75
className: 'show-sliding ' + (uploading ? 'ani-working' : ''),
76
onClick: showUpload,
77
} : { icon: '', label: '', className: 'before-sliding' }),
@@ -76,43 +79,44 @@ export function MenuPanel() {
79
h(MenuButton, {
80
id: 'options-button',
81
icon: 'settings',
79
- label: 'Options',
82
+ label: t`Options`,
83
onClick: showOptions
84
}),
85
h(MenuLink, {
86
id: 'zip-button',
87
icon: 'archive',
85
- label: "Zip",
86
- tooltip: list ? "Download selected elements as a single zip file"
87
- : "Download whole list (unfiltered) as a single zip file. If you select some elements, only those will be downloaded.",
88
+ label: t`Zip`,
89
+ tooltip: list ? t('zip_tooltip_selected', "Download selected elements as a single zip file")
90
+ : t('zip_tooltip_whole', "Download whole list (unfiltered) as a single zip file. If you select some elements, only those will be downloaded."),
91
href: '?'+String(new URLSearchParams(_.pickBy({
92
get: 'zip',
93
search: remoteSearch,
94
list
95
}))),
96
...!list && {
94
- confirm: remoteSearch ? 'Download ALL results of this search as ZIP archive?' : 'Download WHOLE folder as ZIP archive?',
97
+ confirm: remoteSearch ? t('zip_confirm_search', "Download ALL results of this search as ZIP archive?")
98
+ : t('zip_confirm_folder', "Download WHOLE folder as ZIP archive?"),
99
confirmOptions: {
100
afterButtons: h('button', {
101
onClick() {
102
state.showFilter = true
103
closeDialog(false)
100
- return alertDialog("Use checkboxes to select the files, then you can use Zip again")
104
+ return alertDialog(t('zip_checkboxes', "Use checkboxes to select the files, then you can use Zip again"))
105
},
102
- }, "Select some files"),
106
+ }, t`Select some files`),
107
}
108
}
109
}),
110
),
111
remoteSearch && h('div', { id: 'searched' },
108
- (stopSearch ? 'Searching' : 'Searched') + ': ' + remoteSearch + prefix(' (', stoppedSearch && 'interrupted', ')')),
112
+ (stopSearch ? t`Searching` : t`Searched`) + ': ' + remoteSearch + prefix(' (', stoppedSearch && t`interrupted`, ')')),
113
)
114
115
function getSearchProps() {
116
return stopSearch && started1secAgo ? {
117
id: 'search-stop-button',
118
icon: 'stop',
115
- label: 'Stop list',
119
+ label: t`Stop list`,
120
className: 'ani-working',
121
onClick() {
122
stopSearch()
@@ -121,17 +125,17 @@ export function MenuPanel() {
125
} : state.remoteSearch && !stopSearch ? {
126
id: 'search-clear-button',
127
icon: 'search_off',
124
- label: 'Clear search',
128
+ label: t`Clear search`,
129
onClick() {
130
state.remoteSearch = ''
131
}
132
} : {
133
id: 'search-button',
134
icon: 'search',
131
- label: "Search",
135
+ label: t`Search`,
136
onClickAnimation: false,
137
async onClick() {
134
- state.remoteSearch = await promptDialog("Search this folder and sub-folders") || ''
138
+ state.remoteSearch = await promptDialog(t('search_msg', 0, `Search this folder and sub-folders`)) || ''
139
}
140
}
141
}
@@ -178,6 +182,7 @@ export function MenuLink({ href, target, confirm, confirmOptions, ...rest }: Men
182
function LoginButton() {
183
const snap = useSnapState()
184
const navigate = useNavigate()
185
+ const {t} = useI18N()
186
return MenuButton(snap.username ? {
187
id: 'user-button',
188
icon: 'user',
@@ -186,7 +191,7 @@ function LoginButton() {
191
} : {
192
id: 'login-button',
193
icon: 'login',
189
- label: 'Login',
194
+ label: t`Login`,
195
onClickAnimation: false,
196
onClick: () => loginDialog(navigate),
197
})
@@ -195,22 +200,25 @@ function LoginButton() {
200
async function deleteFiles(uris: string[], root: string) {
201
const n = uris.length
202
if (!n) {
198
- alertDialog("Select something to delete").then()
203
+ alertDialog(t('delete_select', 0, `Select something to delete`)).then()
204
return
205
}
201
- if (!await confirmDialog(`Delete ${n} item(s)?`)) return
206
+ if (!await confirmDialog(t('delete_confirm', {n}, "Delete {n,plural, one{# item} other{# items}}?"))) return
207
const errors = onlyTruthy(await Promise.all(uris.map(uri =>
208
apiCall('del', { path: root + uri }).then(() => null, err => ({ uri, err }))
209
)))
210
reloadList()
211
const e = errors.length
212
alertDialog(h(Fragment, {},
208
- `Deletion: ${n - e} completed`,
209
- e > 0 && `, ${e} failed`,
213
+ t('delete_completed', {n: n-e}, "Deletion: {n} completed"),
214
+ e > 0 && t('delete_failed', {n:e}, ", {n} failed"),
215
h('div', { style: { textAlign: 'left', marginTop: '1em', } },
211
- ...errors.map(e => h(Fragment, {},
212
- hError(err2msg(e.err) + ': ' + e.uri),
213
- ))
216
+ ...errors.map(e => {
217
+ const msg = err2msg(e.err)
218
+ return h(Fragment, {},
219
+ hError(t(msg) + ': ' + e.uri),
220
+ )
221
+ })
222
)
223
)).then()
224
}
\ No newline at end of file
frontend/src/misc.ts
+2
-1
@@ -6,6 +6,7 @@ import { newDialog } from './dialog'
6
import { Icon } from './icons'
7
import { Dict } from '@hfs/shared'
8
import { state } from './state'
9
+import { t } from './i18n'
10
export * from '@hfs/shared'
11
12
export const ERRORS: Record<number, string> = {
@@ -58,7 +59,7 @@ export function hfsEvent(name: string, params?:Dict) {
59
const HFS: any = (window as any).HFS = {}
60
61
HFS.onEvent = (name: string, cb: (params:any, tools: any, output:any) => any) => {
61
- const tools = { h, React, state }
62
+ const tools = { h, React, state, t }
63
document.addEventListener('hfs.' + name, ev => {
64
const { params, output } = (ev as CustomEvent).detail
65
const res = cb(params, tools, output)
frontend/src/options.ts
+9
-8
@@ -6,11 +6,12 @@ import { createElement as h } from 'react'
6
import { Checkbox, FlexV, Select } from './components'
7
import { hIcon } from './misc'
8
import { MenuLink } from './menu'
9
+import { t } from './i18n'
10
11
export function showOptions (){
12
const options = ['name', 'extension', 'size', 'time']
13
const close = newDialog({
13
- title: "Options",
14
+ title: t`Options`,
15
icon: () => hIcon('settings'),
16
Content
17
})
@@ -20,38 +21,38 @@ export function showOptions (){
21
return h(FlexV, {},
22
snap.adminUrl && h(MenuLink, {
23
icon: 'admin',
23
- label: "Admin-panel",
24
+ label: t`Admin-panel`,
25
href: snap.adminUrl,
26
target: 'admin',
27
}),
27
- h('div', {}, "Sort by"),
28
+ h('div', {}, t`Sort by`),
29
options.map(x => h('button',{
30
key: x,
31
onClick(){
32
close(state.sortBy = x)
33
}
33
- }, x, ' ', snap.sortBy===x && hIcon('check'))),
34
+ }, t(x), ' ', snap.sortBy===x && hIcon('check'))),
35
h(Checkbox, {
36
value: snap.invertOrder,
37
onChange(v) {
38
state.invertOrder = v
39
}
39
- }, "Invert order"),
40
+ }, t`Invert order`),
41
h(Checkbox, {
42
value: snap.foldersFirst,
43
onChange(v) {
44
state.foldersFirst = v
45
}
45
- }, "Folders first"),
46
+ }, t`Folders first`),
47
h(Checkbox, {
48
value: snap.sortNumerics,
49
onChange(v) {
50
state.sortNumerics = v
51
}
51
- }, "Numeric names"),
52
+ }, t`Numeric names`),
53
54
h(Select, {
54
- options: ['', 'light', 'dark'].map(s => ({ label: "theme: " + (s || "auto"), value: s })),
55
+ options: ['', 'light', 'dark'].map(s => ({ label: t`theme:` + ' ' + t(s || "auto"), value: s })),
56
value: snap.theme,
57
onChange(v) {
58
state.theme = v
frontend/src/upload.ts
+23
-21
@@ -10,6 +10,7 @@ import { reloadList } from './useFetchList'
10
import { apiCall, getNotification } from './api'
11
import { useSnapState } from './state'
12
import { Link } from 'react-router-dom'
13
+import { t } from './i18n'
14
15
export const uploadState = proxy<{
16
done: number
@@ -64,7 +65,7 @@ export function showUpload() {
65
})
66
const close = newDialog({
67
dialogProps: { style: { minWidth: 'min(20em, 100vw - 1em)' } },
67
- title: "Upload",
68
+ title: t`Upload`,
69
icon: () => hIcon('upload'),
70
Content,
71
onClose() {
@@ -79,21 +80,22 @@ export function showUpload() {
80
const { qs, done, doneByte, paused, errors, eta } = useSnapshot(uploadState)
81
const { can_upload } = useSnapState()
82
const etaStr = useMemo(() => !eta ? '' : formatTime(eta*1000, 0, 2), [eta])
83
+ const size = formatBytes(files.reduce((a, f) => a + f.size, 0))
84
85
return h(FlexV, { props: acceptDropFiles(x => setFiles([ ...files, ...x ])) },
86
h(FlexV, { position: 'sticky', top: -4, background: 'var(--bg)' },
85
- !can_upload ? "No upload permission for the current folder"
87
+ !can_upload ? t('no_upload_here', "No upload permission for the current folder")
88
: h(Flex, { justifyContent: 'center', flexWrap: 'wrap', },
87
- h('button', { onClick: () => selectFiles() }, "Pick files"),
88
- h('button', { onClick: () => selectFiles(true) }, "Pick folder"),
89
+ h('button', { onClick: () => selectFiles() }, t`Pick files`),
90
+ h('button', { onClick: () => selectFiles(true) }, t`Pick folder`),
91
files.length > 0 && h('button', {
92
onClick() {
93
enqueue(files)
94
setFiles([])
95
}
94
- }, `Send ${files.length} files, ${formatBytes(files.reduce((a, f) => a + f.size, 0))}`),
95
- files.length > 1 && h('button', { onClick() { setFiles([]) } }, "Clear"),
96
- h('button', { onClick: createFolder }, "Create folder"),
96
+ }, t('send_files', { n: files.length, size }, "Send {n,plural,one{# file} other{# files}}, {size}")),
97
+ files.length > 1 && h('button', { onClick() { setFiles([]) } }, t`Clear`),
98
+ h('button', { onClick: createFolder }, t`Create folder`),
99
),
100
),
101
h(FilesList, {
@@ -186,13 +188,13 @@ function formatPerc(p: number) {
188
return (p*100).toFixed(1) + '%'
189
}
190
189
-function formatTime(t: number, decimals=0, length=Infinity) {
190
- t /= 1000
191
- const ret = [(t % 1).toFixed(decimals).slice(1)]
191
+function formatTime(time: number, decimals=0, length=Infinity) {
192
+ time /= 1000
193
+ const ret = [(time % 1).toFixed(decimals).slice(1)]
194
for (const [c,mod,pad] of [['s', 60, 2], ['m', 60, 2], ['h', 24], ['d', 36], ['y', 1 ]] as [string,number,number|undefined][]) {
193
- ret.push( _.padStart(String(t % mod | 0), pad || 0,'0') + c )
194
- t /= mod
195
- if (t < 1) break
195
+ ret.push( _.padStart(String(time % mod | 0), pad || 0,'0') + c )
196
+ time /= mod
197
+ if (time < 1) break
198
}
199
return ret.slice(-length).reverse().join('')
200
}
@@ -245,7 +247,7 @@ async function startUpload(f: File, to: string, resume=0) {
247
if (!resuming)
248
next()
249
}
248
- req.onerror = () => alertDialog("Couldn't upload " + f.name)
250
+ req.onerror = () => alertDialog(t('failed_upload', f, "Couldn't upload {name}"))
251
let lastProgress = 0
252
req.upload.onprogress = (e:any) => {
253
uploadState.partial = e.loaded + resume
@@ -270,7 +272,7 @@ async function startUpload(f: File, to: string, resume=0) {
272
const {expires} = data
273
const timeout = typeof expires !== 'number' ? 0
274
: (Number(new Date(expires)) - Date.now()) / 1000
273
- const msg = `Resume upload? (${formatPerc(size/f.size)} = ${formatBytes(size)})`
275
+ const msg = t('confirm_resume', "Resume upload?") + ` (${formatPerc(size/f.size)} = ${formatBytes(size)})`
276
if (!await confirmDialog(msg, { timeout, getClose: x => closeResumeDialog=x })) return
277
if (uploading !== uploadState.uploading) return // too late
278
resuming = true
@@ -290,10 +292,10 @@ async function startUpload(f: File, to: string, resume=0) {
292
function error(status: number) {
293
if (uploadState.errors++) return
294
const ERRORS = {
293
- 413: "file too large",
295
+ 413: t`file too large`,
296
}
297
const specifier = (ERRORS as any)[status]
296
- alertDialog("Upload error" + prefix(': ', specifier), 'error').then()
298
+ alertDialog(t`Upload error` + prefix(': ', specifier), 'error').then()
299
}
300
301
function done() {
@@ -335,7 +337,7 @@ export function acceptDropFiles(cb: false | undefined | ((files:File[]) => void)
337
}
338
339
async function createFolder() {
338
- const name = await promptDialog("Enter folder name")
340
+ const name = await promptDialog(t`Enter folder name`)
341
if (!name) return
342
const path = location.pathname
343
try {
@@ -343,14 +345,14 @@ async function createFolder() {
345
reloadList()
346
return alertDialog(h(() =>
347
h(FlexV, {},
346
- h('div', {}, "Successfully created"),
348
+ h('div', {}, t`Successfully created`),
349
h(Link, { to: path + name + '/', onClick() {
350
closeDialog()
351
closeDialog()
350
- } }, "Enter the folder"),
352
+ } }, t('enter_folder', "Enter the folder")),
353
)))
354
}
355
catch(e: any) {
354
- await alertDialog(e.code === 409 ? "Folder with same name already exists" : e)
356
+ await alertDialog(e.code === 409 ? t('folder_exists', "Folder with same name already exists") : e)
357
}
358
}
\ No newline at end of file
frontend/src/useFetchList.ts
+4
-3
@@ -9,6 +9,7 @@ import { subscribeKey } from 'valtio/utils'
9
import { useIsMounted } from 'usehooks-ts'
10
import { alertDialog } from './dialog'
11
import { ERRORS } from './misc'
12
+import { t } from './i18n'
13
14
const API = 'file_list'
15
@@ -57,7 +58,7 @@ export default function useFetchList() {
58
switch (type) {
59
case 'error':
60
state.stopSearch?.()
60
- state.error = "connection error"
61
+ state.error = t`connection error`
62
lastReq.current = null
63
return
64
case 'closed':
@@ -76,7 +77,7 @@ export default function useFetchList() {
77
return buffer.push(entry.add)
78
const { error } = entry
79
if (error === 405) { // "method not allowed" happens when we try to directly access an unauthorized file, and we get a login prompt, and then file_list the file (because we didn't know it was file or folder)
79
- state.messageOnly = "Your download should now start"
80
+ state.messageOnly = t('upload_starting', "Your download should now start")
81
window.location.reload() // reload will start the download, because now we got authenticated
82
return
83
}
@@ -84,7 +85,7 @@ export default function useFetchList() {
85
state.stopSearch?.()
86
state.error = (ERRORS as any)[error] || String(error)
87
if (error === 401)
87
- await alertDialog("This account has no access, try another", 'warning')
88
+ await alertDialog(t('wrong_account', "This account has no access, try another"), 'warning')
89
state.loginRequired = error === 401
90
lastReq.current = null
91
return
package-lock.json
+12
@@ -109,6 +109,7 @@
109
"react": "^18.2.0",
110
"react-dom": "^18.2.0",
111
"react-router-dom": "^6.1.1",
112
+ "talkr": "^3.3.9",
113
"tssrp6a": "^3.0.0",
114
"use-debounce": "^7.0.1",
115
"usehooks-ts": "^2.6.0",
@@ -6318,6 +6319,11 @@
6319
"url": "https://github.com/sponsors/ljharb"
6320
}
6321
},
6322
+ "node_modules/talkr": {
6323
+ "version": "3.3.9",
6324
+ "resolved": "https://registry.npmjs.org/talkr/-/talkr-3.3.9.tgz",
6325
+ "integrity": "sha512-4HsZBTf27hK122KHRl4jmOu+Il/cOGEISj0YhXwNCp8qV28WysPAyYT0gO1fzCzKDL5kfh890cIevvFeTADqtw=="
6326
+ },
6327
"node_modules/tar-fs": {
6328
"version": "2.1.1",
6329
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",
@@ -7939,6 +7945,7 @@
7945
"react-dom": "^18.2.0",
7946
"react-router-dom": "^6.1.1",
7947
"sass": "^1.54.5",
7948
+ "talkr": "*",
7949
"tssrp6a": "^3.0.0",
7950
"use-debounce": "^7.0.1",
7951
"usehooks-ts": "^2.6.0",
@@ -11722,6 +11729,11 @@
11729
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
11730
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="
11731
},
11732
+ "talkr": {
11733
+ "version": "3.3.9",
11734
+ "resolved": "https://registry.npmjs.org/talkr/-/talkr-3.3.9.tgz",
11735
+ "integrity": "sha512-4HsZBTf27hK122KHRl4jmOu+Il/cOGEISj0YhXwNCp8qV28WysPAyYT0gO1fzCzKDL5kfh890cIevvFeTADqtw=="
11736
+ },
11737
"tar-fs": {
11738
"version": "2.1.1",
11739
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",
plugins/download-counter/public/main.js
+2
-2
@@ -1,2 +1,2 @@
1
-HFS.onEvent('additionalEntryProps', ({ entry: { hits } }) =>
2
- hits && '<span class="download-counter">' + hits + '</span>')
1
+HFS.onEvent('additionalEntryProps', ({ entry: { hits } }, { t }) =>
2
+ hits && '<span class="download-counter">' + t`Hits: ` + hits + '</span>')
plugins/download-counter/public/style.css
-1
@@ -1,2 +1 @@
1
-.download-counter::before { content: "Hits: " }
1
.download-counter::after { content: " — " }
shared/index.ts
+11
@@ -9,6 +9,8 @@ export type Dict<T=any> = Record<string, T>
9
export type Falsy = false | null | undefined | '' | 0
10
type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T
11
12
+export const urlParams = Object.fromEntries(new URLSearchParams(window.location.search).entries())
13
+
14
const MULTIPLIERS = ['', 'K', 'M', 'G', 'T']
15
export function formatBytes(n: number, { post='B', k=1024, digits=NaN }={}) {
16
if (isNaN(Number(n)) || n < 0)
@@ -89,3 +91,12 @@ export function domOn<K extends keyof WindowEventMap>(eventName: K, cb: (ev: Win
91
target.addEventListener(eventName, cb)
92
return () => target.removeEventListener(eventName, cb)
93
}
94
+
95
+
96
+export function findFirst<I=any, O=any>(a: I[], cb:(v:I)=>O): any {
97
+ for (const x of a) {
98
+ const ret = cb(x)
99
+ if (ret !== undefined)
100
+ return ret
101
+ }
102
+}
\ No newline at end of file
src/frontEndApis.ts
+21
-1
@@ -16,8 +16,9 @@ import {
16
HTTP_UNAUTHORIZED
17
} from './const'
18
import { hasPermission, urlToNode } from './vfs'
19
-import { mkdir, rm } from 'fs/promises'
19
+import { mkdir, readFile, rm } from 'fs/promises'
20
import { join } from 'path'
21
+import { wantArray } from './misc'
22
23
const customHeader = defineConfig('custom_header')
24
@@ -77,6 +78,25 @@ export const frontEndApis: ApiHandlers = {
78
}
79
},
80
81
+ async load_lang({ lang }) {
82
+ const ret: any = {}
83
+ const langs = wantArray(lang)
84
+ const tried: string[] = []
85
+ for (let k of langs) {
86
+ k = k.toLowerCase()
87
+ while (1) {
88
+ if (tried.includes(k)) break
89
+ tried.push(k)
90
+ try { ret[k] = JSON.parse(await readFile(`hfs-lang-${k}.json`, 'utf8')) }
91
+ catch {}
92
+ const i = k.lastIndexOf('-')
93
+ if (ret[k] || i < 0) break
94
+ k = k.slice(0, i)
95
+ }
96
+ }
97
+ return ret
98
+ }
99
+
100
}
101
102
export function notifyClient(ctx: Koa.Context, name: string, data: any) {