diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/i18n.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 509 |
1 files changed, 123 insertions, 386 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index affa7c8..48408d9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -1,422 +1,159 @@ /** * MeshBay i18n — lightweight string localization. * + * Catalogues live one per language in `locales/` and are fetched on demand, so + * a visitor downloads the language they read plus English as a fallback, never + * the other eight. + * * Usage: - * import { t, setLocale, getLocale, LOCALES } from './i18n.js'; + * import { t, initLocale, setLocale, getLocale, LOCALES } from './i18n.js'; + * await initLocale(); // once, before the first render * t('nav.logout') // "Logout" * t('status.files', { n: 42 }) // "42 files" + * + * A catalogue value is a string, or an object keyed by Intl.PluralRules + * category when it is rendered with a count: + * 'status.files': { one: '{n} file', other: '{n} files' } + * Polish needs `one`/`few`/`many`/`other` where English needs two, which is why + * the plural category is chosen here rather than baked into the string. */ const LANG_KEY = 'mb_lang'; +const FALLBACK = 'en'; -// ── English strings ───────────────────────────────────────────────────────── - -const en = { - // Nav - 'nav.toggle_menu': 'Toggle menu', - 'nav.light_mode': 'Light mode', - 'nav.dark_mode': 'Dark mode', - 'nav.logout': 'Logout', - 'nav.login': 'Login', - - // Sidebar - 'sidebar.my_groups': 'My Groups', - 'sidebar.no_groups': 'No groups yet', - 'sidebar.discover': 'Discover', - 'sidebar.public_groups': 'Public groups', - - // Login - 'login.title': 'Login', - 'login.username': 'Username', - 'login.password': 'Password', - 'login.submit': 'Login', - 'login.loading': 'Logging in...', - 'login.no_account': 'No account?', - 'login.register_link': 'Register', - - // Register - 'register.title': 'Register', - 'register.username': 'Username', - 'register.email': 'Email', - 'register.password': 'Passphrase (min 12 chars)', - 'register.confirm': 'Confirm password', - 'register.submit': 'Register', - 'register.loading': 'Creating account...', - 'register.has_account': 'Already have an account?', - 'register.login_link': 'Login', - 'register.success_title': 'Account created', - 'register.success_msg': 'You can now log in with your credentials.', - 'register.go_login': 'Go to login', - 'register.err_mismatch': 'Passwords do not match', - 'register.err_min_len': 'Use at least {n} characters', - 'register.err_too_weak': 'Too easy to guess. Your passphrase is what protects ' - + 'your keys where they are stored — a few unrelated words work well.', - 'register.strength': 'Strength: about {bits} bits. This protects the copy of ' - + 'your keys kept on the nodes you join, so it is worth getting right.', - - // Home - 'home.welcome': 'Welcome to MeshBay', - 'home.no_groups': 'You are not a member of any group yet.', - 'home.browse_prefix': 'Browse ', - 'home.browse_link': 'public groups', - 'home.browse_suffix': ' or ask a group admin to invite you.', - 'home.my_groups': 'My Groups', - - // Explore - 'explore.title': 'Public Groups', - 'explore.search': 'Search groups...', - 'explore.loading': 'Loading...', - 'explore.empty': 'No public groups available.', - 'explore.create_group': 'Create group', - 'explore.join': 'Join', - 'explore.member': 'Joined', - - // Group page - 'group.default_name': 'Group', - 'group.tab_files': 'Files', - 'group.tab_chat': 'Chat', - 'group.tab_members': 'Members', - 'group.filter': 'Filter files...', - 'group.col_name': 'Name', - 'group.col_size': 'Size', - 'group.col_type': 'Type', - 'group.col_date': 'Date', - 'group.empty_filter': 'No files match the filter', - 'group.empty_dir': 'This directory is empty', - 'group.download': 'Download', - 'group.play': 'Play', - 'group.offline_title': 'No nodes are currently online for this group.', - 'group.offline_hint': 'Files will appear when a node hosting this group connects.', - 'group.upload': 'Upload', - 'group.mkdir': 'New folder', - 'group.mkdir_prompt': 'Name of the new folder:', - 'group.view': 'View', - 'group.delete': 'Delete', - 'group.delete_confirm': 'Delete {name}?', - 'group.delete_group': 'Delete group', - 'group.delete_group_confirm': 'Permanently delete "{name}" and remove all members? Files on the node are preserved.', - 'group.err_transport': 'Transport module not loaded', - - // Status - 'status.idle': 'Idle', - 'status.discovering': 'Finding nodes...', - 'status.connecting': 'Connecting via WebRTC...', - 'status.fetching': 'Fetching index...', - 'status.files': '{n} files', - 'status.offline': 'No nodes online', - 'status.error': 'Connection failed', - - // Chat - 'chat.empty': 'No messages yet. Start the conversation!', - 'chat.placeholder': 'Type a message...', - 'chat.send': 'Send', - 'chat.attach': 'Attach file', - - // Video player - 'video.loading': 'Loading {name}...', - 'video.buffering': 'Buffering...', - 'video.close': 'Close (Esc)', - 'preview.pdf_fallback': 'This browser will not display the PDF inline. Download it instead — it was decrypted here either way.', - 'group.upload_indexing': 'indexing…', - 'video.err_transport': 'Transport not connected', - 'video.err_mse': 'Codec not supported for streaming: {codec}', - - // Settings - 'settings.title': 'Settings', - 'settings.coming_soon': 'Coming soon.', - 'settings.downloads': 'Downloads', - 'settings.dl_auto': 'Save automatically', - 'settings.dl_auto_hint': 'Files go straight into the folder you choose below, ' - + 'with no dialog. Downloading twenty files puts twenty files there.', - 'settings.dl_ask': 'Ask every time', - 'settings.dl_ask_hint': 'A Save As dialog for each file — one per file, ' - + 'including when you download a selection.', - 'settings.dl_folder': 'Folder: {name}', - 'settings.dl_no_folder': 'No folder chosen — downloads go wherever your browser ' - + 'puts them', - 'settings.dl_choose': 'Choose folder', - 'settings.dl_change': 'Change', - 'settings.dl_forget': 'Forget', - 'settings.dl_path_note': 'A web page cannot be given a path, so there is nothing ' - + 'to type here: your browser grants access to the folder you pick, and MeshBay ' - + 'only ever writes inside it. You may be asked to confirm once per session.', - 'settings.dl_unsupported': 'This browser cannot write into a folder of your ' - + 'choosing (no File System Access API), so downloads go to its own download ' - + 'folder. Chrome and Edge support choosing one.', - 'settings.profile': 'Profile', - 'settings.username': 'Username', - 'settings.node_pins': 'Node identities', - 'settings.node_pins_hint': "Each node's identity key is remembered the first time you connect. If it changes, the connection is refused — that is expected only when an operator reinstalls a node. Verify with them before clearing.", - 'settings.node_pins_count': '{n} pinned', - 'settings.node_pins_clear': 'Clear pinned identities', - 'settings.danger': 'Delete account', - 'settings.delete_hint': 'Removes your account, your group memberships and your ' - + 'notifications from the hub, and frees your username. It cannot reach what ' - + 'lives on nodes: files you uploaded stay where their operator keeps them, ' - + 'and each node keeps the identity it pinned for you until its operator ' - + 'unpins it. Connection logs are kept for a year, as the law requires.', - 'settings.delete_account': 'Delete my account', - 'settings.delete_confirm': 'Enter your passphrase to confirm. This cannot be undone.', - 'settings.delete_confirm_btn': 'Delete for good', - 'settings.cancel': 'Cancel', - 'settings.appearance': 'Appearance', - 'settings.theme': 'Theme', - 'settings.theme_light': 'Light', - 'settings.theme_dark': 'Dark', - 'settings.theme_system': 'System', - 'settings.language': 'Language', - 'settings.about': 'About', - 'settings.version': 'Version', - 'settings.protocol': 'Protocol', - 'settings.role': 'Role', - 'settings.groups': 'Group notifications', - 'settings.notifications': 'Notifications', - 'settings.node_key': 'Link Node', - 'settings.node_key_desc': 'If you operate a MeshBay node, paste its Ed25519 public key here to link it to your account. You can find this key in your node\'s admin dashboard.', - 'settings.node_key_placeholder': 'Paste node Ed25519 public key (base64)', - 'settings.node_key_submit': 'Link Node', - 'settings.node_key_success': 'Node linked successfully', - 'settings.node_key_current': 'Linked node key', - - // Sidebar - 'sidebar.create_group': 'Create group', - 'sidebar.settings': 'Settings', - 'sidebar.admin': 'Admin', - - // User menu - 'usermenu.language': 'Language', - 'usermenu.settings': 'Settings', - 'usermenu.theme_light': 'Light mode', - 'usermenu.theme_dark': 'Dark mode', - 'usermenu.logout': 'Logout', - - // Admin panel - 'admin.title': 'Administration', - 'admin.tab_stats': 'Stats', - 'admin.tab_users': 'Users', - 'admin.tab_groups': 'Groups', - 'admin.tab_nodes': 'Nodes', - 'admin.tab_logs': 'Logs', - 'admin.col_observed_ip': 'Seen from', - 'admin.col_hint': 'Announced hint', - 'admin.col_last_seen': 'Last announce', - 'admin.node_online': 'online', - 'admin.node_offline': 'offline', - 'admin.no_nodes': 'No nodes registered', - 'admin.nodes_hint': '"Seen from" is the address the announcement arrived from, ' - + 'on a request signed with the node key — that is the one to answer a ' - + 'question with. The hint is what the node believes its own address to be, ' - + 'learned from a STUN server and sent to us; it is useful for reaching the ' - + 'node and is not evidence of anything.', - 'admin.tab_blocklist': 'Blocklist', - - // Admin stats - 'admin.stat_users': 'Users', - 'admin.stat_groups': 'Groups', - 'admin.stat_nodes': 'Nodes', - 'admin.stat_online': 'Online Nodes', - - // Admin users - 'admin.users_search': 'Search users...', - 'admin.col_username': 'Username', - 'admin.col_role': 'Role', - 'admin.col_status': 'Status', - 'admin.col_created': 'Created', - 'admin.col_actions': 'Actions', - 'admin.col_email': 'Email', - 'admin.col_groups': 'Groups', - 'admin.no_users': 'No users found', - 'admin.btn_suspend': 'Suspend', - 'admin.btn_unsuspend': 'Unsuspend', - 'admin.btn_details': 'Details', - 'admin.btn_delete': 'Delete', - 'admin.delete_confirm': 'Delete the account "{user}"? This cannot be undone. ' - + 'Files they uploaded stay on the nodes that host them, and each node keeps ' - + 'the identity it pinned until its operator unpins it. Suspending is the ' - + 'reversible option.', - 'admin.user_detail': 'User details', - 'admin.btn_close': 'Close', - 'admin.self_note': '(you)', - - // Admin groups - 'admin.col_name': 'Name', - 'admin.col_visibility': 'Visibility', - 'admin.col_members': 'Members', - 'admin.no_groups': 'No groups found', - - // Admin logs - 'admin.col_time': 'Time', - 'admin.col_event': 'Event', - 'admin.col_user': 'User', - 'admin.col_ip': 'IP', - 'admin.col_detail': 'Detail', - 'admin.filter_all': 'All events', - 'admin.no_logs': 'No logs found', - 'admin.btn_load_more': 'Load more', - - // Admin blocklist - 'admin.col_hash': 'Hash', - 'admin.col_reason': 'Reason', - 'admin.col_date': 'Date', - 'admin.col_added_by': 'Added by', - 'admin.no_blocked': 'No blocked hashes', - 'admin.add_hash': 'Add hash to blocklist', - 'admin.hash_placeholder': 'blake3 hash (64 hex chars)', - 'admin.reason_placeholder': 'Reason', - 'admin.btn_block': 'Block', - 'admin.btn_unblock': 'Unblock', +export const LOCALES = [ + { code: 'en', name: 'English', flag: '\u{1F1EC}\u{1F1E7}' }, + { code: 'fr', name: 'Français', flag: '\u{1F1EB}\u{1F1F7}' }, + { code: 'es', name: 'Español', flag: '\u{1F1EA}\u{1F1F8}' }, + { code: 'pt-BR', name: 'Português (Brasil)', flag: '\u{1F1E7}\u{1F1F7}' }, + { code: 'zh-CN', name: '简体中文', flag: '\u{1F1E8}\u{1F1F3}' }, + { code: 'ja', name: '日本語', flag: '\u{1F1EF}\u{1F1F5}' }, + { code: 'de', name: 'Deutsch', flag: '\u{1F1E9}\u{1F1EA}' }, + { code: 'it', name: 'Italiano', flag: '\u{1F1EE}\u{1F1F9}' }, + { code: 'nl', name: 'Nederlands', flag: '\u{1F1F3}\u{1F1F1}' }, + { code: 'pl', name: 'Polski', flag: '\u{1F1F5}\u{1F1F1}' }, +]; - // Notifications - // Create group - 'create_group.title': 'Create Group', - 'create_group.name': 'Group name', - 'create_group.visibility': 'Visibility', - 'create_group.private': 'Private', - 'create_group.public': 'Public', - 'create_group.join_policy': 'Join policy', - 'create_group.invite': 'Invite only', - 'create_group.open': 'Open (anyone can join)', - 'create_group.description': 'Description', - 'create_group.description_hint': 'What is this group about? (optional)', - 'create_group.submit': 'Create', - 'create_group.hint': 'A group needs a node to host files. You can create the group now and connect a node later.', - 'create_group.creating': 'Creating...', +const _codes = LOCALES.map(l => l.code); +const _strings = {}; +const _plurals = {}; +let _locale = FALLBACK; - // Members - 'members.col_role': 'Role', - 'members.group_role': 'Group role', - 'members.admin': 'Admin', - 'members.group_admin': 'Group admin', - 'members.owner': 'Owner', - 'members.member': 'Member', - 'members.invite_title': 'Invite member', - 'members.username_placeholder': 'Username', - 'members.invite_btn': 'Invite', - 'members.invite_needs_pairing': 'Invitations are signed with the key this node ' - + 'pinned for your browser, so pair it below before inviting anyone.', - 'members.invite_ask_operator': 'Only the operator of the node hosting this group can ' - + 'invite, from a browser paired with it.', - 'members.remove': 'Remove', - 'members.remove_confirm': 'Remove {user} from this group? Their account, their ' - + 'other groups and the files they uploaded are not affected.', - 'members.remove_hint': 'Removing someone takes away their membership and stops ' - + 'the node serving them the group key. It does not delete their account, and ' - + 'it does not take back the key they already hold — rotate it on the node ' - + '(meshbay-node gek-init) if that matters.', - 'members.pair_title': 'Pair this browser with your node', - 'members.pair_hint': 'Your node only accepts operator actions — invites, file ' - + 'deletion — from a browser it has been paired with. Run ' - + '`meshbay-node operator pair` on the node and type the code here. The code ' - + 'never passes through the hub.', - 'members.pair_btn': 'Pair', - 'members.pair_success': 'This browser is now paired with the node.', - 'members.invite_code_ready': 'Invitation code for {user} — send it to them the way ' - + 'you normally talk. It works once, and it never passes through the hub.', - 'members.invite_code_hint': 'They enter it the first time they open this group. ' - + 'You do not need to be online then.', - 'transfers.title': 'Transfers', - 'transfers.clear': 'Clear finished', - 'transfers.cancel': 'Cancel', - 'transfers.open': 'Open', - 'transfers.done': 'Finished', - 'transfers.cancelled': 'Cancelled', - 'transfers.failed': 'Failed', - 'group.select': 'Select', - 'group.select_done': 'Done', - 'group.actions': 'Actions ({n})', - 'group.download_n': 'Download ({n})', - 'group.download_zip_n': 'Download folders as zip ({n})', - 'group.delete_n': 'Delete ({n})', - 'group.delete_n_confirm': 'Delete {n} item(s)? {names}', - 'group.download_zip': 'Download as zip ({n} files)', - 'group.zip_empty': 'That folder has nothing in it to download.', - 'group.zip_no_stream': 'This browser cannot write a download straight to disk, ' - + 'so {name} ({size}) has to be built in memory first. On a large archive ' - + 'that may fail. Continue?', - 'group.rmdir': 'Delete folder', - 'group.rmdir_confirm': 'Delete the folder "{name}"? It must be empty.', - 'group.rmdir_not_empty': 'That folder is not empty. Delete what is in it first — ' - + 'nothing here removes files you cannot see.', - 'group.desc_edit': 'Edit description', - 'group.desc_add': 'Add a description', - 'group.desc_save': 'Save', - 'group.desc_cancel': 'Cancel', - 'group.desc_placeholder': 'What this group is for — members see it on their home page.', - 'group.join_code_title': 'This node needs to recognise you', - 'group.join_code_hint': 'Ask whoever invited you for the one-time code, and enter ' - + 'it here. After that this browser is recognised and you will not be asked again.', - 'group.join_code_btn': 'Join', +// ── Resolution ────────────────────────────────────────────────────────────── - 'notif.purge': 'Clear all', - 'notif.title': 'Notifications', - 'notif.empty': 'No notifications', - 'notif.mark_all_read': 'Mark all read', +/** + * Match a requested tag against the registry, region-tolerantly. + * + * `navigator.language` is `pt-BR` or `zh-CN` on the machines those catalogues + * are for, and `fr-CA` or `de-AT` on machines the base catalogue serves fine. + * Trimming to the base first — which this used to do — sent a Brazilian browser + * looking for a `pt` catalogue that does not exist. + */ +function _match(tag) { + if (!tag) return null; + const want = String(tag).replace('_', '-'); + const exact = _codes.find(c => c.toLowerCase() === want.toLowerCase()); + if (exact) return exact; + const base = want.split('-')[0].toLowerCase(); + return _codes.find(c => c.split('-')[0].toLowerCase() === base) || null; +} - // Sidebar - 'sidebar.search': 'Search files', +/** + * Storage access throws outright in some privacy modes rather than returning + * null. A language preference is not worth failing the boot over, so both + * directions swallow it and the browser's own languages decide instead. + */ +function _stored(value) { + try { + if (value === undefined) return localStorage.getItem(LANG_KEY); + localStorage.setItem(LANG_KEY, value); + } catch { + return null; + } + return value; +} - // Status +function _preferred() { + const stored = _match(_stored()); + if (stored) return stored; + for (const tag of navigator.languages || [navigator.language]) { + const hit = _match(tag); + if (hit) return hit; + } + return FALLBACK; +} - // Search - 'search.title': 'Search Files', - 'search.placeholder': 'Search across all groups...', - 'search.no_results': 'No files match your search.', - 'search.synced': 'synced {ago}', - 'search.cache_note': 'Search reads what each group looked like the last time you ' - + 'opened it. Open a group to bring its files up to date here.', - 'search.col_group': 'Group', - 'search.result_count': '{n} files found', - 'search.hint': 'Search file names across all cached group indexes.', -}; +// ── Loading ───────────────────────────────────────────────────────────────── -// ── Locale registry ───────────────────────────────────────────────────────── +async function _load(code) { + if (_strings[code]) return true; + try { + const mod = await import(`./locales/${code}.js`); + _strings[code] = mod.default; + return true; + } catch (err) { + console.warn(`[MeshBay] locale ${code} failed to load:`, err); + return false; + } +} -const _strings = { en }; -let _locale = 'en'; +/** + * Load the active catalogue and the English fallback, then fix the locale. + * Resolves even when a catalogue is missing — an untranslated interface beats + * a blank page, so a failed load degrades to English rather than throwing. + */ +export async function initLocale() { + const want = _preferred(); + const loads = [_load(want)]; + if (want !== FALLBACK) loads.push(_load(FALLBACK)); + const [ok] = await Promise.all(loads); + _locale = ok ? want : FALLBACK; + if (!_strings[FALLBACK]) await _load(FALLBACK); + document.documentElement.lang = _locale; + return _locale; +} -export const LOCALES = [ - { code: 'en', name: 'English', flag: '\u{1F1EC}\u{1F1E7}' }, -]; +// ── Lookup ────────────────────────────────────────────────────────────────── export function getLocale() { return _locale; } export function setLocale(code) { - if (_strings[code]) { - _locale = code; - localStorage.setItem(LANG_KEY, code); - return true; - } - return false; + const hit = _match(code); + if (!hit) return false; + _stored(hit); + return true; } export function addLocale(code, strings) { _strings[code] = strings; } +function _pluralRules(locale) { + if (!_plurals[locale]) _plurals[locale] = new Intl.PluralRules(locale); + return _plurals[locale]; +} + +/** A catalogue entry is a plain string, or plural categories to choose from. */ +function _select(entry, locale, params) { + if (typeof entry === 'string') return entry; + if (!entry || typeof entry !== 'object') return null; + const n = Number(params && params.n); + const cat = _pluralRules(locale).select(Number.isFinite(n) ? n : 0); + return entry[cat] ?? entry.other ?? null; +} + export function t(key, params) { - let s = (_strings[_locale] && _strings[_locale][key]) - || _strings.en[key] - || key; + let s = _select(_strings[_locale] && _strings[_locale][key], _locale, params); + if (s == null) { + s = _select(_strings[FALLBACK] && _strings[FALLBACK][key], FALLBACK, params); + } + if (s == null) return key; if (params) { for (const [k, v] of Object.entries(params)) { - s = s.replace(`{${k}}`, v); + // split/join rather than replace: a value is often a filename, and + // String.replace reads `$&` and friends in the replacement. + s = s.split(`{${k}}`).join(v); } } return s; } - -// ── Init ──────────────────────────────────────────────────────────────────── - -function _init() { - const stored = localStorage.getItem(LANG_KEY); - if (stored && _strings[stored]) { - _locale = stored; - return; - } - const browserLang = (navigator.language || '').split('-')[0]; - if (_strings[browserLang]) { - _locale = browserLang; - } -} - -_init(); |