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) {