aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-16 00:21:07 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-16 00:21:07 +0200
commit3b2d03a919e56d14f8097844b97b844ddf526cd6 (patch)
tree31b222fedf6d608809eb47c45c5f431d7001d87a /packages/meshbay-hub
parent75065cc1ed5d9cd733871d478dc4f915663d795c (diff)
downloadmeshbay-3b2d03a919e56d14f8097844b97b844ddf526cd6.tar.gz
feat(hub): translate the web client into nine more languages
French, Spanish, Brazilian Portuguese, Simplified Chinese, Japanese, German, Italian, Dutch and Polish, all in the formal register. `hub`, `node` and `GEK` stay in English: they name the CLI, node.toml and the docs, and translating them would cut the interface off from everything an operator reads and types. Catalogues move out of i18n.js into locales/, one file per language, fetched with a dynamic import. A visitor downloads their language plus English as a fallback — about 36 KB rather than the ~180 KB that ten inlined catalogues would have cost everyone. i18n.js keeps only the loader, so the first render now waits for initLocale(). Three things the old code got wrong, none of them visible until there was a second language: - Resolution trimmed a tag to its base before matching, so a browser reporting pt-BR looked for a `pt` catalogue that does not exist and fell back to English. Matching is now exact first, then by base language. - Counted strings were single strings, so Polish could not express 1 plik / 2 pliki / 5 plików at all. t() selects through Intl.PluralRules; en.js gains the same treatment, which incidentally fixes "1 files". - Interpolation used String.replace, which reads `$&` in the replacement. A file named rap$&sody.mp3 rendered corrupted in its own delete dialog. Five strings were still hardcoded in app.js — the group name placeholder and the four visibility/join-policy descriptions — and are now keyed. test_locales.py holds the nine translations to the shape of en.js: same keys, a counted string stays counted everywhere, every plural entry covers each category Intl actually produces for that language, and the {placeholders} survive translation. Verified failing first, against a catalogue with a key removed, a placeholder dropped and the Polish `few` form deleted. The language menu also grew from one entry to ten, which overran a short viewport inside a dropdown that clipped instead of scrolling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js509
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js407
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js392
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js402
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js407
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js404
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js393
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js406
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js419
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js403
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js376
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css7
-rw-r--r--packages/meshbay-hub/tests/test_locales.py214
14 files changed, 4369 insertions, 394 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index cf33d72..6a5a13f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -2,7 +2,7 @@ import {
html, render, useState, useEffect, useCallback, useRef,
createContext, useContext,
} from './vendor/htm-preact.js';
-import { t, getLocale, setLocale, LOCALES } from './i18n.js';
+import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js';
import { ZipStream, entriesUnder } from './zipstream.js';
import { transfers, formatSpeed } from './transfers.js';
import * as downloads from './downloads.js';
@@ -852,7 +852,7 @@ function CreateGroupPage({ token, onCreated }) {
<form onSubmit=${onSubmit}>
<div class="form-field">
<label class="form-label">${t('create_group.name')}</label>
- <input type="text" placeholder="e.g. Family Photos, Project Files..."
+ <input type="text" placeholder="${t('create_group.name_placeholder')}"
value=${name} onInput=${e => setName(e.target.value)} required autofocus />
</div>
@@ -872,13 +872,13 @@ function CreateGroupPage({ token, onCreated }) {
onClick=${() => setVisibility('private')}>
<${Icon} name="lock" cls="option-icon" />
<span class="option-title">${t('create_group.private')}</span>
- <span class="option-desc">Only invited members can see and access files</span>
+ <span class="option-desc">${t('create_group.private_desc')}</span>
</button>
<button type="button" class="option-card ${visibility === 'public' ? 'selected' : ''}"
onClick=${() => setVisibility('public')}>
<${Icon} name="globe" cls="option-icon" />
<span class="option-title">${t('create_group.public')}</span>
- <span class="option-desc">Anyone can discover and browse this group</span>
+ <span class="option-desc">${t('create_group.public_desc')}</span>
</button>
</div>
</div>
@@ -890,13 +890,13 @@ function CreateGroupPage({ token, onCreated }) {
onClick=${() => setJoinPolicy('invite')}>
<${Icon} name="envelope" cls="option-icon" />
<span class="option-title">${t('create_group.invite')}</span>
- <span class="option-desc">Members must be invited by an admin</span>
+ <span class="option-desc">${t('create_group.invite_desc')}</span>
</button>
<button type="button" class="option-card ${joinPolicy === 'open' ? 'selected' : ''}"
onClick=${() => setJoinPolicy('open')}>
<${Icon} name="door" cls="option-icon" />
<span class="option-title">${t('create_group.open')}</span>
- <span class="option-desc">Anyone can join without approval</span>
+ <span class="option-desc">${t('create_group.open_desc')}</span>
</button>
</div>
</div>
@@ -3643,4 +3643,14 @@ function App() {
// ── Boot ─────────────────────────────────────────────────────────────────────
-render(html`<${App} />`, document.getElementById('app'));
+// Catalogues are fetched, so the first render waits for one: mounting earlier
+// would paint the interface in English and then swap every string. initLocale()
+// falls back to English rather than rejecting, so this cannot strand the page.
+const mount = () => render(html`<${App} />`, document.getElementById('app'));
+initLocale().then(mount, (err) => {
+ // Nothing in initLocale() is supposed to reject. If something does, an
+ // English interface is still an interface; an unhandled rejection here is a
+ // blank page.
+ console.error('[MeshBay] locale init failed, continuing in English:', err);
+ mount();
+});
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();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
new file mode 100644
index 0000000..1ff4687
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -0,0 +1,407 @@
+/**
+ * German — translated from locales/en.js.
+ *
+ * Register: formal (Siezen).
+ * `hub`, `node` and `GEK` are left in English: they name the CLI
+ * (`meshbay-node`), node.toml and the docs, which are English throughout.
+ * Grammatical gender chosen here: der Hub, der Node, der GEK (der Schlüssel).
+ */
+
+export default {
+ // Nav
+ 'nav.toggle_menu': 'Menü anzeigen',
+ 'nav.light_mode': 'Helles Design',
+ 'nav.dark_mode': 'Dunkles Design',
+ 'nav.logout': 'Abmelden',
+ 'nav.login': 'Anmelden',
+
+ // Sidebar
+ 'sidebar.my_groups': 'Meine Gruppen',
+ 'sidebar.no_groups': 'Noch keine Gruppen',
+ 'sidebar.discover': 'Entdecken',
+ 'sidebar.public_groups': 'Öffentliche Gruppen',
+
+ // Login
+ 'login.title': 'Anmelden',
+ 'login.username': 'Benutzername',
+ 'login.password': 'Passwort',
+ 'login.submit': 'Anmelden',
+ 'login.loading': 'Anmeldung läuft …',
+ 'login.no_account': 'Noch kein Konto?',
+ 'login.register_link': 'Konto erstellen',
+
+ // Register
+ 'register.title': 'Konto erstellen',
+ 'register.username': 'Benutzername',
+ 'register.email': 'E-Mail-Adresse',
+ 'register.password': 'Passphrase (mindestens 12 Zeichen)',
+ 'register.confirm': 'Passwort bestätigen',
+ 'register.submit': 'Konto erstellen',
+ 'register.loading': 'Konto wird erstellt …',
+ 'register.has_account': 'Sie haben bereits ein Konto?',
+ 'register.login_link': 'Anmelden',
+ 'register.success_title': 'Konto erstellt',
+ 'register.success_msg': 'Sie können sich jetzt mit Ihren Zugangsdaten anmelden.',
+ 'register.go_login': 'Zur Anmeldung',
+ 'register.err_mismatch': 'Die Passwörter stimmen nicht überein',
+ 'register.err_min_len': {
+ one: 'Verwenden Sie mindestens {n} Zeichen',
+ other: 'Verwenden Sie mindestens {n} Zeichen',
+ },
+ 'register.err_too_weak': 'Zu leicht zu erraten. Ihre Passphrase ist das, was Ihre '
+ + 'Schlüssel dort schützt, wo sie gespeichert sind — ein paar zusammenhanglose '
+ + 'Wörter funktionieren gut.',
+ 'register.strength': 'Stärke: etwa {bits} Bit. Sie schützt die Kopie Ihrer '
+ + 'Schlüssel, die auf den Nodes liegt, denen Sie beitreten — es lohnt sich also, '
+ + 'sie gut zu wählen.',
+
+ // Home
+ 'home.welcome': 'Willkommen bei MeshBay',
+ 'home.no_groups': 'Sie sind noch in keiner Gruppe Mitglied.',
+ 'home.browse_prefix': 'Sehen Sie sich die ',
+ 'home.browse_link': 'öffentlichen Gruppen',
+ 'home.browse_suffix': ' an oder bitten Sie einen Gruppenadministrator um eine Einladung.',
+ 'home.my_groups': 'Meine Gruppen',
+
+ // Explore
+ 'explore.title': 'Öffentliche Gruppen',
+ 'explore.search': 'Gruppen suchen …',
+ 'explore.loading': 'Wird geladen …',
+ 'explore.empty': 'Keine öffentlichen Gruppen verfügbar.',
+ 'explore.create_group': 'Gruppe erstellen',
+ 'explore.join': 'Beitreten',
+ 'explore.member': 'Beigetreten',
+
+ // Group page
+ 'group.default_name': 'Gruppe',
+ 'group.tab_files': 'Dateien',
+ 'group.tab_chat': 'Chat',
+ 'group.tab_members': 'Mitglieder',
+ 'group.filter': 'Dateien filtern …',
+ 'group.col_name': 'Name',
+ 'group.col_size': 'Größe',
+ 'group.col_type': 'Typ',
+ 'group.col_date': 'Datum',
+ 'group.empty_filter': 'Keine Datei entspricht dem Filter',
+ 'group.empty_dir': 'Dieser Ordner ist leer',
+ 'group.download': 'Herunterladen',
+ 'group.play': 'Abspielen',
+ 'group.offline_title': 'Für diese Gruppe ist derzeit kein Node online.',
+ 'group.offline_hint': 'Die Dateien erscheinen, sobald sich ein Node verbindet, der '
+ + 'diese Gruppe hostet.',
+ 'group.upload': 'Hochladen',
+ 'group.mkdir': 'Neuer Ordner',
+ 'group.mkdir_prompt': 'Name des neuen Ordners:',
+ 'group.view': 'Ansehen',
+ 'group.delete': 'Löschen',
+ 'group.delete_confirm': '{name} löschen?',
+ 'group.delete_group': 'Gruppe löschen',
+ 'group.delete_group_confirm': '„{name}“ endgültig löschen und alle Mitglieder '
+ + 'entfernen? Die Dateien auf dem Node bleiben erhalten.',
+ 'group.err_transport': 'Transportmodul nicht geladen',
+
+ // Status
+ 'status.idle': 'Inaktiv',
+ 'status.discovering': 'Nodes werden gesucht …',
+ 'status.connecting': 'Verbindung über WebRTC …',
+ 'status.fetching': 'Index wird abgerufen …',
+ 'status.files': {
+ one: '{n} Datei',
+ other: '{n} Dateien',
+ },
+ 'status.offline': 'Kein Node online',
+ 'status.error': 'Verbindung fehlgeschlagen',
+
+ // Chat
+ 'chat.empty': 'Noch keine Nachrichten. Beginnen Sie das Gespräch!',
+ 'chat.placeholder': 'Nachricht schreiben …',
+ 'chat.send': 'Senden',
+ 'chat.attach': 'Datei anhängen',
+
+ // Video player
+ 'video.loading': '{name} wird geladen …',
+ 'video.buffering': 'Wird gepuffert …',
+ 'video.close': 'Schließen (Esc)',
+ 'preview.pdf_fallback': 'Dieser Browser zeigt das PDF nicht direkt an. Laden Sie es '
+ + 'stattdessen herunter — entschlüsselt wurde es ohnehin hier.',
+ 'group.upload_indexing': 'wird indiziert …',
+ 'video.err_transport': 'Transport nicht verbunden',
+ 'video.err_mse': 'Codec wird für das Streaming nicht unterstützt: {codec}',
+
+ // Settings
+ 'settings.title': 'Einstellungen',
+ 'settings.coming_soon': 'Demnächst verfügbar.',
+ 'settings.downloads': 'Downloads',
+ 'settings.dl_auto': 'Automatisch speichern',
+ 'settings.dl_auto_hint': 'Dateien landen ohne Dialog direkt in dem Ordner, den Sie '
+ + 'unten auswählen. Zwanzig Downloads legen dort zwanzig Dateien ab.',
+ 'settings.dl_ask': 'Jedes Mal fragen',
+ 'settings.dl_ask_hint': 'Ein „Speichern unter“-Dialog pro Datei — einer je Datei, '
+ + 'auch wenn Sie eine Auswahl herunterladen.',
+ 'settings.dl_folder': 'Ordner: {name}',
+ 'settings.dl_no_folder': 'Kein Ordner ausgewählt — Downloads landen dort, wo Ihr '
+ + 'Browser sie ablegt',
+ 'settings.dl_choose': 'Ordner auswählen',
+ 'settings.dl_change': 'Ändern',
+ 'settings.dl_forget': 'Verwerfen',
+ 'settings.dl_path_note': 'Einer Webseite lässt sich kein Pfad übergeben, hier ist '
+ + 'also nichts einzutippen: Ihr Browser gewährt Zugriff auf den Ordner, den Sie '
+ + 'auswählen, und MeshBay schreibt ausschließlich darin. Einmal pro Sitzung kann '
+ + 'eine Bestätigung verlangt werden.',
+ 'settings.dl_unsupported': 'Dieser Browser kann nicht in einen Ordner Ihrer Wahl '
+ + 'schreiben (keine File System Access API), daher landen Downloads in seinem '
+ + 'eigenen Download-Ordner. Chrome und Edge lassen die Wahl zu.',
+ 'settings.profile': 'Profil',
+ 'settings.username': 'Benutzername',
+ 'settings.node_pins': 'Node-Identitäten',
+ 'settings.node_pins_hint': 'Der Identitätsschlüssel jedes Nodes wird bei der ersten '
+ + 'Verbindung gemerkt. Ändert er sich, wird die Verbindung abgelehnt — das ist nur '
+ + 'zu erwarten, wenn ein Betreiber einen Node neu installiert. Fragen Sie dort '
+ + 'nach, bevor Sie die Einträge löschen.',
+ 'settings.node_pins_count': {
+ one: '{n} gemerkt',
+ other: '{n} gemerkt',
+ },
+ 'settings.node_pins_clear': 'Gemerkte Identitäten löschen',
+ 'settings.danger': 'Konto löschen',
+ 'settings.delete_hint': 'Entfernt Ihr Konto, Ihre Gruppenmitgliedschaften und Ihre '
+ + 'Benachrichtigungen vom Hub und gibt Ihren Benutzernamen frei. Was auf den Nodes '
+ + 'liegt, erreicht das nicht: hochgeladene Dateien bleiben dort, wo ihr Betreiber '
+ + 'sie aufbewahrt, und jeder Node behält die für Sie gemerkte Identität, bis sein '
+ + 'Betreiber sie entfernt. Verbindungsprotokolle werden ein Jahr lang aufbewahrt, '
+ + 'wie es das Gesetz verlangt.',
+ 'settings.delete_account': 'Mein Konto löschen',
+ 'settings.delete_confirm': 'Geben Sie zur Bestätigung Ihre Passphrase ein. '
+ + 'Das lässt sich nicht rückgängig machen.',
+ 'settings.delete_confirm_btn': 'Endgültig löschen',
+ 'settings.cancel': 'Abbrechen',
+ 'settings.appearance': 'Darstellung',
+ 'settings.theme': 'Design',
+ 'settings.theme_light': 'Hell',
+ 'settings.theme_dark': 'Dunkel',
+ 'settings.theme_system': 'System',
+ 'settings.language': 'Sprache',
+ 'settings.about': 'Über',
+ 'settings.version': 'Version',
+ 'settings.protocol': 'Protokoll',
+ 'settings.role': 'Rolle',
+ 'settings.groups': 'Gruppenbenachrichtigungen',
+ 'settings.notifications': 'Benachrichtigungen',
+ 'settings.node_key': 'Node verknüpfen',
+ 'settings.node_key_desc': 'Wenn Sie einen MeshBay-Node betreiben, fügen Sie hier '
+ + 'dessen öffentlichen Ed25519-Schlüssel ein, um ihn mit Ihrem Konto zu '
+ + 'verknüpfen. Sie finden diesen Schlüssel im Administrations-Dashboard Ihres Nodes.',
+ 'settings.node_key_placeholder': 'Öffentlichen Ed25519-Schlüssel des Nodes einfügen (base64)',
+ 'settings.node_key_submit': 'Node verknüpfen',
+ 'settings.node_key_success': 'Node erfolgreich verknüpft',
+ 'settings.node_key_current': 'Schlüssel des verknüpften Nodes',
+
+ // Sidebar
+ 'sidebar.create_group': 'Gruppe erstellen',
+ 'sidebar.settings': 'Einstellungen',
+ 'sidebar.admin': 'Verwaltung',
+
+ // User menu
+ 'usermenu.language': 'Sprache',
+ 'usermenu.settings': 'Einstellungen',
+ 'usermenu.theme_light': 'Helles Design',
+ 'usermenu.theme_dark': 'Dunkles Design',
+ 'usermenu.logout': 'Abmelden',
+
+ // Admin panel
+ 'admin.title': 'Verwaltung',
+ 'admin.tab_stats': 'Statistik',
+ 'admin.tab_users': 'Benutzer',
+ 'admin.tab_groups': 'Gruppen',
+ 'admin.tab_nodes': 'Nodes',
+ 'admin.tab_logs': 'Protokolle',
+ 'admin.col_observed_ip': 'Gesehen von',
+ 'admin.col_hint': 'Gemeldete Adresse',
+ 'admin.col_last_seen': 'Letzte Meldung',
+ 'admin.node_online': 'online',
+ 'admin.node_offline': 'offline',
+ 'admin.no_nodes': 'Keine Nodes registriert',
+ 'admin.nodes_hint': '„Gesehen von“ ist die Adresse, von der die Meldung tatsächlich '
+ + 'eintraf, in einer mit dem Node-Schlüssel signierten Anfrage — auf diese sollten '
+ + 'Sie sich bei einer Auskunft stützen. Die gemeldete Adresse ist die, für die der '
+ + 'Node sich selbst hält; er hat sie von einem STUN-Server erfahren und uns '
+ + 'geschickt. Sie hilft, den Node zu erreichen, und beweist nichts.',
+ 'admin.tab_blocklist': 'Sperrliste',
+
+ // Admin stats
+ 'admin.stat_users': 'Benutzer',
+ 'admin.stat_groups': 'Gruppen',
+ 'admin.stat_nodes': 'Nodes',
+ 'admin.stat_online': 'Nodes online',
+
+ // Admin users
+ 'admin.users_search': 'Benutzer suchen …',
+ 'admin.col_username': 'Benutzername',
+ 'admin.col_role': 'Rolle',
+ 'admin.col_status': 'Status',
+ 'admin.col_created': 'Erstellt',
+ 'admin.col_actions': 'Aktionen',
+ 'admin.col_email': 'E-Mail',
+ 'admin.col_groups': 'Gruppen',
+ 'admin.no_users': 'Keine Benutzer gefunden',
+ 'admin.btn_suspend': 'Sperren',
+ 'admin.btn_unsuspend': 'Entsperren',
+ 'admin.btn_details': 'Details',
+ 'admin.btn_delete': 'Löschen',
+ 'admin.delete_confirm': 'Das Konto „{user}“ löschen? Das lässt sich nicht '
+ + 'rückgängig machen. Die hochgeladenen Dateien bleiben auf den Nodes, die sie '
+ + 'hosten, und jeder Node behält die gemerkte Identität, bis sein Betreiber sie '
+ + 'entfernt. Sperren ist die umkehrbare Option.',
+ 'admin.user_detail': 'Benutzerdetails',
+ 'admin.btn_close': 'Schließen',
+ 'admin.self_note': '(Sie)',
+
+ // Admin groups
+ 'admin.col_name': 'Name',
+ 'admin.col_visibility': 'Sichtbarkeit',
+ 'admin.col_members': 'Mitglieder',
+ 'admin.no_groups': 'Keine Gruppen gefunden',
+
+ // Admin logs
+ 'admin.col_time': 'Zeit',
+ 'admin.col_event': 'Ereignis',
+ 'admin.col_user': 'Benutzer',
+ 'admin.col_ip': 'IP',
+ 'admin.col_detail': 'Detail',
+ 'admin.filter_all': 'Alle Ereignisse',
+ 'admin.no_logs': 'Keine Protokolle gefunden',
+ 'admin.btn_load_more': 'Mehr laden',
+
+ // Admin blocklist
+ 'admin.col_hash': 'Hash',
+ 'admin.col_reason': 'Grund',
+ 'admin.col_date': 'Datum',
+ 'admin.col_added_by': 'Hinzugefügt von',
+ 'admin.no_blocked': 'Keine gesperrten Hashes',
+ 'admin.add_hash': 'Hash zur Sperrliste hinzufügen',
+ 'admin.hash_placeholder': 'blake3-Hash (64 Hexadezimalzeichen)',
+ 'admin.reason_placeholder': 'Grund',
+ 'admin.btn_block': 'Sperren',
+ 'admin.btn_unblock': 'Entsperren',
+
+ // Notifications
+ // Create group
+ 'create_group.title': 'Gruppe erstellen',
+ 'create_group.name': 'Gruppenname',
+ 'create_group.name_placeholder': 'z. B. Familienfotos, Projektunterlagen …',
+ 'create_group.visibility': 'Sichtbarkeit',
+ 'create_group.private': 'Privat',
+ 'create_group.private_desc': 'Nur eingeladene Mitglieder sehen die Dateien und greifen darauf zu',
+ 'create_group.public': 'Öffentlich',
+ 'create_group.public_desc': 'Alle können diese Gruppe finden und durchsehen',
+ 'create_group.join_policy': 'Beitrittsregel',
+ 'create_group.invite': 'Nur mit Einladung',
+ 'create_group.invite_desc': 'Mitglieder müssen von einem Administrator eingeladen werden',
+ 'create_group.open': 'Offen (alle können beitreten)',
+ 'create_group.open_desc': 'Alle können ohne Freigabe beitreten',
+ 'create_group.description': 'Beschreibung',
+ 'create_group.description_hint': 'Worum geht es in dieser Gruppe? (optional)',
+ 'create_group.submit': 'Erstellen',
+ 'create_group.hint': 'Eine Gruppe braucht einen Node, der die Dateien hostet. Sie '
+ + 'können die Gruppe jetzt erstellen und später einen Node verbinden.',
+ 'create_group.creating': 'Wird erstellt …',
+
+ // Members
+ 'members.col_role': 'Rolle',
+ 'members.group_role': 'Rolle in der Gruppe',
+ 'members.admin': 'Administrator',
+ 'members.group_admin': 'Gruppenadministrator',
+ 'members.owner': 'Eigentümer',
+ 'members.member': 'Mitglied',
+ 'members.invite_title': 'Mitglied einladen',
+ 'members.username_placeholder': 'Benutzername',
+ 'members.invite_btn': 'Einladen',
+ 'members.invite_needs_pairing': 'Einladungen werden mit dem Schlüssel signiert, den '
+ + 'dieser Node für Ihren Browser gemerkt hat. Koppeln Sie ihn daher unten, bevor '
+ + 'Sie jemanden einladen.',
+ 'members.invite_ask_operator': 'Nur der Betreiber des Nodes, der diese Gruppe '
+ + 'hostet, kann einladen — aus einem mit ihm gekoppelten Browser.',
+ 'members.remove': 'Entfernen',
+ 'members.remove_confirm': '{user} aus dieser Gruppe entfernen? Das Konto, die '
+ + 'übrigen Gruppen und die hochgeladenen Dateien bleiben unberührt.',
+ 'members.remove_hint': 'Wer entfernt wird, verliert die Mitgliedschaft, und der Node '
+ + 'gibt ihm den Gruppenschlüssel nicht mehr heraus. Das Konto wird dadurch nicht '
+ + 'gelöscht, und der bereits erhaltene Schlüssel lässt sich nicht zurückholen — '
+ + 'wechseln Sie ihn auf dem Node (meshbay-node gek-init), wenn das wichtig ist.',
+ 'members.pair_title': 'Diesen Browser mit Ihrem Node koppeln',
+ 'members.pair_hint': 'Ihr Node akzeptiert Betreiberaktionen — Einladungen, Löschen '
+ + 'von Dateien — nur aus einem Browser, mit dem er gekoppelt wurde. Führen Sie auf '
+ + 'dem Node `meshbay-node operator pair` aus und geben Sie den Code hier ein. Der '
+ + 'Code läuft nie über den Hub.',
+ 'members.pair_btn': 'Koppeln',
+ 'members.pair_success': 'Dieser Browser ist jetzt mit dem Node gekoppelt.',
+ 'members.invite_code_ready': 'Einladungscode für {user} — schicken Sie ihn auf dem '
+ + 'Weg, den Sie ohnehin nutzen. Er funktioniert einmal und läuft nie über den Hub.',
+ 'members.invite_code_hint': 'Die eingeladene Person gibt ihn ein, wenn sie diese '
+ + 'Gruppe zum ersten Mal öffnet. Sie müssen dann nicht online sein.',
+ 'transfers.title': 'Übertragungen',
+ 'transfers.clear': 'Abgeschlossene entfernen',
+ 'transfers.cancel': 'Abbrechen',
+ 'transfers.open': 'Öffnen',
+ 'transfers.done': 'Abgeschlossen',
+ 'transfers.cancelled': 'Abgebrochen',
+ 'transfers.failed': 'Fehlgeschlagen',
+ 'group.select': 'Auswählen',
+ 'group.select_done': 'Fertig',
+ 'group.actions': 'Aktionen ({n})',
+ 'group.download_n': 'Herunterladen ({n})',
+ 'group.download_zip_n': 'Ordner als ZIP herunterladen ({n})',
+ 'group.delete_n': 'Löschen ({n})',
+ 'group.delete_n_confirm': {
+ one: '{n} Element löschen? {names}',
+ other: '{n} Elemente löschen? {names}',
+ },
+ 'group.download_zip': {
+ one: 'Als ZIP herunterladen ({n} Datei)',
+ other: 'Als ZIP herunterladen ({n} Dateien)',
+ },
+ 'group.zip_empty': 'In diesem Ordner gibt es nichts herunterzuladen.',
+ 'group.zip_no_stream': 'Dieser Browser kann einen Download nicht direkt auf die '
+ + 'Festplatte schreiben, daher muss {name} ({size}) zuerst im Arbeitsspeicher '
+ + 'aufgebaut werden. Bei einem großen Archiv kann das fehlschlagen. Fortfahren?',
+ 'group.rmdir': 'Ordner löschen',
+ 'group.rmdir_confirm': 'Den Ordner „{name}“ löschen? Er muss leer sein.',
+ 'group.rmdir_not_empty': 'Dieser Ordner ist nicht leer. Löschen Sie zuerst seinen '
+ + 'Inhalt — nichts hier entfernt Dateien, die Sie nicht sehen.',
+ 'group.desc_edit': 'Beschreibung bearbeiten',
+ 'group.desc_add': 'Beschreibung hinzufügen',
+ 'group.desc_save': 'Speichern',
+ 'group.desc_cancel': 'Abbrechen',
+ 'group.desc_placeholder': 'Wofür diese Gruppe da ist — Mitglieder sehen es auf ihrer '
+ + 'Startseite.',
+ 'group.join_code_title': 'Dieser Node muss Sie erkennen',
+ 'group.join_code_hint': 'Fragen Sie die Person, die Sie eingeladen hat, nach dem '
+ + 'Einmalcode und geben Sie ihn hier ein. Danach ist dieser Browser bekannt und Sie '
+ + 'werden nicht erneut gefragt.',
+ 'group.join_code_btn': 'Beitreten',
+
+ 'notif.purge': 'Alle löschen',
+ 'notif.title': 'Benachrichtigungen',
+ 'notif.empty': 'Keine Benachrichtigungen',
+ 'notif.mark_all_read': 'Alle als gelesen markieren',
+
+ // Sidebar
+ 'sidebar.search': 'Dateien suchen',
+
+ // Status
+
+ // Search
+ 'search.title': 'Dateien suchen',
+ 'search.placeholder': 'In allen Gruppen suchen …',
+ 'search.no_results': 'Keine Datei entspricht Ihrer Suche.',
+ 'search.synced': 'synchronisiert {ago}',
+ 'search.cache_note': 'Die Suche stützt sich auf den Stand, den jede Gruppe beim '
+ + 'letzten Öffnen hatte. Öffnen Sie eine Gruppe, um ihre Dateien hier zu '
+ + 'aktualisieren.',
+ 'search.col_group': 'Gruppe',
+ 'search.result_count': {
+ one: '{n} Datei gefunden',
+ other: '{n} Dateien gefunden',
+ },
+ 'search.hint': 'Durchsucht Dateinamen in allen zwischengespeicherten Gruppenindexen.',
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
new file mode 100644
index 0000000..0e4427d
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -0,0 +1,392 @@
+/**
+ * English — the source catalogue.
+ *
+ * Every other locale is a translation of this file and carries the same keys.
+ * A value may be a string, or an object of Intl.PluralRules categories
+ * (`one`, `few`, `many`, `other`) when it is rendered with a count.
+ *
+ * Product vocabulary stays in English everywhere: `hub`, `node` and `GEK` name
+ * the CLI (`meshbay-node`), node.toml and the docs, so translating them would
+ * cut the interface off from everything an operator reads and types.
+ */
+
+export default {
+ // 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': {
+ one: 'Use at least {n} character',
+ other: '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': {
+ one: '{n} file',
+ other: '{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': {
+ one: '{n} pinned',
+ other: '{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',
+
+ // Notifications
+ // Create group
+ 'create_group.title': 'Create Group',
+ 'create_group.name': 'Group name',
+ 'create_group.name_placeholder': 'e.g. Family Photos, Project Files...',
+ 'create_group.visibility': 'Visibility',
+ 'create_group.private': 'Private',
+ 'create_group.private_desc': 'Only invited members can see and access files',
+ 'create_group.public': 'Public',
+ 'create_group.public_desc': 'Anyone can discover and browse this group',
+ 'create_group.join_policy': 'Join policy',
+ 'create_group.invite': 'Invite only',
+ 'create_group.invite_desc': 'Members must be invited by an admin',
+ 'create_group.open': 'Open (anyone can join)',
+ 'create_group.open_desc': 'Anyone can join without approval',
+ '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...',
+
+ // 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': {
+ one: 'Delete {n} item? {names}',
+ other: 'Delete {n} items? {names}',
+ },
+ 'group.download_zip': {
+ one: 'Download as zip ({n} file)',
+ other: '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',
+
+ 'notif.purge': 'Clear all',
+ 'notif.title': 'Notifications',
+ 'notif.empty': 'No notifications',
+ 'notif.mark_all_read': 'Mark all read',
+
+ // Sidebar
+ 'sidebar.search': 'Search files',
+
+ // Status
+
+ // 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': {
+ one: '{n} file found',
+ other: '{n} files found',
+ },
+ 'search.hint': 'Search file names across all cached group indexes.',
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
new file mode 100644
index 0000000..c09175b
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -0,0 +1,402 @@
+/**
+ * Spanish — translated from locales/en.js.
+ *
+ * Register: formal (usted).
+ * `hub`, `node` and `GEK` are left in English: they name the CLI
+ * (`meshbay-node`), node.toml and the docs, which are English throughout.
+ */
+
+export default {
+ // Nav
+ 'nav.toggle_menu': 'Mostrar el menú',
+ 'nav.light_mode': 'Tema claro',
+ 'nav.dark_mode': 'Tema oscuro',
+ 'nav.logout': 'Cerrar sesión',
+ 'nav.login': 'Iniciar sesión',
+
+ // Sidebar
+ 'sidebar.my_groups': 'Mis grupos',
+ 'sidebar.no_groups': 'Aún no hay grupos',
+ 'sidebar.discover': 'Descubrir',
+ 'sidebar.public_groups': 'Grupos públicos',
+
+ // Login
+ 'login.title': 'Iniciar sesión',
+ 'login.username': 'Nombre de usuario',
+ 'login.password': 'Contraseña',
+ 'login.submit': 'Iniciar sesión',
+ 'login.loading': 'Iniciando sesión...',
+ 'login.no_account': '¿No tiene cuenta?',
+ 'login.register_link': 'Crear una cuenta',
+
+ // Register
+ 'register.title': 'Crear una cuenta',
+ 'register.username': 'Nombre de usuario',
+ 'register.email': 'Correo electrónico',
+ 'register.password': 'Frase de contraseña (mínimo 12 caracteres)',
+ 'register.confirm': 'Confirmar contraseña',
+ 'register.submit': 'Crear la cuenta',
+ 'register.loading': 'Creando la cuenta...',
+ 'register.has_account': '¿Ya tiene una cuenta?',
+ 'register.login_link': 'Iniciar sesión',
+ 'register.success_title': 'Cuenta creada',
+ 'register.success_msg': 'Ya puede iniciar sesión con sus credenciales.',
+ 'register.go_login': 'Ir al inicio de sesión',
+ 'register.err_mismatch': 'Las contraseñas no coinciden',
+ 'register.err_min_len': {
+ one: 'Use al menos {n} carácter',
+ other: 'Use al menos {n} caracteres',
+ },
+ 'register.err_too_weak': 'Demasiado fácil de adivinar. Su frase de contraseña es lo '
+ + 'que protege sus claves allí donde están almacenadas — unas cuantas palabras sin '
+ + 'relación entre sí funcionan muy bien.',
+ 'register.strength': 'Solidez: unos {bits} bits. Protege la copia de sus claves que '
+ + 'se guarda en los nodes a los que se une, así que conviene acertar.',
+
+ // Home
+ 'home.welcome': 'Bienvenido a MeshBay',
+ 'home.no_groups': 'Todavía no es miembro de ningún grupo.',
+ 'home.browse_prefix': 'Explore los ',
+ 'home.browse_link': 'grupos públicos',
+ 'home.browse_suffix': ' o pida a un administrador de grupo que le invite.',
+ 'home.my_groups': 'Mis grupos',
+
+ // Explore
+ 'explore.title': 'Grupos públicos',
+ 'explore.search': 'Buscar grupos...',
+ 'explore.loading': 'Cargando...',
+ 'explore.empty': 'No hay grupos públicos disponibles.',
+ 'explore.create_group': 'Crear un grupo',
+ 'explore.join': 'Unirse',
+ 'explore.member': 'Miembro',
+
+ // Group page
+ 'group.default_name': 'Grupo',
+ 'group.tab_files': 'Archivos',
+ 'group.tab_chat': 'Chat',
+ 'group.tab_members': 'Miembros',
+ 'group.filter': 'Filtrar archivos...',
+ 'group.col_name': 'Nombre',
+ 'group.col_size': 'Tamaño',
+ 'group.col_type': 'Tipo',
+ 'group.col_date': 'Fecha',
+ 'group.empty_filter': 'Ningún archivo coincide con el filtro',
+ 'group.empty_dir': 'Esta carpeta está vacía',
+ 'group.download': 'Descargar',
+ 'group.play': 'Reproducir',
+ 'group.offline_title': 'Ningún node está en línea para este grupo en este momento.',
+ 'group.offline_hint': 'Los archivos aparecerán cuando se conecte un node que aloje '
+ + 'este grupo.',
+ 'group.upload': 'Subir',
+ 'group.mkdir': 'Nueva carpeta',
+ 'group.mkdir_prompt': 'Nombre de la nueva carpeta:',
+ 'group.view': 'Ver',
+ 'group.delete': 'Eliminar',
+ 'group.delete_confirm': '¿Eliminar {name}?',
+ 'group.delete_group': 'Eliminar el grupo',
+ 'group.delete_group_confirm': '¿Eliminar «{name}» de forma permanente y expulsar a '
+ + 'todos los miembros? Los archivos que están en el node se conservan.',
+ 'group.err_transport': 'Módulo de transporte no cargado',
+
+ // Status
+ 'status.idle': 'Inactivo',
+ 'status.discovering': 'Buscando nodes...',
+ 'status.connecting': 'Conectando por WebRTC...',
+ 'status.fetching': 'Obteniendo el índice...',
+ 'status.files': {
+ one: '{n} archivo',
+ other: '{n} archivos',
+ },
+ 'status.offline': 'Ningún node en línea',
+ 'status.error': 'Error de conexión',
+
+ // Chat
+ 'chat.empty': 'Todavía no hay mensajes. ¡Empiece la conversación!',
+ 'chat.placeholder': 'Escriba un mensaje...',
+ 'chat.send': 'Enviar',
+ 'chat.attach': 'Adjuntar archivo',
+
+ // Video player
+ 'video.loading': 'Cargando {name}...',
+ 'video.buffering': 'Almacenando en búfer...',
+ 'video.close': 'Cerrar (Esc)',
+ 'preview.pdf_fallback': 'Este navegador no mostrará el PDF integrado. Descárguelo '
+ + 'en su lugar — en cualquier caso se descifró aquí.',
+ 'group.upload_indexing': 'indexando…',
+ 'video.err_transport': 'Transporte no conectado',
+ 'video.err_mse': 'Códec no compatible con la reproducción en continuo: {codec}',
+
+ // Settings
+ 'settings.title': 'Ajustes',
+ 'settings.coming_soon': 'Próximamente.',
+ 'settings.downloads': 'Descargas',
+ 'settings.dl_auto': 'Guardar automáticamente',
+ 'settings.dl_auto_hint': 'Los archivos van directamente a la carpeta que elija más '
+ + 'abajo, sin ningún diálogo. Descargar veinte archivos deja allí veinte archivos.',
+ 'settings.dl_ask': 'Preguntar cada vez',
+ 'settings.dl_ask_hint': 'Un diálogo «Guardar como» por archivo — uno por archivo, '
+ + 'también cuando descarga una selección.',
+ 'settings.dl_folder': 'Carpeta: {name}',
+ 'settings.dl_no_folder': 'Ninguna carpeta elegida — las descargas van adonde las '
+ + 'ponga su navegador',
+ 'settings.dl_choose': 'Elegir carpeta',
+ 'settings.dl_change': 'Cambiar',
+ 'settings.dl_forget': 'Olvidar',
+ 'settings.dl_path_note': 'A una página web no se le puede indicar una ruta, así que '
+ + 'aquí no hay nada que escribir: su navegador concede acceso a la carpeta que '
+ + 'usted señale, y MeshBay solo escribe dentro de ella. Es posible que se le pida '
+ + 'confirmación una vez por sesión.',
+ 'settings.dl_unsupported': 'Este navegador no puede escribir en una carpeta de su '
+ + 'elección (no tiene File System Access API), de modo que las descargas van a su '
+ + 'propia carpeta de descargas. Chrome y Edge sí permiten elegir una.',
+ 'settings.profile': 'Perfil',
+ 'settings.username': 'Nombre de usuario',
+ 'settings.node_pins': 'Identidades de los nodes',
+ 'settings.node_pins_hint': 'La clave de identidad de cada node se memoriza la '
+ + 'primera vez que se conecta. Si cambia, la conexión se rechaza — algo esperable '
+ + 'solo cuando un operador reinstala un node. Confírmelo con él antes de borrarla.',
+ 'settings.node_pins_count': {
+ one: '{n} fijada',
+ other: '{n} fijadas',
+ },
+ 'settings.node_pins_clear': 'Borrar las identidades fijadas',
+ 'settings.danger': 'Eliminar la cuenta',
+ 'settings.delete_hint': 'Elimina su cuenta, sus pertenencias a grupos y sus '
+ + 'notificaciones del hub, y libera su nombre de usuario. No alcanza lo que vive '
+ + 'en los nodes: los archivos que subió se quedan donde los guarda su operador, y '
+ + 'cada node conserva la identidad que fijó para usted hasta que su operador la '
+ + 'retire. Los registros de conexión se guardan un año, como exige la ley.',
+ 'settings.delete_account': 'Eliminar mi cuenta',
+ 'settings.delete_confirm': 'Escriba su frase de contraseña para confirmar. '
+ + 'Esta acción no se puede deshacer.',
+ 'settings.delete_confirm_btn': 'Eliminar definitivamente',
+ 'settings.cancel': 'Cancelar',
+ 'settings.appearance': 'Apariencia',
+ 'settings.theme': 'Tema',
+ 'settings.theme_light': 'Claro',
+ 'settings.theme_dark': 'Oscuro',
+ 'settings.theme_system': 'Sistema',
+ 'settings.language': 'Idioma',
+ 'settings.about': 'Acerca de',
+ 'settings.version': 'Versión',
+ 'settings.protocol': 'Protocolo',
+ 'settings.role': 'Rol',
+ 'settings.groups': 'Notificaciones de grupo',
+ 'settings.notifications': 'Notificaciones',
+ 'settings.node_key': 'Vincular un node',
+ 'settings.node_key_desc': 'Si administra un node MeshBay, pegue aquí su clave '
+ + 'pública Ed25519 para vincularlo a su cuenta. Encontrará esa clave en el panel '
+ + 'de administración de su node.',
+ 'settings.node_key_placeholder': 'Pegue la clave pública Ed25519 del node (base64)',
+ 'settings.node_key_submit': 'Vincular el node',
+ 'settings.node_key_success': 'Node vinculado correctamente',
+ 'settings.node_key_current': 'Clave del node vinculado',
+
+ // Sidebar
+ 'sidebar.create_group': 'Crear un grupo',
+ 'sidebar.settings': 'Ajustes',
+ 'sidebar.admin': 'Administración',
+
+ // User menu
+ 'usermenu.language': 'Idioma',
+ 'usermenu.settings': 'Ajustes',
+ 'usermenu.theme_light': 'Tema claro',
+ 'usermenu.theme_dark': 'Tema oscuro',
+ 'usermenu.logout': 'Cerrar sesión',
+
+ // Admin panel
+ 'admin.title': 'Administración',
+ 'admin.tab_stats': 'Estadísticas',
+ 'admin.tab_users': 'Usuarios',
+ 'admin.tab_groups': 'Grupos',
+ 'admin.tab_nodes': 'Nodes',
+ 'admin.tab_logs': 'Registros',
+ 'admin.col_observed_ip': 'Visto desde',
+ 'admin.col_hint': 'Dirección anunciada',
+ 'admin.col_last_seen': 'Último anuncio',
+ 'admin.node_online': 'en línea',
+ 'admin.node_offline': 'desconectado',
+ 'admin.no_nodes': 'Ningún node registrado',
+ 'admin.nodes_hint': '«Visto desde» es la dirección desde la que llegó el anuncio, '
+ + 'en una petición firmada con la clave del node — esa es la que sirve para '
+ + 'responder a un requerimiento. La dirección anunciada es la que el node cree que '
+ + 'es la suya, aprendida de un servidor STUN y enviada aquí; sirve para alcanzar el '
+ + 'node y no prueba nada.',
+ 'admin.tab_blocklist': 'Lista de bloqueo',
+
+ // Admin stats
+ 'admin.stat_users': 'Usuarios',
+ 'admin.stat_groups': 'Grupos',
+ 'admin.stat_nodes': 'Nodes',
+ 'admin.stat_online': 'Nodes en línea',
+
+ // Admin users
+ 'admin.users_search': 'Buscar usuarios...',
+ 'admin.col_username': 'Nombre de usuario',
+ 'admin.col_role': 'Rol',
+ 'admin.col_status': 'Estado',
+ 'admin.col_created': 'Creación',
+ 'admin.col_actions': 'Acciones',
+ 'admin.col_email': 'Correo electrónico',
+ 'admin.col_groups': 'Grupos',
+ 'admin.no_users': 'No se ha encontrado ningún usuario',
+ 'admin.btn_suspend': 'Suspender',
+ 'admin.btn_unsuspend': 'Reactivar',
+ 'admin.btn_details': 'Detalles',
+ 'admin.btn_delete': 'Eliminar',
+ 'admin.delete_confirm': '¿Eliminar la cuenta «{user}»? Esta acción no se puede '
+ + 'deshacer. Los archivos que subió permanecen en los nodes que los alojan, y cada '
+ + 'node conserva la identidad que fijó hasta que su operador la retire. La '
+ + 'suspensión es la opción reversible.',
+ 'admin.user_detail': 'Detalles del usuario',
+ 'admin.btn_close': 'Cerrar',
+ 'admin.self_note': '(usted)',
+
+ // Admin groups
+ 'admin.col_name': 'Nombre',
+ 'admin.col_visibility': 'Visibilidad',
+ 'admin.col_members': 'Miembros',
+ 'admin.no_groups': 'No se ha encontrado ningún grupo',
+
+ // Admin logs
+ 'admin.col_time': 'Hora',
+ 'admin.col_event': 'Evento',
+ 'admin.col_user': 'Usuario',
+ 'admin.col_ip': 'IP',
+ 'admin.col_detail': 'Detalle',
+ 'admin.filter_all': 'Todos los eventos',
+ 'admin.no_logs': 'No se ha encontrado ningún registro',
+ 'admin.btn_load_more': 'Cargar más',
+
+ // Admin blocklist
+ 'admin.col_hash': 'Huella',
+ 'admin.col_reason': 'Motivo',
+ 'admin.col_date': 'Fecha',
+ 'admin.col_added_by': 'Añadida por',
+ 'admin.no_blocked': 'Ninguna huella bloqueada',
+ 'admin.add_hash': 'Añadir una huella a la lista de bloqueo',
+ 'admin.hash_placeholder': 'huella blake3 (64 caracteres hexadecimales)',
+ 'admin.reason_placeholder': 'Motivo',
+ 'admin.btn_block': 'Bloquear',
+ 'admin.btn_unblock': 'Desbloquear',
+
+ // Notifications
+ // Create group
+ 'create_group.title': 'Crear un grupo',
+ 'create_group.name': 'Nombre del grupo',
+ 'create_group.name_placeholder': 'p. ej. Fotos de familia, Archivos del proyecto...',
+ 'create_group.visibility': 'Visibilidad',
+ 'create_group.private': 'Privado',
+ 'create_group.private_desc': 'Solo los miembros invitados ven los archivos y acceden a ellos',
+ 'create_group.public': 'Público',
+ 'create_group.public_desc': 'Cualquiera puede descubrir y explorar este grupo',
+ 'create_group.join_policy': 'Política de acceso',
+ 'create_group.invite': 'Solo por invitación',
+ 'create_group.invite_desc': 'Los miembros deben ser invitados por un administrador',
+ 'create_group.open': 'Abierto (cualquiera puede unirse)',
+ 'create_group.open_desc': 'Cualquiera puede unirse sin aprobación',
+ 'create_group.description': 'Descripción',
+ 'create_group.description_hint': '¿De qué trata este grupo? (opcional)',
+ 'create_group.submit': 'Crear',
+ 'create_group.hint': 'Un grupo necesita un node para alojar los archivos. Puede '
+ + 'crear el grupo ahora y conectar un node más adelante.',
+ 'create_group.creating': 'Creando...',
+
+ // Members
+ 'members.col_role': 'Rol',
+ 'members.group_role': 'Rol en el grupo',
+ 'members.admin': 'Administrador',
+ 'members.group_admin': 'Administrador del grupo',
+ 'members.owner': 'Propietario',
+ 'members.member': 'Miembro',
+ 'members.invite_title': 'Invitar a un miembro',
+ 'members.username_placeholder': 'Nombre de usuario',
+ 'members.invite_btn': 'Invitar',
+ 'members.invite_needs_pairing': 'Las invitaciones se firman con la clave que este '
+ + 'node fijó para su navegador, así que empareje el navegador abajo antes de '
+ + 'invitar a nadie.',
+ 'members.invite_ask_operator': 'Solo el operador del node que aloja este grupo puede '
+ + 'invitar, desde un navegador emparejado con él.',
+ 'members.remove': 'Expulsar',
+ 'members.remove_confirm': '¿Expulsar a {user} de este grupo? Su cuenta, sus otros '
+ + 'grupos y los archivos que subió no se ven afectados.',
+ 'members.remove_hint': 'Expulsar a alguien le quita la pertenencia al grupo e impide '
+ + 'que el node le entregue la clave del grupo. No elimina su cuenta, y no recupera '
+ + 'la clave que ya tiene en su poder — rótela en el node (meshbay-node gek-init) si '
+ + 'eso importa.',
+ 'members.pair_title': 'Emparejar este navegador con su node',
+ 'members.pair_hint': 'Su node solo acepta acciones de operador — invitaciones, '
+ + 'eliminación de archivos — desde un navegador con el que se haya emparejado. '
+ + 'Ejecute `meshbay-node operator pair` en el node y escriba aquí el código. El '
+ + 'código nunca pasa por el hub.',
+ 'members.pair_btn': 'Emparejar',
+ 'members.pair_success': 'Este navegador ya está emparejado con el node.',
+ 'members.invite_code_ready': 'Código de invitación para {user} — envíeselo por el '
+ + 'medio que usen habitualmente. Funciona una sola vez y nunca pasa por el hub.',
+ 'members.invite_code_hint': 'Lo introduce la primera vez que abre este grupo. No '
+ + 'necesita que usted esté en línea en ese momento.',
+ 'transfers.title': 'Transferencias',
+ 'transfers.clear': 'Borrar las terminadas',
+ 'transfers.cancel': 'Cancelar',
+ 'transfers.open': 'Abrir',
+ 'transfers.done': 'Terminada',
+ 'transfers.cancelled': 'Cancelada',
+ 'transfers.failed': 'Fallida',
+ 'group.select': 'Seleccionar',
+ 'group.select_done': 'Listo',
+ 'group.actions': 'Acciones ({n})',
+ 'group.download_n': 'Descargar ({n})',
+ 'group.download_zip_n': 'Descargar las carpetas en zip ({n})',
+ 'group.delete_n': 'Eliminar ({n})',
+ 'group.delete_n_confirm': {
+ one: '¿Eliminar {n} elemento? {names}',
+ other: '¿Eliminar {n} elementos? {names}',
+ },
+ 'group.download_zip': {
+ one: 'Descargar en zip ({n} archivo)',
+ other: 'Descargar en zip ({n} archivos)',
+ },
+ 'group.zip_empty': 'Esa carpeta no contiene nada que descargar.',
+ 'group.zip_no_stream': 'Este navegador no puede escribir una descarga directamente '
+ + 'en el disco, así que {name} ({size}) tiene que construirse antes en memoria. Con '
+ + 'un archivo comprimido grande eso puede fallar. ¿Continuar?',
+ 'group.rmdir': 'Eliminar la carpeta',
+ 'group.rmdir_confirm': '¿Eliminar la carpeta «{name}»? Debe estar vacía.',
+ 'group.rmdir_not_empty': 'Esa carpeta no está vacía. Elimine antes lo que contiene — '
+ + 'aquí nada borra archivos que usted no ve.',
+ 'group.desc_edit': 'Editar la descripción',
+ 'group.desc_add': 'Añadir una descripción',
+ 'group.desc_save': 'Guardar',
+ 'group.desc_cancel': 'Cancelar',
+ 'group.desc_placeholder': 'Para qué sirve este grupo — los miembros lo ven en su '
+ + 'página de inicio.',
+ 'group.join_code_title': 'Este node necesita reconocerle',
+ 'group.join_code_hint': 'Pida el código de un solo uso a quien le invitó e '
+ + 'introdúzcalo aquí. Después, este navegador queda reconocido y no se le volverá a '
+ + 'preguntar.',
+ 'group.join_code_btn': 'Unirse',
+
+ 'notif.purge': 'Borrar todo',
+ 'notif.title': 'Notificaciones',
+ 'notif.empty': 'Ninguna notificación',
+ 'notif.mark_all_read': 'Marcar todo como leído',
+
+ // Sidebar
+ 'sidebar.search': 'Buscar archivos',
+
+ // Status
+
+ // Search
+ 'search.title': 'Buscar archivos',
+ 'search.placeholder': 'Buscar en todos los grupos...',
+ 'search.no_results': 'Ningún archivo coincide con su búsqueda.',
+ 'search.synced': 'sincronizado {ago}',
+ 'search.cache_note': 'La búsqueda usa el estado que tenía cada grupo la última vez '
+ + 'que lo abrió. Abra un grupo para poner sus archivos al día aquí.',
+ 'search.col_group': 'Grupo',
+ 'search.result_count': {
+ one: '{n} archivo encontrado',
+ other: '{n} archivos encontrados',
+ },
+ 'search.hint': 'Busca nombres de archivo en todos los índices de grupo en caché.',
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
new file mode 100644
index 0000000..845e7ce
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -0,0 +1,407 @@
+/**
+ * French — translated from locales/en.js.
+ *
+ * Register: formal (vouvoiement).
+ * `hub`, `node` and `GEK` are left in English: they name the CLI
+ * (`meshbay-node`), node.toml and the docs, which are English throughout.
+ */
+
+export default {
+ // Nav
+ 'nav.toggle_menu': 'Afficher le menu',
+ 'nav.light_mode': 'Thème clair',
+ 'nav.dark_mode': 'Thème sombre',
+ 'nav.logout': 'Déconnexion',
+ 'nav.login': 'Connexion',
+
+ // Sidebar
+ 'sidebar.my_groups': 'Mes groupes',
+ 'sidebar.no_groups': 'Aucun groupe pour le moment',
+ 'sidebar.discover': 'Découvrir',
+ 'sidebar.public_groups': 'Groupes publics',
+
+ // Login
+ 'login.title': 'Connexion',
+ 'login.username': "Nom d'utilisateur",
+ 'login.password': 'Mot de passe',
+ 'login.submit': 'Se connecter',
+ 'login.loading': 'Connexion en cours...',
+ 'login.no_account': 'Pas encore de compte ?',
+ 'login.register_link': 'Créer un compte',
+
+ // Register
+ 'register.title': 'Créer un compte',
+ 'register.username': "Nom d'utilisateur",
+ 'register.email': 'Adresse e-mail',
+ 'register.password': 'Phrase secrète (12 caractères minimum)',
+ 'register.confirm': 'Confirmer le mot de passe',
+ 'register.submit': 'Créer le compte',
+ 'register.loading': 'Création du compte...',
+ 'register.has_account': 'Vous avez déjà un compte ?',
+ 'register.login_link': 'Se connecter',
+ 'register.success_title': 'Compte créé',
+ 'register.success_msg': 'Vous pouvez maintenant vous connecter avec vos identifiants.',
+ 'register.go_login': 'Aller à la connexion',
+ 'register.err_mismatch': 'Les mots de passe ne correspondent pas',
+ 'register.err_min_len': {
+ one: 'Utilisez au moins {n} caractère',
+ other: 'Utilisez au moins {n} caractères',
+ },
+ 'register.err_too_weak': 'Trop facile à deviner. Votre phrase secrète est ce qui '
+ + 'protège vos clés là où elles sont stockées — quelques mots sans rapport entre '
+ + 'eux font très bien l’affaire.',
+ 'register.strength': 'Robustesse : environ {bits} bits. Elle protège la copie de '
+ + 'vos clés conservée sur les nodes que vous rejoignez, cela vaut donc la peine '
+ + 'de la soigner.',
+
+ // Home
+ 'home.welcome': 'Bienvenue sur MeshBay',
+ 'home.no_groups': "Vous n'êtes membre d'aucun groupe pour le moment.",
+ 'home.browse_prefix': 'Parcourez les ',
+ 'home.browse_link': 'groupes publics',
+ 'home.browse_suffix': " ou demandez à l'administrateur d'un groupe de vous inviter.",
+ 'home.my_groups': 'Mes groupes',
+
+ // Explore
+ 'explore.title': 'Groupes publics',
+ 'explore.search': 'Rechercher un groupe...',
+ 'explore.loading': 'Chargement...',
+ 'explore.empty': 'Aucun groupe public disponible.',
+ 'explore.create_group': 'Créer un groupe',
+ 'explore.join': 'Rejoindre',
+ 'explore.member': 'Membre',
+
+ // Group page
+ 'group.default_name': 'Groupe',
+ 'group.tab_files': 'Fichiers',
+ 'group.tab_chat': 'Discussion',
+ 'group.tab_members': 'Membres',
+ 'group.filter': 'Filtrer les fichiers...',
+ 'group.col_name': 'Nom',
+ 'group.col_size': 'Taille',
+ 'group.col_type': 'Type',
+ 'group.col_date': 'Date',
+ 'group.empty_filter': 'Aucun fichier ne correspond au filtre',
+ 'group.empty_dir': 'Ce dossier est vide',
+ 'group.download': 'Télécharger',
+ 'group.play': 'Lire',
+ 'group.offline_title': "Aucun node n'est actuellement en ligne pour ce groupe.",
+ 'group.offline_hint': 'Les fichiers apparaîtront dès qu’un node hébergeant ce '
+ + 'groupe se connectera.',
+ 'group.upload': 'Envoyer',
+ 'group.mkdir': 'Nouveau dossier',
+ 'group.mkdir_prompt': 'Nom du nouveau dossier :',
+ 'group.view': 'Afficher',
+ 'group.delete': 'Supprimer',
+ 'group.delete_confirm': 'Supprimer {name} ?',
+ 'group.delete_group': 'Supprimer le groupe',
+ 'group.delete_group_confirm': 'Supprimer définitivement « {name} » et en retirer '
+ + 'tous les membres ? Les fichiers présents sur le node sont conservés.',
+ 'group.err_transport': 'Module de transport non chargé',
+
+ // Status
+ 'status.idle': 'Inactif',
+ 'status.discovering': 'Recherche de nodes...',
+ 'status.connecting': 'Connexion via WebRTC...',
+ 'status.fetching': "Récupération de l'index...",
+ 'status.files': {
+ one: '{n} fichier',
+ other: '{n} fichiers',
+ },
+ 'status.offline': 'Aucun node en ligne',
+ 'status.error': 'Échec de la connexion',
+
+ // Chat
+ 'chat.empty': 'Aucun message pour le moment. Lancez la conversation !',
+ 'chat.placeholder': 'Écrivez un message...',
+ 'chat.send': 'Envoyer',
+ 'chat.attach': 'Joindre un fichier',
+
+ // Video player
+ 'video.loading': 'Chargement de {name}...',
+ 'video.buffering': 'Mise en mémoire tampon...',
+ 'video.close': 'Fermer (Échap)',
+ 'preview.pdf_fallback': 'Ce navigateur n’affichera pas le PDF directement. '
+ + 'Téléchargez-le plutôt — il a été déchiffré ici dans les deux cas.',
+ 'group.upload_indexing': 'indexation…',
+ 'video.err_transport': 'Transport non connecté',
+ 'video.err_mse': 'Codec non pris en charge pour la diffusion : {codec}',
+
+ // Settings
+ 'settings.title': 'Paramètres',
+ 'settings.coming_soon': 'Bientôt disponible.',
+ 'settings.downloads': 'Téléchargements',
+ 'settings.dl_auto': 'Enregistrer automatiquement',
+ 'settings.dl_auto_hint': 'Les fichiers vont directement dans le dossier choisi '
+ + 'ci-dessous, sans boîte de dialogue. Télécharger vingt fichiers en dépose vingt.',
+ 'settings.dl_ask': 'Demander à chaque fois',
+ 'settings.dl_ask_hint': 'Une boîte de dialogue « Enregistrer sous » par fichier — '
+ + 'un par fichier, y compris lorsque vous téléchargez une sélection.',
+ 'settings.dl_folder': 'Dossier : {name}',
+ 'settings.dl_no_folder': 'Aucun dossier choisi — les téléchargements vont là où '
+ + 'votre navigateur les place',
+ 'settings.dl_choose': 'Choisir un dossier',
+ 'settings.dl_change': 'Changer',
+ 'settings.dl_forget': 'Oublier',
+ 'settings.dl_path_note': 'On ne peut pas indiquer un chemin à une page web, il n’y '
+ + 'a donc rien à saisir ici : votre navigateur donne accès au dossier que vous '
+ + 'désignez, et MeshBay n’écrit jamais ailleurs qu’à l’intérieur. Une '
+ + 'confirmation peut vous être demandée une fois par session.',
+ 'settings.dl_unsupported': 'Ce navigateur ne sait pas écrire dans un dossier de '
+ + 'votre choix (pas de File System Access API) ; les téléchargements vont donc '
+ + 'dans son propre dossier de téléchargement. Chrome et Edge permettent d’en '
+ + 'choisir un.',
+ 'settings.profile': 'Profil',
+ 'settings.username': "Nom d'utilisateur",
+ 'settings.node_pins': 'Identités des nodes',
+ 'settings.node_pins_hint': "La clé d'identité de chaque node est mémorisée lors de "
+ + 'la première connexion. Si elle change, la connexion est refusée — ce qui n’est '
+ + 'normal que lorsqu’un opérateur réinstalle un node. Vérifiez auprès de lui '
+ + 'avant d’effacer.',
+ 'settings.node_pins_count': {
+ one: '{n} épinglée',
+ other: '{n} épinglées',
+ },
+ 'settings.node_pins_clear': 'Effacer les identités épinglées',
+ 'settings.danger': 'Supprimer le compte',
+ 'settings.delete_hint': 'Supprime votre compte, vos adhésions aux groupes et vos '
+ + 'notifications du hub, et libère votre nom d’utilisateur. Cela n’atteint pas '
+ + 'ce qui se trouve sur les nodes : les fichiers que vous avez envoyés restent là '
+ + 'où leur opérateur les conserve, et chaque node garde l’identité qu’il a '
+ + 'épinglée pour vous jusqu’à ce que son opérateur la retire. Les journaux de '
+ + 'connexion sont conservés un an, comme la loi l’exige.',
+ 'settings.delete_account': 'Supprimer mon compte',
+ 'settings.delete_confirm': 'Saisissez votre phrase secrète pour confirmer. '
+ + 'Cette action est irréversible.',
+ 'settings.delete_confirm_btn': 'Supprimer définitivement',
+ 'settings.cancel': 'Annuler',
+ 'settings.appearance': 'Apparence',
+ 'settings.theme': 'Thème',
+ 'settings.theme_light': 'Clair',
+ 'settings.theme_dark': 'Sombre',
+ 'settings.theme_system': 'Système',
+ 'settings.language': 'Langue',
+ 'settings.about': 'À propos',
+ 'settings.version': 'Version',
+ 'settings.protocol': 'Protocole',
+ 'settings.role': 'Rôle',
+ 'settings.groups': 'Notifications de groupe',
+ 'settings.notifications': 'Notifications',
+ 'settings.node_key': 'Associer un node',
+ 'settings.node_key_desc': 'Si vous exploitez un node MeshBay, collez ici sa clé '
+ + 'publique Ed25519 pour l’associer à votre compte. Vous trouverez cette clé '
+ + 'dans le tableau de bord d’administration de votre node.',
+ 'settings.node_key_placeholder': 'Collez la clé publique Ed25519 du node (base64)',
+ 'settings.node_key_submit': 'Associer le node',
+ 'settings.node_key_success': 'Node associé avec succès',
+ 'settings.node_key_current': 'Clé du node associé',
+
+ // Sidebar
+ 'sidebar.create_group': 'Créer un groupe',
+ 'sidebar.settings': 'Paramètres',
+ 'sidebar.admin': 'Administration',
+
+ // User menu
+ 'usermenu.language': 'Langue',
+ 'usermenu.settings': 'Paramètres',
+ 'usermenu.theme_light': 'Thème clair',
+ 'usermenu.theme_dark': 'Thème sombre',
+ 'usermenu.logout': 'Déconnexion',
+
+ // Admin panel
+ 'admin.title': 'Administration',
+ 'admin.tab_stats': 'Statistiques',
+ 'admin.tab_users': 'Utilisateurs',
+ 'admin.tab_groups': 'Groupes',
+ 'admin.tab_nodes': 'Nodes',
+ 'admin.tab_logs': 'Journaux',
+ 'admin.col_observed_ip': 'Vu depuis',
+ 'admin.col_hint': 'Adresse annoncée',
+ 'admin.col_last_seen': 'Dernière annonce',
+ 'admin.node_online': 'en ligne',
+ 'admin.node_offline': 'hors ligne',
+ 'admin.no_nodes': 'Aucun node enregistré',
+ 'admin.nodes_hint': '« Vu depuis » est l’adresse d’où l’annonce nous est '
+ + 'parvenue, sur une requête signée avec la clé du node — c’est celle sur '
+ + 'laquelle s’appuyer pour répondre à une réquisition. L’adresse annoncée est '
+ + 'celle que le node croit être la sienne, apprise d’un serveur STUN puis '
+ + 'transmise ; elle sert à joindre le node et ne prouve rien.',
+ 'admin.tab_blocklist': 'Liste de blocage',
+
+ // Admin stats
+ 'admin.stat_users': 'Utilisateurs',
+ 'admin.stat_groups': 'Groupes',
+ 'admin.stat_nodes': 'Nodes',
+ 'admin.stat_online': 'Nodes en ligne',
+
+ // Admin users
+ 'admin.users_search': 'Rechercher un utilisateur...',
+ 'admin.col_username': "Nom d'utilisateur",
+ 'admin.col_role': 'Rôle',
+ 'admin.col_status': 'Statut',
+ 'admin.col_created': 'Création',
+ 'admin.col_actions': 'Actions',
+ 'admin.col_email': 'E-mail',
+ 'admin.col_groups': 'Groupes',
+ 'admin.no_users': 'Aucun utilisateur trouvé',
+ 'admin.btn_suspend': 'Suspendre',
+ 'admin.btn_unsuspend': 'Réactiver',
+ 'admin.btn_details': 'Détails',
+ 'admin.btn_delete': 'Supprimer',
+ 'admin.delete_confirm': 'Supprimer le compte « {user} » ? Cette action est '
+ + 'irréversible. Les fichiers qu’il a envoyés restent sur les nodes qui les '
+ + 'hébergent, et chaque node conserve l’identité qu’il a épinglée jusqu’à ce '
+ + 'que son opérateur la retire. La suspension est l’option réversible.',
+ 'admin.user_detail': "Détails de l'utilisateur",
+ 'admin.btn_close': 'Fermer',
+ 'admin.self_note': '(vous)',
+
+ // Admin groups
+ 'admin.col_name': 'Nom',
+ 'admin.col_visibility': 'Visibilité',
+ 'admin.col_members': 'Membres',
+ 'admin.no_groups': 'Aucun groupe trouvé',
+
+ // Admin logs
+ 'admin.col_time': 'Horodatage',
+ 'admin.col_event': 'Événement',
+ 'admin.col_user': 'Utilisateur',
+ 'admin.col_ip': 'IP',
+ 'admin.col_detail': 'Détail',
+ 'admin.filter_all': 'Tous les événements',
+ 'admin.no_logs': 'Aucun journal trouvé',
+ 'admin.btn_load_more': 'Charger plus',
+
+ // Admin blocklist
+ 'admin.col_hash': 'Empreinte',
+ 'admin.col_reason': 'Motif',
+ 'admin.col_date': 'Date',
+ 'admin.col_added_by': 'Ajoutée par',
+ 'admin.no_blocked': 'Aucune empreinte bloquée',
+ 'admin.add_hash': 'Ajouter une empreinte à la liste de blocage',
+ 'admin.hash_placeholder': 'empreinte blake3 (64 caractères hexadécimaux)',
+ 'admin.reason_placeholder': 'Motif',
+ 'admin.btn_block': 'Bloquer',
+ 'admin.btn_unblock': 'Débloquer',
+
+ // Notifications
+ // Create group
+ 'create_group.title': 'Créer un groupe',
+ 'create_group.name': 'Nom du groupe',
+ 'create_group.name_placeholder': 'ex. Photos de famille, Documents du projet...',
+ 'create_group.visibility': 'Visibilité',
+ 'create_group.private': 'Privé',
+ 'create_group.private_desc': 'Seuls les membres invités voient les fichiers et y accèdent',
+ 'create_group.public': 'Public',
+ 'create_group.public_desc': 'Tout le monde peut découvrir et parcourir ce groupe',
+ 'create_group.join_policy': "Politique d'adhésion",
+ 'create_group.invite': 'Sur invitation',
+ 'create_group.invite_desc': 'Les membres doivent être invités par un administrateur',
+ 'create_group.open': 'Ouvert (tout le monde peut rejoindre)',
+ 'create_group.open_desc': 'Tout le monde peut rejoindre sans validation',
+ 'create_group.description': 'Description',
+ 'create_group.description_hint': 'De quoi parle ce groupe ? (facultatif)',
+ 'create_group.submit': 'Créer',
+ 'create_group.hint': 'Un groupe a besoin d’un node pour héberger les fichiers. '
+ + 'Vous pouvez créer le groupe maintenant et connecter un node plus tard.',
+ 'create_group.creating': 'Création...',
+
+ // Members
+ 'members.col_role': 'Rôle',
+ 'members.group_role': 'Rôle dans le groupe',
+ 'members.admin': 'Administrateur',
+ 'members.group_admin': 'Administrateur du groupe',
+ 'members.owner': 'Propriétaire',
+ 'members.member': 'Membre',
+ 'members.invite_title': 'Inviter un membre',
+ 'members.username_placeholder': "Nom d'utilisateur",
+ 'members.invite_btn': 'Inviter',
+ 'members.invite_needs_pairing': 'Les invitations sont signées avec la clé que ce '
+ + 'node a épinglée pour votre navigateur : appairez-le ci-dessous avant '
+ + 'd’inviter qui que ce soit.',
+ 'members.invite_ask_operator': 'Seul l’opérateur du node qui héberge ce groupe '
+ + 'peut inviter, depuis un navigateur appairé avec lui.',
+ 'members.remove': 'Retirer',
+ 'members.remove_confirm': 'Retirer {user} de ce groupe ? Son compte, ses autres '
+ + 'groupes et les fichiers qu’il a envoyés ne sont pas affectés.',
+ 'members.remove_hint': 'Retirer quelqu’un lui ôte son adhésion et empêche le node '
+ + 'de lui remettre la clé du groupe. Cela ne supprime pas son compte, et cela ne '
+ + 'reprend pas la clé qu’il détient déjà — faites-la tourner sur le node '
+ + '(meshbay-node gek-init) si cela compte.',
+ 'members.pair_title': 'Appairer ce navigateur avec votre node',
+ 'members.pair_hint': 'Votre node n’accepte les actions d’opérateur — invitations, '
+ + 'suppression de fichiers — que depuis un navigateur avec lequel il a été '
+ + 'appairé. Lancez `meshbay-node operator pair` sur le node et saisissez le code '
+ + 'ici. Le code ne transite jamais par le hub.',
+ 'members.pair_btn': 'Appairer',
+ 'members.pair_success': 'Ce navigateur est désormais appairé avec le node.',
+ 'members.invite_code_ready': 'Code d’invitation pour {user} — transmettez-le-lui '
+ + 'par le moyen dont vous avez l’habitude. Il ne fonctionne qu’une fois, et ne '
+ + 'transite jamais par le hub.',
+ 'members.invite_code_hint': 'La personne le saisit la première fois qu’elle ouvre '
+ + 'ce groupe. Vous n’avez pas besoin d’être en ligne à ce moment-là.',
+ 'transfers.title': 'Transferts',
+ 'transfers.clear': 'Effacer les terminés',
+ 'transfers.cancel': 'Annuler',
+ 'transfers.open': 'Ouvrir',
+ 'transfers.done': 'Terminé',
+ 'transfers.cancelled': 'Annulé',
+ 'transfers.failed': 'Échec',
+ 'group.select': 'Sélectionner',
+ 'group.select_done': 'Terminé',
+ 'group.actions': 'Actions ({n})',
+ 'group.download_n': 'Télécharger ({n})',
+ 'group.download_zip_n': 'Télécharger les dossiers en zip ({n})',
+ 'group.delete_n': 'Supprimer ({n})',
+ 'group.delete_n_confirm': {
+ one: 'Supprimer {n} élément ? {names}',
+ other: 'Supprimer {n} éléments ? {names}',
+ },
+ 'group.download_zip': {
+ one: 'Télécharger en zip ({n} fichier)',
+ other: 'Télécharger en zip ({n} fichiers)',
+ },
+ 'group.zip_empty': 'Ce dossier ne contient rien à télécharger.',
+ 'group.zip_no_stream': 'Ce navigateur ne sait pas écrire un téléchargement '
+ + 'directement sur le disque : {name} ({size}) doit donc être assemblé en mémoire '
+ + 'au préalable. Sur une archive volumineuse, cela peut échouer. Continuer ?',
+ 'group.rmdir': 'Supprimer le dossier',
+ 'group.rmdir_confirm': 'Supprimer le dossier « {name} » ? Il doit être vide.',
+ 'group.rmdir_not_empty': 'Ce dossier n’est pas vide. Supprimez d’abord ce qu’il '
+ + 'contient — rien ici ne supprime des fichiers que vous ne voyez pas.',
+ 'group.desc_edit': 'Modifier la description',
+ 'group.desc_add': 'Ajouter une description',
+ 'group.desc_save': 'Enregistrer',
+ 'group.desc_cancel': 'Annuler',
+ 'group.desc_placeholder': 'À quoi sert ce groupe — les membres le voient sur leur '
+ + 'page d’accueil.',
+ 'group.join_code_title': 'Ce node doit vous reconnaître',
+ 'group.join_code_hint': 'Demandez son code à usage unique à la personne qui vous a '
+ + 'invité, et saisissez-le ici. Ensuite, ce navigateur est reconnu et la question '
+ + 'ne vous sera plus posée.',
+ 'group.join_code_btn': 'Rejoindre',
+
+ 'notif.purge': 'Tout effacer',
+ 'notif.title': 'Notifications',
+ 'notif.empty': 'Aucune notification',
+ 'notif.mark_all_read': 'Tout marquer comme lu',
+
+ // Sidebar
+ 'sidebar.search': 'Rechercher des fichiers',
+
+ // Status
+
+ // Search
+ 'search.title': 'Rechercher des fichiers',
+ 'search.placeholder': 'Rechercher dans tous les groupes...',
+ 'search.no_results': 'Aucun fichier ne correspond à votre recherche.',
+ 'search.synced': 'synchronisé {ago}',
+ 'search.cache_note': 'La recherche s’appuie sur l’état de chaque groupe lors de '
+ + 'votre dernière visite. Ouvrez un groupe pour mettre ses fichiers à jour ici.',
+ 'search.col_group': 'Groupe',
+ 'search.result_count': {
+ one: '{n} fichier trouvé',
+ other: '{n} fichiers trouvés',
+ },
+ 'search.hint': 'Recherche les noms de fichiers dans tous les index de groupe en cache.',
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
new file mode 100644
index 0000000..e3c92ef
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -0,0 +1,404 @@
+/**
+ * Italian — translated from locales/en.js.
+ *
+ * Register: formal (forma di cortesia, "Lei").
+ * `hub`, `node` and `GEK` are left in English: they name the CLI
+ * (`meshbay-node`), node.toml and the docs, which are English throughout.
+ * Grammatical gender chosen here: l'hub (m.), il node (m.), la GEK → "la chiave".
+ */
+
+export default {
+ // Nav
+ 'nav.toggle_menu': 'Mostra il menu',
+ 'nav.light_mode': 'Tema chiaro',
+ 'nav.dark_mode': 'Tema scuro',
+ 'nav.logout': 'Esci',
+ 'nav.login': 'Accedi',
+
+ // Sidebar
+ 'sidebar.my_groups': 'I miei gruppi',
+ 'sidebar.no_groups': 'Ancora nessun gruppo',
+ 'sidebar.discover': 'Esplora',
+ 'sidebar.public_groups': 'Gruppi pubblici',
+
+ // Login
+ 'login.title': 'Accedi',
+ 'login.username': 'Nome utente',
+ 'login.password': 'Password',
+ 'login.submit': 'Accedi',
+ 'login.loading': 'Accesso in corso...',
+ 'login.no_account': 'Non ha ancora un account?',
+ 'login.register_link': 'Crea un account',
+
+ // Register
+ 'register.title': 'Crea un account',
+ 'register.username': 'Nome utente',
+ 'register.email': 'Indirizzo e-mail',
+ 'register.password': 'Passphrase (almeno 12 caratteri)',
+ 'register.confirm': 'Conferma la password',
+ 'register.submit': "Crea l'account",
+ 'register.loading': "Creazione dell'account...",
+ 'register.has_account': 'Ha già un account?',
+ 'register.login_link': 'Accedi',
+ 'register.success_title': 'Account creato',
+ 'register.success_msg': 'Ora può accedere con le sue credenziali.',
+ 'register.go_login': "Vai all'accesso",
+ 'register.err_mismatch': 'Le password non coincidono',
+ 'register.err_min_len': {
+ one: 'Usi almeno {n} carattere',
+ other: 'Usi almeno {n} caratteri',
+ },
+ 'register.err_too_weak': 'Troppo facile da indovinare. La passphrase è ciò che '
+ + 'protegge le sue chiavi là dove sono conservate — qualche parola senza alcun '
+ + 'legame tra loro funziona bene.',
+ 'register.strength': 'Robustezza: circa {bits} bit. Protegge la copia delle sue '
+ + 'chiavi conservata sui node a cui aderisce, quindi vale la pena sceglierla con cura.',
+
+ // Home
+ 'home.welcome': 'Benvenuto in MeshBay',
+ 'home.no_groups': 'Non fa ancora parte di nessun gruppo.',
+ 'home.browse_prefix': 'Dia un’occhiata ai ',
+ 'home.browse_link': 'gruppi pubblici',
+ 'home.browse_suffix': " oppure chieda a un amministratore di gruppo di invitarla.",
+ 'home.my_groups': 'I miei gruppi',
+
+ // Explore
+ 'explore.title': 'Gruppi pubblici',
+ 'explore.search': 'Cerca gruppi...',
+ 'explore.loading': 'Caricamento...',
+ 'explore.empty': 'Nessun gruppo pubblico disponibile.',
+ 'explore.create_group': 'Crea un gruppo',
+ 'explore.join': 'Partecipa',
+ 'explore.member': 'Iscritto',
+
+ // Group page
+ 'group.default_name': 'Gruppo',
+ 'group.tab_files': 'File',
+ 'group.tab_chat': 'Chat',
+ 'group.tab_members': 'Membri',
+ 'group.filter': 'Filtra i file...',
+ 'group.col_name': 'Nome',
+ 'group.col_size': 'Dimensione',
+ 'group.col_type': 'Tipo',
+ 'group.col_date': 'Data',
+ 'group.empty_filter': 'Nessun file corrisponde al filtro',
+ 'group.empty_dir': 'Questa cartella è vuota',
+ 'group.download': 'Scarica',
+ 'group.play': 'Riproduci',
+ 'group.offline_title': 'Al momento nessun node è online per questo gruppo.',
+ 'group.offline_hint': 'I file compariranno quando si collegherà un node che ospita '
+ + 'questo gruppo.',
+ 'group.upload': 'Carica',
+ 'group.mkdir': 'Nuova cartella',
+ 'group.mkdir_prompt': 'Nome della nuova cartella:',
+ 'group.view': 'Visualizza',
+ 'group.delete': 'Elimina',
+ 'group.delete_confirm': 'Eliminare {name}?',
+ 'group.delete_group': 'Elimina il gruppo',
+ 'group.delete_group_confirm': 'Eliminare definitivamente «{name}» e rimuovere tutti '
+ + 'i membri? I file presenti sul node vengono conservati.',
+ 'group.err_transport': 'Modulo di trasporto non caricato',
+
+ // Status
+ 'status.idle': 'Inattivo',
+ 'status.discovering': 'Ricerca dei node...',
+ 'status.connecting': 'Connessione tramite WebRTC...',
+ 'status.fetching': "Recupero dell'indice...",
+ 'status.files': {
+ one: '{n} file',
+ other: '{n} file',
+ },
+ 'status.offline': 'Nessun node online',
+ 'status.error': 'Connessione non riuscita',
+
+ // Chat
+ 'chat.empty': 'Ancora nessun messaggio. Inizi la conversazione!',
+ 'chat.placeholder': 'Scriva un messaggio...',
+ 'chat.send': 'Invia',
+ 'chat.attach': 'Allega un file',
+
+ // Video player
+ 'video.loading': 'Caricamento di {name}...',
+ 'video.buffering': 'Buffering in corso...',
+ 'video.close': 'Chiudi (Esc)',
+ 'preview.pdf_fallback': 'Questo browser non mostrerà il PDF nella pagina. Lo scarichi '
+ + 'invece — in ogni caso è stato decifrato qui.',
+ 'group.upload_indexing': 'indicizzazione…',
+ 'video.err_transport': 'Trasporto non connesso',
+ 'video.err_mse': 'Codec non supportato per lo streaming: {codec}',
+
+ // Settings
+ 'settings.title': 'Impostazioni',
+ 'settings.coming_soon': 'Prossimamente.',
+ 'settings.downloads': 'Download',
+ 'settings.dl_auto': 'Salva automaticamente',
+ 'settings.dl_auto_hint': 'I file finiscono direttamente nella cartella che sceglie '
+ + 'qui sotto, senza alcuna finestra. Scaricare venti file ne deposita venti.',
+ 'settings.dl_ask': 'Chiedi ogni volta',
+ 'settings.dl_ask_hint': 'Una finestra «Salva con nome» per ciascun file — una per '
+ + 'file, anche quando scarica una selezione.',
+ 'settings.dl_folder': 'Cartella: {name}',
+ 'settings.dl_no_folder': 'Nessuna cartella scelta — i download finiscono dove li '
+ + 'colloca il browser',
+ 'settings.dl_choose': 'Scegli una cartella',
+ 'settings.dl_change': 'Cambia',
+ 'settings.dl_forget': 'Dimentica',
+ 'settings.dl_path_note': 'A una pagina web non si può indicare un percorso, quindi '
+ + 'qui non c’è nulla da digitare: è il browser a concedere l’accesso alla cartella '
+ + 'che indica, e MeshBay scrive soltanto al suo interno. Potrebbe esserle chiesta '
+ + 'una conferma una volta per sessione.',
+ 'settings.dl_unsupported': 'Questo browser non sa scrivere in una cartella a sua '
+ + 'scelta (manca la File System Access API), perciò i download finiscono nella sua '
+ + 'cartella di download. Chrome ed Edge permettono di sceglierne una.',
+ 'settings.profile': 'Profilo',
+ 'settings.username': 'Nome utente',
+ 'settings.node_pins': 'Identità dei node',
+ 'settings.node_pins_hint': "La chiave d'identità di ogni node viene memorizzata alla "
+ + 'prima connessione. Se cambia, la connessione viene rifiutata — cosa che ci si '
+ + 'aspetta solo quando un operatore reinstalla un node. Verifichi con lui prima di '
+ + 'cancellarla.',
+ 'settings.node_pins_count': {
+ one: '{n} fissata',
+ other: '{n} fissate',
+ },
+ 'settings.node_pins_clear': 'Cancella le identità fissate',
+ 'settings.danger': "Elimina l'account",
+ 'settings.delete_hint': "Rimuove il suo account, le sue adesioni ai gruppi e le sue "
+ + "notifiche dall'hub, e libera il suo nome utente. Non arriva a ciò che si trova "
+ + 'sui node: i file che ha caricato restano dove il loro operatore li conserva, e '
+ + 'ogni node mantiene l’identità che ha fissato per lei finché il suo operatore non '
+ + 'la rimuove. I registri di connessione sono conservati per un anno, come impone '
+ + 'la legge.',
+ 'settings.delete_account': 'Elimina il mio account',
+ 'settings.delete_confirm': 'Inserisca la sua passphrase per confermare. '
+ + "L'operazione non può essere annullata.",
+ 'settings.delete_confirm_btn': 'Elimina definitivamente',
+ 'settings.cancel': 'Annulla',
+ 'settings.appearance': 'Aspetto',
+ 'settings.theme': 'Tema',
+ 'settings.theme_light': 'Chiaro',
+ 'settings.theme_dark': 'Scuro',
+ 'settings.theme_system': 'Sistema',
+ 'settings.language': 'Lingua',
+ 'settings.about': 'Informazioni',
+ 'settings.version': 'Versione',
+ 'settings.protocol': 'Protocollo',
+ 'settings.role': 'Ruolo',
+ 'settings.groups': 'Notifiche dei gruppi',
+ 'settings.notifications': 'Notifiche',
+ 'settings.node_key': 'Collega un node',
+ 'settings.node_key_desc': 'Se gestisce un node MeshBay, incolli qui la sua chiave '
+ + 'pubblica Ed25519 per collegarlo al suo account. Trova questa chiave nel '
+ + 'pannello di amministrazione del suo node.',
+ 'settings.node_key_placeholder': 'Incolli la chiave pubblica Ed25519 del node (base64)',
+ 'settings.node_key_submit': 'Collega il node',
+ 'settings.node_key_success': 'Node collegato correttamente',
+ 'settings.node_key_current': 'Chiave del node collegato',
+
+ // Sidebar
+ 'sidebar.create_group': 'Crea un gruppo',
+ 'sidebar.settings': 'Impostazioni',
+ 'sidebar.admin': 'Amministrazione',
+
+ // User menu
+ 'usermenu.language': 'Lingua',
+ 'usermenu.settings': 'Impostazioni',
+ 'usermenu.theme_light': 'Tema chiaro',
+ 'usermenu.theme_dark': 'Tema scuro',
+ 'usermenu.logout': 'Esci',
+
+ // Admin panel
+ 'admin.title': 'Amministrazione',
+ 'admin.tab_stats': 'Statistiche',
+ 'admin.tab_users': 'Utenti',
+ 'admin.tab_groups': 'Gruppi',
+ 'admin.tab_nodes': 'Node',
+ 'admin.tab_logs': 'Registri',
+ 'admin.col_observed_ip': 'Visto da',
+ 'admin.col_hint': 'Indirizzo dichiarato',
+ 'admin.col_last_seen': 'Ultimo annuncio',
+ 'admin.node_online': 'online',
+ 'admin.node_offline': 'offline',
+ 'admin.no_nodes': 'Nessun node registrato',
+ 'admin.nodes_hint': '«Visto da» è l’indirizzo dal quale l’annuncio è effettivamente '
+ + 'arrivato, in una richiesta firmata con la chiave del node — è quello su cui '
+ + 'basarsi per rispondere a una richiesta formale. L’indirizzo dichiarato è quello '
+ + 'che il node crede essere il proprio, appreso da un server STUN e inviato a noi; '
+ + 'serve a raggiungere il node e non prova nulla.',
+ 'admin.tab_blocklist': 'Elenco dei blocchi',
+
+ // Admin stats
+ 'admin.stat_users': 'Utenti',
+ 'admin.stat_groups': 'Gruppi',
+ 'admin.stat_nodes': 'Node',
+ 'admin.stat_online': 'Node online',
+
+ // Admin users
+ 'admin.users_search': 'Cerca utenti...',
+ 'admin.col_username': 'Nome utente',
+ 'admin.col_role': 'Ruolo',
+ 'admin.col_status': 'Stato',
+ 'admin.col_created': 'Creazione',
+ 'admin.col_actions': 'Azioni',
+ 'admin.col_email': 'E-mail',
+ 'admin.col_groups': 'Gruppi',
+ 'admin.no_users': 'Nessun utente trovato',
+ 'admin.btn_suspend': 'Sospendi',
+ 'admin.btn_unsuspend': 'Riattiva',
+ 'admin.btn_details': 'Dettagli',
+ 'admin.btn_delete': 'Elimina',
+ 'admin.delete_confirm': 'Eliminare l’account «{user}»? L’operazione non può essere '
+ + 'annullata. I file che ha caricato restano sui node che li ospitano, e ogni node '
+ + 'mantiene l’identità che ha fissato finché il suo operatore non la rimuove. La '
+ + 'sospensione è l’opzione reversibile.',
+ 'admin.user_detail': "Dettagli dell'utente",
+ 'admin.btn_close': 'Chiudi',
+ 'admin.self_note': '(lei)',
+
+ // Admin groups
+ 'admin.col_name': 'Nome',
+ 'admin.col_visibility': 'Visibilità',
+ 'admin.col_members': 'Membri',
+ 'admin.no_groups': 'Nessun gruppo trovato',
+
+ // Admin logs
+ 'admin.col_time': 'Ora',
+ 'admin.col_event': 'Evento',
+ 'admin.col_user': 'Utente',
+ 'admin.col_ip': 'IP',
+ 'admin.col_detail': 'Dettaglio',
+ 'admin.filter_all': 'Tutti gli eventi',
+ 'admin.no_logs': 'Nessun registro trovato',
+ 'admin.btn_load_more': 'Carica altri',
+
+ // Admin blocklist
+ 'admin.col_hash': 'Hash',
+ 'admin.col_reason': 'Motivo',
+ 'admin.col_date': 'Data',
+ 'admin.col_added_by': 'Aggiunto da',
+ 'admin.no_blocked': 'Nessun hash bloccato',
+ 'admin.add_hash': "Aggiungi un hash all'elenco dei blocchi",
+ 'admin.hash_placeholder': 'hash blake3 (64 caratteri esadecimali)',
+ 'admin.reason_placeholder': 'Motivo',
+ 'admin.btn_block': 'Blocca',
+ 'admin.btn_unblock': 'Sblocca',
+
+ // Notifications
+ // Create group
+ 'create_group.title': 'Crea un gruppo',
+ 'create_group.name': 'Nome del gruppo',
+ 'create_group.name_placeholder': 'es. Foto di famiglia, File del progetto...',
+ 'create_group.visibility': 'Visibilità',
+ 'create_group.private': 'Privato',
+ 'create_group.private_desc': 'Solo i membri invitati vedono i file e vi accedono',
+ 'create_group.public': 'Pubblico',
+ 'create_group.public_desc': 'Chiunque può scoprire e sfogliare questo gruppo',
+ 'create_group.join_policy': 'Modalità di adesione',
+ 'create_group.invite': 'Solo su invito',
+ 'create_group.invite_desc': 'I membri devono essere invitati da un amministratore',
+ 'create_group.open': 'Aperto (chiunque può partecipare)',
+ 'create_group.open_desc': 'Chiunque può partecipare senza approvazione',
+ 'create_group.description': 'Descrizione',
+ 'create_group.description_hint': 'Di cosa si occupa questo gruppo? (facoltativo)',
+ 'create_group.submit': 'Crea',
+ 'create_group.hint': 'Un gruppo ha bisogno di un node che ospiti i file. Può creare '
+ + 'il gruppo ora e collegare un node più avanti.',
+ 'create_group.creating': 'Creazione in corso...',
+
+ // Members
+ 'members.col_role': 'Ruolo',
+ 'members.group_role': 'Ruolo nel gruppo',
+ 'members.admin': 'Amministratore',
+ 'members.group_admin': 'Amministratore del gruppo',
+ 'members.owner': 'Proprietario',
+ 'members.member': 'Membro',
+ 'members.invite_title': 'Invita un membro',
+ 'members.username_placeholder': 'Nome utente',
+ 'members.invite_btn': 'Invita',
+ 'members.invite_needs_pairing': 'Gli inviti sono firmati con la chiave che questo '
+ + 'node ha fissato per il suo browser: lo associ qui sotto prima di invitare '
+ + 'chiunque.',
+ 'members.invite_ask_operator': 'Solo l’operatore del node che ospita questo gruppo '
+ + 'può invitare, da un browser associato a esso.',
+ 'members.remove': 'Rimuovi',
+ 'members.remove_confirm': 'Rimuovere {user} da questo gruppo? Il suo account, gli '
+ + 'altri gruppi e i file che ha caricato non vengono toccati.',
+ 'members.remove_hint': 'Rimuovere qualcuno gli toglie l’adesione e impedisce al node '
+ + 'di consegnargli la chiave del gruppo. Non elimina il suo account e non recupera '
+ + 'la chiave che già possiede — la ruoti sul node (meshbay-node gek-init) se la '
+ + 'cosa è importante.',
+ 'members.pair_title': 'Associ questo browser al suo node',
+ 'members.pair_hint': 'Il suo node accetta le azioni da operatore — inviti, '
+ + 'eliminazione di file — solo da un browser a cui è stato associato. Esegua '
+ + '`meshbay-node operator pair` sul node e digiti qui il codice. Il codice non '
+ + 'passa mai per l’hub.',
+ 'members.pair_btn': 'Associa',
+ 'members.pair_success': 'Questo browser è ora associato al node.',
+ 'members.invite_code_ready': 'Codice di invito per {user} — glielo mandi con il '
+ + 'mezzo che usate di solito. Funziona una volta sola e non passa mai per l’hub.',
+ 'members.invite_code_hint': 'Lo inserirà la prima volta che aprirà questo gruppo. '
+ + 'Non è necessario che lei sia online in quel momento.',
+ 'transfers.title': 'Trasferimenti',
+ 'transfers.clear': 'Rimuovi i completati',
+ 'transfers.cancel': 'Annulla',
+ 'transfers.open': 'Apri',
+ 'transfers.done': 'Completato',
+ 'transfers.cancelled': 'Annullato',
+ 'transfers.failed': 'Non riuscito',
+ 'group.select': 'Seleziona',
+ 'group.select_done': 'Fine',
+ 'group.actions': 'Azioni ({n})',
+ 'group.download_n': 'Scarica ({n})',
+ 'group.download_zip_n': 'Scarica le cartelle in zip ({n})',
+ 'group.delete_n': 'Elimina ({n})',
+ 'group.delete_n_confirm': {
+ one: 'Eliminare {n} elemento? {names}',
+ other: 'Eliminare {n} elementi? {names}',
+ },
+ 'group.download_zip': {
+ one: 'Scarica in zip ({n} file)',
+ other: 'Scarica in zip ({n} file)',
+ },
+ 'group.zip_empty': 'Quella cartella non contiene nulla da scaricare.',
+ 'group.zip_no_stream': 'Questo browser non sa scrivere un download direttamente su '
+ + 'disco, quindi {name} ({size}) deve prima essere costruito in memoria. Con un '
+ + 'archivio grande l’operazione può fallire. Continuare?',
+ 'group.rmdir': 'Elimina la cartella',
+ 'group.rmdir_confirm': 'Eliminare la cartella «{name}»? Deve essere vuota.',
+ 'group.rmdir_not_empty': 'Quella cartella non è vuota. Elimini prima ciò che '
+ + 'contiene — qui nulla cancella file che lei non vede.',
+ 'group.desc_edit': 'Modifica la descrizione',
+ 'group.desc_add': 'Aggiungi una descrizione',
+ 'group.desc_save': 'Salva',
+ 'group.desc_cancel': 'Annulla',
+ 'group.desc_placeholder': 'A cosa serve questo gruppo — i membri lo vedono sulla '
+ + 'loro pagina iniziale.',
+ 'group.join_code_title': 'Questo node deve riconoscerla',
+ 'group.join_code_hint': 'Chieda il codice monouso a chi l’ha invitata e lo inserisca '
+ + 'qui. Dopodiché questo browser è riconosciuto e non le verrà più chiesto.',
+ 'group.join_code_btn': 'Partecipa',
+
+ 'notif.purge': 'Cancella tutto',
+ 'notif.title': 'Notifiche',
+ 'notif.empty': 'Nessuna notifica',
+ 'notif.mark_all_read': 'Segna tutto come letto',
+
+ // Sidebar
+ 'sidebar.search': 'Cerca file',
+
+ // Status
+
+ // Search
+ 'search.title': 'Cerca file',
+ 'search.placeholder': 'Cerca in tutti i gruppi...',
+ 'search.no_results': 'Nessun file corrisponde alla sua ricerca.',
+ 'search.synced': 'sincronizzato {ago}',
+ 'search.cache_note': 'La ricerca si basa su com’era ogni gruppo l’ultima volta che '
+ + 'lo ha aperto. Apra un gruppo per aggiornarne qui i file.',
+ 'search.col_group': 'Gruppo',
+ 'search.result_count': {
+ one: '{n} file trovato',
+ other: '{n} file trovati',
+ },
+ 'search.hint': 'Cerca i nomi dei file in tutti gli indici di gruppo in cache.',
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
new file mode 100644
index 0000000..f9fd5ac
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -0,0 +1,393 @@
+/**
+ * Japanese — translated from locales/en.js.
+ *
+ * Register: formal — です・ます throughout, with 丁寧語 for instructions.
+ * `hub`, `node` and `GEK` are left in English: they name the CLI
+ * (`meshbay-node`), node.toml and the docs, which are English throughout.
+ * Japanese has a single plural category, so counted strings need only `other`.
+ */
+
+export default {
+ // Nav
+ 'nav.toggle_menu': 'メニューを表示',
+ 'nav.light_mode': 'ライトテーマ',
+ 'nav.dark_mode': 'ダークテーマ',
+ 'nav.logout': 'ログアウト',
+ 'nav.login': 'ログイン',
+
+ // Sidebar
+ 'sidebar.my_groups': 'マイグループ',
+ 'sidebar.no_groups': 'グループはまだありません',
+ 'sidebar.discover': '見つける',
+ 'sidebar.public_groups': '公開グループ',
+
+ // Login
+ 'login.title': 'ログイン',
+ 'login.username': 'ユーザー名',
+ 'login.password': 'パスワード',
+ 'login.submit': 'ログイン',
+ 'login.loading': 'ログインしています…',
+ 'login.no_account': 'アカウントをお持ちでないですか?',
+ 'login.register_link': 'アカウントを作成',
+
+ // Register
+ 'register.title': 'アカウントを作成',
+ 'register.username': 'ユーザー名',
+ 'register.email': 'メールアドレス',
+ 'register.password': 'パスフレーズ(12 文字以上)',
+ 'register.confirm': 'パスワードの確認',
+ 'register.submit': 'アカウントを作成',
+ 'register.loading': 'アカウントを作成しています…',
+ 'register.has_account': 'すでにアカウントをお持ちですか?',
+ 'register.login_link': 'ログイン',
+ 'register.success_title': 'アカウントを作成しました',
+ 'register.success_msg': 'ご自身の資格情報でログインできるようになりました。',
+ 'register.go_login': 'ログインへ進む',
+ 'register.err_mismatch': 'パスワードが一致しません',
+ 'register.err_min_len': {
+ other: '{n} 文字以上でご入力ください',
+ },
+ 'register.err_too_weak': '推測されやすすぎます。パスフレーズは、鍵が保管されている場所で'
+ + 'その鍵を守るものです。互いに関係のない単語をいくつか並べると効果的です。',
+ 'register.strength': '強度:約 {bits} ビット。参加している各 node に保管される鍵の'
+ + 'コピーを守るものですので、しっかり決めておく価値があります。',
+
+ // Home
+ 'home.welcome': 'MeshBay へようこそ',
+ 'home.no_groups': 'まだどのグループにも参加していません。',
+ 'home.browse_prefix': '',
+ 'home.browse_link': '公開グループ',
+ 'home.browse_suffix': 'を見てみるか、グループ管理者に招待を依頼してください。',
+ 'home.my_groups': 'マイグループ',
+
+ // Explore
+ 'explore.title': '公開グループ',
+ 'explore.search': 'グループを検索…',
+ 'explore.loading': '読み込んでいます…',
+ 'explore.empty': '利用できる公開グループはありません。',
+ 'explore.create_group': 'グループを作成',
+ 'explore.join': '参加',
+ 'explore.member': '参加済み',
+
+ // Group page
+ 'group.default_name': 'グループ',
+ 'group.tab_files': 'ファイル',
+ 'group.tab_chat': 'チャット',
+ 'group.tab_members': 'メンバー',
+ 'group.filter': 'ファイルを絞り込み…',
+ 'group.col_name': '名前',
+ 'group.col_size': 'サイズ',
+ 'group.col_type': '種類',
+ 'group.col_date': '日付',
+ 'group.empty_filter': '条件に一致するファイルはありません',
+ 'group.empty_dir': 'このフォルダーは空です',
+ 'group.download': 'ダウンロード',
+ 'group.play': '再生',
+ 'group.offline_title': 'このグループのために稼働している node は現在ありません。',
+ 'group.offline_hint': 'このグループをホストしている node が接続すると、'
+ + 'ファイルが表示されます。',
+ 'group.upload': 'アップロード',
+ 'group.mkdir': '新しいフォルダー',
+ 'group.mkdir_prompt': '新しいフォルダーの名前:',
+ 'group.view': '表示',
+ 'group.delete': '削除',
+ 'group.delete_confirm': '{name} を削除しますか?',
+ 'group.delete_group': 'グループを削除',
+ 'group.delete_group_confirm': '「{name}」を完全に削除し、すべてのメンバーを'
+ + '解除しますか?node 上のファイルは保持されます。',
+ 'group.err_transport': 'トランスポートモジュールが読み込まれていません',
+
+ // Status
+ 'status.idle': '待機中',
+ 'status.discovering': 'node を探しています…',
+ 'status.connecting': 'WebRTC で接続しています…',
+ 'status.fetching': 'インデックスを取得しています…',
+ 'status.files': {
+ other: '{n} 個のファイル',
+ },
+ 'status.offline': 'オンラインの node はありません',
+ 'status.error': '接続に失敗しました',
+
+ // Chat
+ 'chat.empty': 'まだメッセージはありません。会話を始めましょう。',
+ 'chat.placeholder': 'メッセージを入力…',
+ 'chat.send': '送信',
+ 'chat.attach': 'ファイルを添付',
+
+ // Video player
+ 'video.loading': '{name} を読み込んでいます…',
+ 'video.buffering': 'バッファリング中…',
+ 'video.close': '閉じる(Esc)',
+ 'preview.pdf_fallback': 'このブラウザーはページ内に PDF を表示しません。'
+ + 'ダウンロードしてご覧ください。いずれにせよ復号はここで行われています。',
+ 'group.upload_indexing': 'インデックスを作成中…',
+ 'video.err_transport': 'トランスポートが接続されていません',
+ 'video.err_mse': 'ストリーミング再生に対応していないコーデックです:{codec}',
+
+ // Settings
+ 'settings.title': '設定',
+ 'settings.coming_soon': '近日公開。',
+ 'settings.downloads': 'ダウンロード',
+ 'settings.dl_auto': '自動的に保存する',
+ 'settings.dl_auto_hint': 'ファイルはダイアログなしで、下で選んだフォルダーに'
+ + 'そのまま保存されます。20 個ダウンロードすれば、そこに 20 個並びます。',
+ 'settings.dl_ask': '毎回確認する',
+ 'settings.dl_ask_hint': 'ファイルごとに「名前を付けて保存」ダイアログが開きます。'
+ + 'まとめてダウンロードする場合も 1 ファイルにつき 1 回です。',
+ 'settings.dl_folder': 'フォルダー:{name}',
+ 'settings.dl_no_folder': 'フォルダーが選ばれていません。ダウンロードは'
+ + 'ブラウザーが決めた場所に保存されます',
+ 'settings.dl_choose': 'フォルダーを選択',
+ 'settings.dl_change': '変更',
+ 'settings.dl_forget': '解除',
+ 'settings.dl_path_note': 'ウェブページにパスを指定することはできないため、'
+ + 'ここに入力するものはありません。お選びになったフォルダーへのアクセスは'
+ + 'ブラウザーが許可し、MeshBay はその中にしか書き込みません。'
+ + 'セッションごとに 1 回、確認を求められることがあります。',
+ 'settings.dl_unsupported': 'このブラウザーはお好きなフォルダーへの書き込みに'
+ + '対応していないため(File System Access API がありません)、'
+ + 'ダウンロードはブラウザー自身のダウンロードフォルダーに保存されます。'
+ + 'Chrome と Edge では選択できます。',
+ 'settings.profile': 'プロフィール',
+ 'settings.username': 'ユーザー名',
+ 'settings.node_pins': 'node の識別情報',
+ 'settings.node_pins_hint': '各 node の識別鍵は、最初に接続したときに記憶されます。'
+ + 'それが変わった場合、接続は拒否されます。これが起こるのは、運営者が node を'
+ + '再インストールしたときだけのはずです。消去する前に運営者にご確認ください。',
+ 'settings.node_pins_count': {
+ other: '{n} 件を固定中',
+ },
+ 'settings.node_pins_clear': '固定した識別情報を消去',
+ 'settings.danger': 'アカウントを削除',
+ 'settings.delete_hint': 'hub から、お客様のアカウント、グループへの参加、通知を'
+ + '削除し、ユーザー名を解放します。node 上にあるものには及びません。'
+ + 'アップロードしたファイルは、その運営者が保管する場所に残り、'
+ + '各 node はお客様のために固定した識別情報を、運営者が解除するまで保持します。'
+ + '接続ログは法令に従い 1 年間保管されます。',
+ 'settings.delete_account': 'アカウントを削除する',
+ 'settings.delete_confirm': '確認のためパスフレーズをご入力ください。'
+ + 'この操作は取り消せません。',
+ 'settings.delete_confirm_btn': '完全に削除する',
+ 'settings.cancel': 'キャンセル',
+ 'settings.appearance': '外観',
+ 'settings.theme': 'テーマ',
+ 'settings.theme_light': 'ライト',
+ 'settings.theme_dark': 'ダーク',
+ 'settings.theme_system': 'システムに合わせる',
+ 'settings.language': '言語',
+ 'settings.about': 'このアプリについて',
+ 'settings.version': 'バージョン',
+ 'settings.protocol': 'プロトコル',
+ 'settings.role': '権限',
+ 'settings.groups': 'グループの通知',
+ 'settings.notifications': '通知',
+ 'settings.node_key': 'node を連携',
+ 'settings.node_key_desc': 'MeshBay の node を運営している場合は、その Ed25519 '
+ + '公開鍵をここに貼り付けて、アカウントに連携してください。'
+ + 'この鍵は node の管理ダッシュボードで確認できます。',
+ 'settings.node_key_placeholder': 'node の Ed25519 公開鍵を貼り付け(base64)',
+ 'settings.node_key_submit': 'node を連携',
+ 'settings.node_key_success': 'node を連携しました',
+ 'settings.node_key_current': '連携済みの node 鍵',
+
+ // Sidebar
+ 'sidebar.create_group': 'グループを作成',
+ 'sidebar.settings': '設定',
+ 'sidebar.admin': '管理',
+
+ // User menu
+ 'usermenu.language': '言語',
+ 'usermenu.settings': '設定',
+ 'usermenu.theme_light': 'ライトテーマ',
+ 'usermenu.theme_dark': 'ダークテーマ',
+ 'usermenu.logout': 'ログアウト',
+
+ // Admin panel
+ 'admin.title': '管理',
+ 'admin.tab_stats': '統計',
+ 'admin.tab_users': 'ユーザー',
+ 'admin.tab_groups': 'グループ',
+ 'admin.tab_nodes': 'node',
+ 'admin.tab_logs': 'ログ',
+ 'admin.col_observed_ip': '接続元',
+ 'admin.col_hint': '申告されたアドレス',
+ 'admin.col_last_seen': '最終通知',
+ 'admin.node_online': 'オンライン',
+ 'admin.node_offline': 'オフライン',
+ 'admin.no_nodes': '登録された node はありません',
+ 'admin.nodes_hint': '「接続元」は、node の鍵で署名されたリクエストとして'
+ + '通知が実際に届いたアドレスです。照会に答える際はこちらを用いてください。'
+ + '申告されたアドレスは、node が STUN サーバーから知り得た「自分のアドレス」として'
+ + '送ってきたものです。node に到達するには役立ちますが、何かの証拠にはなりません。',
+ 'admin.tab_blocklist': 'ブロックリスト',
+
+ // Admin stats
+ 'admin.stat_users': 'ユーザー',
+ 'admin.stat_groups': 'グループ',
+ 'admin.stat_nodes': 'node',
+ 'admin.stat_online': 'オンラインの node',
+
+ // Admin users
+ 'admin.users_search': 'ユーザーを検索…',
+ 'admin.col_username': 'ユーザー名',
+ 'admin.col_role': '権限',
+ 'admin.col_status': '状態',
+ 'admin.col_created': '作成日',
+ 'admin.col_actions': '操作',
+ 'admin.col_email': 'メールアドレス',
+ 'admin.col_groups': 'グループ',
+ 'admin.no_users': 'ユーザーが見つかりません',
+ 'admin.btn_suspend': '停止',
+ 'admin.btn_unsuspend': '再開',
+ 'admin.btn_details': '詳細',
+ 'admin.btn_delete': '削除',
+ 'admin.delete_confirm': 'アカウント「{user}」を削除しますか?この操作は取り消せません。'
+ + 'このユーザーがアップロードしたファイルは、それをホストする node に残り、'
+ + '各 node は固定した識別情報を、その運営者が解除するまで保持します。'
+ + '停止であれば元に戻せます。',
+ 'admin.user_detail': 'ユーザーの詳細',
+ 'admin.btn_close': '閉じる',
+ 'admin.self_note': '(ご自身)',
+
+ // Admin groups
+ 'admin.col_name': '名前',
+ 'admin.col_visibility': '公開範囲',
+ 'admin.col_members': 'メンバー',
+ 'admin.no_groups': 'グループが見つかりません',
+
+ // Admin logs
+ 'admin.col_time': '時刻',
+ 'admin.col_event': 'イベント',
+ 'admin.col_user': 'ユーザー',
+ 'admin.col_ip': 'IP',
+ 'admin.col_detail': '詳細',
+ 'admin.filter_all': 'すべてのイベント',
+ 'admin.no_logs': 'ログが見つかりません',
+ 'admin.btn_load_more': 'さらに読み込む',
+
+ // Admin blocklist
+ 'admin.col_hash': 'ハッシュ',
+ 'admin.col_reason': '理由',
+ 'admin.col_date': '日付',
+ 'admin.col_added_by': '追加者',
+ 'admin.no_blocked': 'ブロック中のハッシュはありません',
+ 'admin.add_hash': 'ハッシュをブロックリストに追加',
+ 'admin.hash_placeholder': 'blake3 ハッシュ(16 進数 64 文字)',
+ 'admin.reason_placeholder': '理由',
+ 'admin.btn_block': 'ブロック',
+ 'admin.btn_unblock': 'ブロック解除',
+
+ // Notifications
+ // Create group
+ 'create_group.title': 'グループを作成',
+ 'create_group.name': 'グループ名',
+ 'create_group.name_placeholder': '例:家族の写真、プロジェクトの資料…',
+ 'create_group.visibility': '公開範囲',
+ 'create_group.private': '非公開',
+ 'create_group.private_desc': '招待されたメンバーだけがファイルを見て利用できます',
+ 'create_group.public': '公開',
+ 'create_group.public_desc': '誰でもこのグループを見つけて閲覧できます',
+ 'create_group.join_policy': '参加方法',
+ 'create_group.invite': '招待制',
+ 'create_group.invite_desc': 'メンバーは管理者から招待される必要があります',
+ 'create_group.open': 'オープン(誰でも参加できます)',
+ 'create_group.open_desc': '承認なしで誰でも参加できます',
+ 'create_group.description': '説明',
+ 'create_group.description_hint': 'どんなグループですか?(任意)',
+ 'create_group.submit': '作成',
+ 'create_group.hint': 'グループにはファイルをホストする node が必要です。'
+ + '先にグループを作成し、あとから node を接続することもできます。',
+ 'create_group.creating': '作成しています…',
+
+ // Members
+ 'members.col_role': '権限',
+ 'members.group_role': 'グループ内の権限',
+ 'members.admin': '管理者',
+ 'members.group_admin': 'グループ管理者',
+ 'members.owner': '所有者',
+ 'members.member': 'メンバー',
+ 'members.invite_title': 'メンバーを招待',
+ 'members.username_placeholder': 'ユーザー名',
+ 'members.invite_btn': '招待',
+ 'members.invite_needs_pairing': '招待は、この node がお使いのブラウザー用に'
+ + '固定した鍵で署名されます。どなたかを招待する前に、下でペアリングしてください。',
+ 'members.invite_ask_operator': '招待できるのは、このグループをホストしている '
+ + 'node の運営者だけで、その node とペアリング済みのブラウザーからに限られます。',
+ 'members.remove': '解除',
+ 'members.remove_confirm': '{user} をこのグループから外しますか?'
+ + 'そのアカウント、ほかのグループ、アップロード済みのファイルには影響しません。',
+ 'members.remove_hint': 'メンバーを外すと参加資格がなくなり、node はその人に'
+ + 'グループ鍵を渡さなくなります。アカウントが削除されるわけではなく、'
+ + 'すでに渡っている鍵を取り戻すこともできません。それが重要であれば、'
+ + 'node 上で鍵をローテーションしてください(meshbay-node gek-init)。',
+ 'members.pair_title': 'このブラウザーを node とペアリング',
+ 'members.pair_hint': 'node が運営者としての操作(招待、ファイルの削除)を受け付けるのは、'
+ + 'ペアリング済みのブラウザーからだけです。node で '
+ + '`meshbay-node operator pair` を実行し、表示されたコードをここに入力してください。'
+ + 'このコードが hub を経由することはありません。',
+ 'members.pair_btn': 'ペアリング',
+ 'members.pair_success': 'このブラウザーは node とペアリングされました。',
+ 'members.invite_code_ready': '{user} 宛ての招待コードです。'
+ + 'いつもの連絡手段でお送りください。使えるのは 1 回だけで、hub を経由することはありません。',
+ 'members.invite_code_hint': '相手がこのグループを初めて開いたときに入力します。'
+ + 'そのときお客様がオンラインである必要はありません。',
+ 'transfers.title': '転送',
+ 'transfers.clear': '完了分を消去',
+ 'transfers.cancel': 'キャンセル',
+ 'transfers.open': '開く',
+ 'transfers.done': '完了',
+ 'transfers.cancelled': 'キャンセル済み',
+ 'transfers.failed': '失敗',
+ 'group.select': '選択',
+ 'group.select_done': '完了',
+ 'group.actions': '操作({n})',
+ 'group.download_n': 'ダウンロード({n})',
+ 'group.download_zip_n': 'フォルダーを zip でダウンロード({n})',
+ 'group.delete_n': '削除({n})',
+ 'group.delete_n_confirm': {
+ other: '{n} 件を削除しますか?{names}',
+ },
+ 'group.download_zip': {
+ other: 'zip でダウンロード({n} 個のファイル)',
+ },
+ 'group.zip_empty': 'そのフォルダーにダウンロードできるものはありません。',
+ 'group.zip_no_stream': 'このブラウザーはダウンロードをディスクへ直接書き込めないため、'
+ + '{name}({size})をいったんメモリー上で組み立てる必要があります。'
+ + '大きなアーカイブでは失敗することがあります。続けますか?',
+ 'group.rmdir': 'フォルダーを削除',
+ 'group.rmdir_confirm': 'フォルダー「{name}」を削除しますか?空である必要があります。',
+ 'group.rmdir_not_empty': 'そのフォルダーは空ではありません。先に中身を削除してください。'
+ + 'ここでの操作が、目に見えないファイルを消すことはありません。',
+ 'group.desc_edit': '説明を編集',
+ 'group.desc_add': '説明を追加',
+ 'group.desc_save': '保存',
+ 'group.desc_cancel': 'キャンセル',
+ 'group.desc_placeholder': 'このグループの目的。メンバーのホーム画面に表示されます。',
+ 'group.join_code_title': 'この node がお客様を認識する必要があります',
+ 'group.join_code_hint': '招待してくれた方にワンタイムコードを聞いて、ここに入力してください。'
+ + 'それ以降このブラウザーは認識され、再び尋ねられることはありません。',
+ 'group.join_code_btn': '参加',
+
+ 'notif.purge': 'すべて消去',
+ 'notif.title': '通知',
+ 'notif.empty': '通知はありません',
+ 'notif.mark_all_read': 'すべて既読にする',
+
+ // Sidebar
+ 'sidebar.search': 'ファイルを検索',
+
+ // Status
+
+ // Search
+ 'search.title': 'ファイルを検索',
+ 'search.placeholder': 'すべてのグループから検索…',
+ 'search.no_results': '検索条件に一致するファイルはありません。',
+ 'search.synced': '{ago}に同期',
+ 'search.cache_note': '検索は、各グループを最後に開いたときの状態を参照します。'
+ + 'グループを開くと、そのファイルがここで最新になります。',
+ 'search.col_group': 'グループ',
+ 'search.result_count': {
+ other: '{n} 個のファイルが見つかりました',
+ },
+ 'search.hint': 'キャッシュされたすべてのグループインデックスからファイル名を検索します。',
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
new file mode 100644
index 0000000..2f778b3
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -0,0 +1,406 @@
+/**
+ * Dutch — translated from locales/en.js.
+ *
+ * Register: formal ("u").
+ * `hub`, `node` and `GEK` are left in English: they name the CLI
+ * (`meshbay-node`), node.toml and the docs, which are English throughout.
+ * Grammatical gender chosen here: de hub, de node, de GEK (de sleutel).
+ */
+
+export default {
+ // Nav
+ 'nav.toggle_menu': 'Menu tonen',
+ 'nav.light_mode': 'Licht thema',
+ 'nav.dark_mode': 'Donker thema',
+ 'nav.logout': 'Afmelden',
+ 'nav.login': 'Aanmelden',
+
+ // Sidebar
+ 'sidebar.my_groups': 'Mijn groepen',
+ 'sidebar.no_groups': 'Nog geen groepen',
+ 'sidebar.discover': 'Ontdekken',
+ 'sidebar.public_groups': 'Openbare groepen',
+
+ // Login
+ 'login.title': 'Aanmelden',
+ 'login.username': 'Gebruikersnaam',
+ 'login.password': 'Wachtwoord',
+ 'login.submit': 'Aanmelden',
+ 'login.loading': 'Bezig met aanmelden...',
+ 'login.no_account': 'Nog geen account?',
+ 'login.register_link': 'Account aanmaken',
+
+ // Register
+ 'register.title': 'Account aanmaken',
+ 'register.username': 'Gebruikersnaam',
+ 'register.email': 'E-mailadres',
+ 'register.password': 'Wachtwoordzin (minstens 12 tekens)',
+ 'register.confirm': 'Wachtwoord bevestigen',
+ 'register.submit': 'Account aanmaken',
+ 'register.loading': 'Account wordt aangemaakt...',
+ 'register.has_account': 'Hebt u al een account?',
+ 'register.login_link': 'Aanmelden',
+ 'register.success_title': 'Account aangemaakt',
+ 'register.success_msg': 'U kunt zich nu met uw gegevens aanmelden.',
+ 'register.go_login': 'Naar het aanmelden',
+ 'register.err_mismatch': 'De wachtwoorden komen niet overeen',
+ 'register.err_min_len': {
+ one: 'Gebruik minstens {n} teken',
+ other: 'Gebruik minstens {n} tekens',
+ },
+ 'register.err_too_weak': 'Te makkelijk te raden. Uw wachtwoordzin is wat uw sleutels '
+ + 'beschermt daar waar ze bewaard worden — een paar woorden zonder onderling '
+ + 'verband werken goed.',
+ 'register.strength': 'Sterkte: ongeveer {bits} bits. Ze beschermt de kopie van uw '
+ + 'sleutels die op de nodes staat waar u zich bij aansluit, dus het loont om er '
+ + 'even bij stil te staan.',
+
+ // Home
+ 'home.welcome': 'Welkom bij MeshBay',
+ 'home.no_groups': 'U bent nog van geen enkele groep lid.',
+ 'home.browse_prefix': 'Bekijk de ',
+ 'home.browse_link': 'openbare groepen',
+ 'home.browse_suffix': ' of vraag een groepsbeheerder om u uit te nodigen.',
+ 'home.my_groups': 'Mijn groepen',
+
+ // Explore
+ 'explore.title': 'Openbare groepen',
+ 'explore.search': 'Groepen zoeken...',
+ 'explore.loading': 'Bezig met laden...',
+ 'explore.empty': 'Geen openbare groepen beschikbaar.',
+ 'explore.create_group': 'Groep aanmaken',
+ 'explore.join': 'Deelnemen',
+ 'explore.member': 'Lid',
+
+ // Group page
+ 'group.default_name': 'Groep',
+ 'group.tab_files': 'Bestanden',
+ 'group.tab_chat': 'Chat',
+ 'group.tab_members': 'Leden',
+ 'group.filter': 'Bestanden filteren...',
+ 'group.col_name': 'Naam',
+ 'group.col_size': 'Grootte',
+ 'group.col_type': 'Type',
+ 'group.col_date': 'Datum',
+ 'group.empty_filter': 'Geen bestand voldoet aan het filter',
+ 'group.empty_dir': 'Deze map is leeg',
+ 'group.download': 'Downloaden',
+ 'group.play': 'Afspelen',
+ 'group.offline_title': 'Er is momenteel geen node online voor deze groep.',
+ 'group.offline_hint': 'De bestanden verschijnen zodra er een node verbinding maakt '
+ + 'die deze groep host.',
+ 'group.upload': 'Uploaden',
+ 'group.mkdir': 'Nieuwe map',
+ 'group.mkdir_prompt': 'Naam van de nieuwe map:',
+ 'group.view': 'Bekijken',
+ 'group.delete': 'Verwijderen',
+ 'group.delete_confirm': '{name} verwijderen?',
+ 'group.delete_group': 'Groep verwijderen',
+ 'group.delete_group_confirm': '"{name}" definitief verwijderen en alle leden '
+ + 'verwijderen? De bestanden op de node blijven bewaard.',
+ 'group.err_transport': 'Transportmodule niet geladen',
+
+ // Status
+ 'status.idle': 'Inactief',
+ 'status.discovering': 'Nodes zoeken...',
+ 'status.connecting': 'Verbinden via WebRTC...',
+ 'status.fetching': 'Index ophalen...',
+ 'status.files': {
+ one: '{n} bestand',
+ other: '{n} bestanden',
+ },
+ 'status.offline': 'Geen node online',
+ 'status.error': 'Verbinding mislukt',
+
+ // Chat
+ 'chat.empty': 'Nog geen berichten. Begin het gesprek!',
+ 'chat.placeholder': 'Typ een bericht...',
+ 'chat.send': 'Versturen',
+ 'chat.attach': 'Bestand bijvoegen',
+
+ // Video player
+ 'video.loading': '{name} wordt geladen...',
+ 'video.buffering': 'Bezig met bufferen...',
+ 'video.close': 'Sluiten (Esc)',
+ 'preview.pdf_fallback': 'Deze browser toont de PDF niet in de pagina zelf. Download '
+ + 'hem in plaats daarvan — ontsleuteld werd hij hoe dan ook hier.',
+ 'group.upload_indexing': 'indexeren…',
+ 'video.err_transport': 'Transport niet verbonden',
+ 'video.err_mse': 'Codec wordt niet ondersteund voor streamen: {codec}',
+
+ // Settings
+ 'settings.title': 'Instellingen',
+ 'settings.coming_soon': 'Binnenkort beschikbaar.',
+ 'settings.downloads': 'Downloads',
+ 'settings.dl_auto': 'Automatisch opslaan',
+ 'settings.dl_auto_hint': 'Bestanden gaan zonder venster rechtstreeks naar de map die '
+ + 'u hieronder kiest. Twintig downloads zetten daar twintig bestanden neer.',
+ 'settings.dl_ask': 'Elke keer vragen',
+ 'settings.dl_ask_hint': 'Een venster "Opslaan als" per bestand — één per bestand, '
+ + 'ook wanneer u een selectie downloadt.',
+ 'settings.dl_folder': 'Map: {name}',
+ 'settings.dl_no_folder': 'Geen map gekozen — downloads komen terecht waar uw browser '
+ + 'ze neerzet',
+ 'settings.dl_choose': 'Map kiezen',
+ 'settings.dl_change': 'Wijzigen',
+ 'settings.dl_forget': 'Vergeten',
+ 'settings.dl_path_note': 'Aan een webpagina kan geen pad worden doorgegeven, dus '
+ + 'hier valt niets in te typen: uw browser verleent toegang tot de map die u '
+ + 'aanwijst, en MeshBay schrijft uitsluitend daarbinnen. Mogelijk wordt u één keer '
+ + 'per sessie om bevestiging gevraagd.',
+ 'settings.dl_unsupported': 'Deze browser kan niet schrijven naar een map van uw keuze '
+ + '(geen File System Access API), dus downloads gaan naar zijn eigen downloadmap. '
+ + 'Chrome en Edge laten wel een keuze toe.',
+ 'settings.profile': 'Profiel',
+ 'settings.username': 'Gebruikersnaam',
+ 'settings.node_pins': 'Node-identiteiten',
+ 'settings.node_pins_hint': 'De identiteitssleutel van elke node wordt bij de eerste '
+ + 'verbinding onthouden. Verandert die, dan wordt de verbinding geweigerd — wat '
+ + 'alleen te verwachten is wanneer een beheerder een node opnieuw installeert. '
+ + 'Vraag het daar na voordat u ze wist.',
+ 'settings.node_pins_count': {
+ one: '{n} vastgezet',
+ other: '{n} vastgezet',
+ },
+ 'settings.node_pins_clear': 'Vastgezette identiteiten wissen',
+ 'settings.danger': 'Account verwijderen',
+ 'settings.delete_hint': 'Verwijdert uw account, uw groepslidmaatschappen en uw '
+ + 'meldingen van de hub, en geeft uw gebruikersnaam vrij. Wat op de nodes staat, '
+ + 'wordt hiermee niet bereikt: bestanden die u hebt geüpload blijven waar hun '
+ + 'beheerder ze bewaart, en elke node houdt de voor u vastgezette identiteit tot '
+ + 'zijn beheerder die weghaalt. Verbindingslogboeken worden een jaar bewaard, zoals '
+ + 'de wet voorschrijft.',
+ 'settings.delete_account': 'Mijn account verwijderen',
+ 'settings.delete_confirm': 'Voer uw wachtwoordzin in ter bevestiging. Dit kan niet '
+ + 'ongedaan worden gemaakt.',
+ 'settings.delete_confirm_btn': 'Definitief verwijderen',
+ 'settings.cancel': 'Annuleren',
+ 'settings.appearance': 'Weergave',
+ 'settings.theme': 'Thema',
+ 'settings.theme_light': 'Licht',
+ 'settings.theme_dark': 'Donker',
+ 'settings.theme_system': 'Systeem',
+ 'settings.language': 'Taal',
+ 'settings.about': 'Over',
+ 'settings.version': 'Versie',
+ 'settings.protocol': 'Protocol',
+ 'settings.role': 'Rol',
+ 'settings.groups': 'Groepsmeldingen',
+ 'settings.notifications': 'Meldingen',
+ 'settings.node_key': 'Node koppelen',
+ 'settings.node_key_desc': 'Als u een MeshBay-node beheert, plak hier dan de '
+ + 'openbare Ed25519-sleutel ervan om hem aan uw account te koppelen. U vindt die '
+ + 'sleutel in het beheerdersdashboard van uw node.',
+ 'settings.node_key_placeholder': 'Plak de openbare Ed25519-sleutel van de node (base64)',
+ 'settings.node_key_submit': 'Node koppelen',
+ 'settings.node_key_success': 'Node gekoppeld',
+ 'settings.node_key_current': 'Sleutel van de gekoppelde node',
+
+ // Sidebar
+ 'sidebar.create_group': 'Groep aanmaken',
+ 'sidebar.settings': 'Instellingen',
+ 'sidebar.admin': 'Beheer',
+
+ // User menu
+ 'usermenu.language': 'Taal',
+ 'usermenu.settings': 'Instellingen',
+ 'usermenu.theme_light': 'Licht thema',
+ 'usermenu.theme_dark': 'Donker thema',
+ 'usermenu.logout': 'Afmelden',
+
+ // Admin panel
+ 'admin.title': 'Beheer',
+ 'admin.tab_stats': 'Statistieken',
+ 'admin.tab_users': 'Gebruikers',
+ 'admin.tab_groups': 'Groepen',
+ 'admin.tab_nodes': 'Nodes',
+ 'admin.tab_logs': 'Logboeken',
+ 'admin.col_observed_ip': 'Gezien vanaf',
+ 'admin.col_hint': 'Opgegeven adres',
+ 'admin.col_last_seen': 'Laatste melding',
+ 'admin.node_online': 'online',
+ 'admin.node_offline': 'offline',
+ 'admin.no_nodes': 'Geen nodes geregistreerd',
+ 'admin.nodes_hint': '"Gezien vanaf" is het adres waarvandaan de melding werkelijk '
+ + 'binnenkwam, in een verzoek dat met de sleutel van de node is ondertekend — dat '
+ + 'is het adres om een vordering mee te beantwoorden. Het opgegeven adres is wat de '
+ + 'node zelf denkt te zijn, vernomen van een STUN-server en naar ons gestuurd; het '
+ + 'helpt de node te bereiken en bewijst niets.',
+ 'admin.tab_blocklist': 'Blokkeerlijst',
+
+ // Admin stats
+ 'admin.stat_users': 'Gebruikers',
+ 'admin.stat_groups': 'Groepen',
+ 'admin.stat_nodes': 'Nodes',
+ 'admin.stat_online': 'Nodes online',
+
+ // Admin users
+ 'admin.users_search': 'Gebruikers zoeken...',
+ 'admin.col_username': 'Gebruikersnaam',
+ 'admin.col_role': 'Rol',
+ 'admin.col_status': 'Status',
+ 'admin.col_created': 'Aangemaakt',
+ 'admin.col_actions': 'Acties',
+ 'admin.col_email': 'E-mail',
+ 'admin.col_groups': 'Groepen',
+ 'admin.no_users': 'Geen gebruikers gevonden',
+ 'admin.btn_suspend': 'Schorsen',
+ 'admin.btn_unsuspend': 'Herstellen',
+ 'admin.btn_details': 'Details',
+ 'admin.btn_delete': 'Verwijderen',
+ 'admin.delete_confirm': 'Het account "{user}" verwijderen? Dit kan niet ongedaan '
+ + 'worden gemaakt. De bestanden die deze persoon heeft geüpload blijven op de nodes '
+ + 'die ze hosten, en elke node houdt de vastgezette identiteit tot zijn beheerder '
+ + 'die weghaalt. Schorsen is de omkeerbare optie.',
+ 'admin.user_detail': 'Gebruikersgegevens',
+ 'admin.btn_close': 'Sluiten',
+ 'admin.self_note': '(u)',
+
+ // Admin groups
+ 'admin.col_name': 'Naam',
+ 'admin.col_visibility': 'Zichtbaarheid',
+ 'admin.col_members': 'Leden',
+ 'admin.no_groups': 'Geen groepen gevonden',
+
+ // Admin logs
+ 'admin.col_time': 'Tijd',
+ 'admin.col_event': 'Gebeurtenis',
+ 'admin.col_user': 'Gebruiker',
+ 'admin.col_ip': 'IP',
+ 'admin.col_detail': 'Detail',
+ 'admin.filter_all': 'Alle gebeurtenissen',
+ 'admin.no_logs': 'Geen logboeken gevonden',
+ 'admin.btn_load_more': 'Meer laden',
+
+ // Admin blocklist
+ 'admin.col_hash': 'Hash',
+ 'admin.col_reason': 'Reden',
+ 'admin.col_date': 'Datum',
+ 'admin.col_added_by': 'Toegevoegd door',
+ 'admin.no_blocked': 'Geen geblokkeerde hashes',
+ 'admin.add_hash': 'Hash aan de blokkeerlijst toevoegen',
+ 'admin.hash_placeholder': 'blake3-hash (64 hexadecimale tekens)',
+ 'admin.reason_placeholder': 'Reden',
+ 'admin.btn_block': 'Blokkeren',
+ 'admin.btn_unblock': 'Deblokkeren',
+
+ // Notifications
+ // Create group
+ 'create_group.title': 'Groep aanmaken',
+ 'create_group.name': 'Groepsnaam',
+ 'create_group.name_placeholder': 'bijv. Familiefotos, Projectbestanden...',
+ 'create_group.visibility': 'Zichtbaarheid',
+ 'create_group.private': 'Privé',
+ 'create_group.private_desc': 'Alleen uitgenodigde leden zien de bestanden en hebben er toegang toe',
+ 'create_group.public': 'Openbaar',
+ 'create_group.public_desc': 'Iedereen kan deze groep vinden en doorbladeren',
+ 'create_group.join_policy': 'Toetredingsbeleid',
+ 'create_group.invite': 'Alleen op uitnodiging',
+ 'create_group.invite_desc': 'Leden moeten door een beheerder worden uitgenodigd',
+ 'create_group.open': 'Open (iedereen kan deelnemen)',
+ 'create_group.open_desc': 'Iedereen kan zonder goedkeuring deelnemen',
+ 'create_group.description': 'Beschrijving',
+ 'create_group.description_hint': 'Waar gaat deze groep over? (optioneel)',
+ 'create_group.submit': 'Aanmaken',
+ 'create_group.hint': 'Een groep heeft een node nodig om de bestanden te hosten. U '
+ + 'kunt de groep nu aanmaken en later een node aansluiten.',
+ 'create_group.creating': 'Bezig met aanmaken...',
+
+ // Members
+ 'members.col_role': 'Rol',
+ 'members.group_role': 'Rol in de groep',
+ 'members.admin': 'Beheerder',
+ 'members.group_admin': 'Groepsbeheerder',
+ 'members.owner': 'Eigenaar',
+ 'members.member': 'Lid',
+ 'members.invite_title': 'Lid uitnodigen',
+ 'members.username_placeholder': 'Gebruikersnaam',
+ 'members.invite_btn': 'Uitnodigen',
+ 'members.invite_needs_pairing': 'Uitnodigingen worden ondertekend met de sleutel die '
+ + 'deze node voor uw browser heeft vastgezet, dus koppel hem hieronder voordat u '
+ + 'iemand uitnodigt.',
+ 'members.invite_ask_operator': 'Alleen de beheerder van de node die deze groep host '
+ + 'kan uitnodigen, vanuit een browser die eraan gekoppeld is.',
+ 'members.remove': 'Verwijderen',
+ 'members.remove_confirm': '{user} uit deze groep verwijderen? Het account, de andere '
+ + 'groepen en de geüploade bestanden van deze persoon blijven ongemoeid.',
+ 'members.remove_hint': 'Iemand verwijderen neemt het lidmaatschap weg en zorgt ervoor '
+ + 'dat de node de groepssleutel niet meer aan die persoon geeft. Het account wordt '
+ + 'er niet mee verwijderd, en de sleutel die de persoon al heeft, komt er niet mee '
+ + 'terug — roteer die op de node (meshbay-node gek-init) als dat van belang is.',
+ 'members.pair_title': 'Deze browser aan uw node koppelen',
+ 'members.pair_hint': 'Uw node accepteert beheerdersacties — uitnodigingen, bestanden '
+ + 'verwijderen — alleen vanuit een browser waarmee hij gekoppeld is. Voer op de '
+ + 'node `meshbay-node operator pair` uit en typ de code hier in. De code gaat nooit '
+ + 'via de hub.',
+ 'members.pair_btn': 'Koppelen',
+ 'members.pair_success': 'Deze browser is nu aan de node gekoppeld.',
+ 'members.invite_code_ready': 'Uitnodigingscode voor {user} — stuur die op de manier '
+ + 'waarop u elkaar gewoonlijk spreekt. Hij werkt één keer en gaat nooit via de hub.',
+ 'members.invite_code_hint': 'De persoon voert hem in de eerste keer dat deze groep '
+ + 'wordt geopend. U hoeft dan niet online te zijn.',
+ 'transfers.title': 'Overdrachten',
+ 'transfers.clear': 'Voltooide wissen',
+ 'transfers.cancel': 'Annuleren',
+ 'transfers.open': 'Openen',
+ 'transfers.done': 'Voltooid',
+ 'transfers.cancelled': 'Geannuleerd',
+ 'transfers.failed': 'Mislukt',
+ 'group.select': 'Selecteren',
+ 'group.select_done': 'Klaar',
+ 'group.actions': 'Acties ({n})',
+ 'group.download_n': 'Downloaden ({n})',
+ 'group.download_zip_n': 'Mappen als zip downloaden ({n})',
+ 'group.delete_n': 'Verwijderen ({n})',
+ 'group.delete_n_confirm': {
+ one: '{n} item verwijderen? {names}',
+ other: '{n} items verwijderen? {names}',
+ },
+ 'group.download_zip': {
+ one: 'Als zip downloaden ({n} bestand)',
+ other: 'Als zip downloaden ({n} bestanden)',
+ },
+ 'group.zip_empty': 'Die map bevat niets om te downloaden.',
+ 'group.zip_no_stream': 'Deze browser kan een download niet rechtstreeks naar schijf '
+ + 'schrijven, dus {name} ({size}) moet eerst in het geheugen worden opgebouwd. Bij '
+ + 'een groot archief kan dat mislukken. Doorgaan?',
+ 'group.rmdir': 'Map verwijderen',
+ 'group.rmdir_confirm': 'De map "{name}" verwijderen? Ze moet leeg zijn.',
+ 'group.rmdir_not_empty': 'Die map is niet leeg. Verwijder eerst wat erin staat — '
+ + 'niets hier verwijdert bestanden die u niet ziet.',
+ 'group.desc_edit': 'Beschrijving bewerken',
+ 'group.desc_add': 'Beschrijving toevoegen',
+ 'group.desc_save': 'Opslaan',
+ 'group.desc_cancel': 'Annuleren',
+ 'group.desc_placeholder': 'Waar deze groep voor dient — leden zien dit op hun '
+ + 'startpagina.',
+ 'group.join_code_title': 'Deze node moet u herkennen',
+ 'group.join_code_hint': 'Vraag degene die u heeft uitgenodigd om de eenmalige code en '
+ + 'voer die hier in. Daarna is deze browser bekend en wordt het u niet opnieuw '
+ + 'gevraagd.',
+ 'group.join_code_btn': 'Deelnemen',
+
+ 'notif.purge': 'Alles wissen',
+ 'notif.title': 'Meldingen',
+ 'notif.empty': 'Geen meldingen',
+ 'notif.mark_all_read': 'Alles als gelezen markeren',
+
+ // Sidebar
+ 'sidebar.search': 'Bestanden zoeken',
+
+ // Status
+
+ // Search
+ 'search.title': 'Bestanden zoeken',
+ 'search.placeholder': 'In alle groepen zoeken...',
+ 'search.no_results': 'Geen bestand voldoet aan uw zoekopdracht.',
+ 'search.synced': 'gesynchroniseerd {ago}',
+ 'search.cache_note': 'De zoekopdracht gaat uit van hoe elke groep eruitzag toen u '
+ + 'die voor het laatst opende. Open een groep om de bestanden hier bij te werken.',
+ 'search.col_group': 'Groep',
+ 'search.result_count': {
+ one: '{n} bestand gevonden',
+ other: '{n} bestanden gevonden',
+ },
+ 'search.hint': 'Zoekt bestandsnamen in alle groepsindexen in de cache.',
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
new file mode 100644
index 0000000..d77a160
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -0,0 +1,419 @@
+/**
+ * Polish — translated from locales/en.js.
+ *
+ * Register: formal — the polite impersonal ("Proszę wpisać…", "Można…"), which is
+ * how formal Polish software addresses the reader; direct "Pan / Pani" is used
+ * only where a sentence needs a subject.
+ * `hub`, `node` and `GEK` are left in English: they name the CLI
+ * (`meshbay-node`), node.toml and the docs, which are English throughout.
+ * Polish uses four plural categories, so counted strings carry all of
+ * one / few / many / other.
+ */
+
+export default {
+ // Nav
+ 'nav.toggle_menu': 'Pokaż menu',
+ 'nav.light_mode': 'Motyw jasny',
+ 'nav.dark_mode': 'Motyw ciemny',
+ 'nav.logout': 'Wyloguj',
+ 'nav.login': 'Zaloguj',
+
+ // Sidebar
+ 'sidebar.my_groups': 'Moje grupy',
+ 'sidebar.no_groups': 'Nie ma jeszcze żadnych grup',
+ 'sidebar.discover': 'Odkrywaj',
+ 'sidebar.public_groups': 'Grupy publiczne',
+
+ // Login
+ 'login.title': 'Logowanie',
+ 'login.username': 'Nazwa użytkownika',
+ 'login.password': 'Hasło',
+ 'login.submit': 'Zaloguj',
+ 'login.loading': 'Logowanie...',
+ 'login.no_account': 'Nie ma jeszcze konta?',
+ 'login.register_link': 'Załóż konto',
+
+ // Register
+ 'register.title': 'Zakładanie konta',
+ 'register.username': 'Nazwa użytkownika',
+ 'register.email': 'Adres e-mail',
+ 'register.password': 'Hasło-fraza (co najmniej 12 znaków)',
+ 'register.confirm': 'Potwierdź hasło',
+ 'register.submit': 'Załóż konto',
+ 'register.loading': 'Tworzenie konta...',
+ 'register.has_account': 'Konto już istnieje?',
+ 'register.login_link': 'Zaloguj',
+ 'register.success_title': 'Konto zostało założone',
+ 'register.success_msg': 'Można się już zalogować przy użyciu swoich danych.',
+ 'register.go_login': 'Przejdź do logowania',
+ 'register.err_mismatch': 'Hasła nie są zgodne',
+ 'register.err_min_len': {
+ one: 'Proszę użyć co najmniej {n} znaku',
+ few: 'Proszę użyć co najmniej {n} znaków',
+ many: 'Proszę użyć co najmniej {n} znaków',
+ other: 'Proszę użyć co najmniej {n} znaku',
+ },
+ 'register.err_too_weak': 'Zbyt łatwe do odgadnięcia. To hasło-fraza chroni klucze '
+ + 'tam, gdzie są przechowywane — kilka niepowiązanych ze sobą słów sprawdza się '
+ + 'bardzo dobrze.',
+ 'register.strength': 'Siła: około {bits} bitów. Chroni kopię kluczy przechowywaną na '
+ + 'nodes, do których się dołącza, więc warto ją dobrać starannie.',
+
+ // Home
+ 'home.welcome': 'Witamy w MeshBay',
+ 'home.no_groups': 'Nie należy Pan(i) jeszcze do żadnej grupy.',
+ 'home.browse_prefix': 'Warto przejrzeć ',
+ 'home.browse_link': 'grupy publiczne',
+ 'home.browse_suffix': ' albo poprosić administratora grupy o zaproszenie.',
+ 'home.my_groups': 'Moje grupy',
+
+ // Explore
+ 'explore.title': 'Grupy publiczne',
+ 'explore.search': 'Szukaj grup...',
+ 'explore.loading': 'Wczytywanie...',
+ 'explore.empty': 'Brak dostępnych grup publicznych.',
+ 'explore.create_group': 'Utwórz grupę',
+ 'explore.join': 'Dołącz',
+ 'explore.member': 'Należy',
+
+ // Group page
+ 'group.default_name': 'Grupa',
+ 'group.tab_files': 'Pliki',
+ 'group.tab_chat': 'Czat',
+ 'group.tab_members': 'Członkowie',
+ 'group.filter': 'Filtruj pliki...',
+ 'group.col_name': 'Nazwa',
+ 'group.col_size': 'Rozmiar',
+ 'group.col_type': 'Typ',
+ 'group.col_date': 'Data',
+ 'group.empty_filter': 'Żaden plik nie pasuje do filtra',
+ 'group.empty_dir': 'Ten folder jest pusty',
+ 'group.download': 'Pobierz',
+ 'group.play': 'Odtwórz',
+ 'group.offline_title': 'Obecnie żaden node nie jest dostępny dla tej grupy.',
+ 'group.offline_hint': 'Pliki pojawią się, gdy połączy się node hostujący tę grupę.',
+ 'group.upload': 'Wyślij',
+ 'group.mkdir': 'Nowy folder',
+ 'group.mkdir_prompt': 'Nazwa nowego folderu:',
+ 'group.view': 'Podgląd',
+ 'group.delete': 'Usuń',
+ 'group.delete_confirm': 'Usunąć {name}?',
+ 'group.delete_group': 'Usuń grupę',
+ 'group.delete_group_confirm': 'Trwale usunąć grupę „{name}” i odłączyć wszystkich '
+ + 'członków? Pliki znajdujące się na node zostaną zachowane.',
+ 'group.err_transport': 'Moduł transportu nie został wczytany',
+
+ // Status
+ 'status.idle': 'Bezczynny',
+ 'status.discovering': 'Szukanie nodes...',
+ 'status.connecting': 'Łączenie przez WebRTC...',
+ 'status.fetching': 'Pobieranie indeksu...',
+ 'status.files': {
+ one: '{n} plik',
+ few: '{n} pliki',
+ many: '{n} plików',
+ other: '{n} pliku',
+ },
+ 'status.offline': 'Żaden node nie jest dostępny',
+ 'status.error': 'Połączenie nie powiodło się',
+
+ // Chat
+ 'chat.empty': 'Nie ma jeszcze wiadomości. Warto zacząć rozmowę!',
+ 'chat.placeholder': 'Napisz wiadomość...',
+ 'chat.send': 'Wyślij',
+ 'chat.attach': 'Załącz plik',
+
+ // Video player
+ 'video.loading': 'Wczytywanie {name}...',
+ 'video.buffering': 'Buforowanie...',
+ 'video.close': 'Zamknij (Esc)',
+ 'preview.pdf_fallback': 'Ta przeglądarka nie wyświetli pliku PDF na stronie. Proszę '
+ + 'go pobrać — i tak został odszyfrowany tutaj.',
+ 'group.upload_indexing': 'indeksowanie…',
+ 'video.err_transport': 'Transport nie jest połączony',
+ 'video.err_mse': 'Kodek nieobsługiwany przy odtwarzaniu strumieniowym: {codec}',
+
+ // Settings
+ 'settings.title': 'Ustawienia',
+ 'settings.coming_soon': 'Wkrótce.',
+ 'settings.downloads': 'Pobieranie',
+ 'settings.dl_auto': 'Zapisuj automatycznie',
+ 'settings.dl_auto_hint': 'Pliki trafiają prosto do folderu wybranego poniżej, bez '
+ + 'żadnego okna. Pobranie dwudziestu plików umieści tam dwadzieścia plików.',
+ 'settings.dl_ask': 'Pytaj za każdym razem',
+ 'settings.dl_ask_hint': 'Okno „Zapisz jako” dla każdego pliku — po jednym na plik, '
+ + 'także przy pobieraniu zaznaczonych pozycji.',
+ 'settings.dl_folder': 'Folder: {name}',
+ 'settings.dl_no_folder': 'Nie wybrano folderu — pobrane pliki trafiają tam, gdzie '
+ + 'umieszcza je przeglądarka',
+ 'settings.dl_choose': 'Wybierz folder',
+ 'settings.dl_change': 'Zmień',
+ 'settings.dl_forget': 'Zapomnij',
+ 'settings.dl_path_note': 'Stronie internetowej nie da się podać ścieżki, więc nie ma '
+ + 'tu czego wpisywać: to przeglądarka udziela dostępu do wskazanego folderu, a '
+ + 'MeshBay zapisuje wyłącznie w jego wnętrzu. Raz na sesję może pojawić się prośba '
+ + 'o potwierdzenie.',
+ 'settings.dl_unsupported': 'Ta przeglądarka nie potrafi zapisywać w dowolnie '
+ + 'wybranym folderze (brak File System Access API), więc pobrane pliki trafiają do '
+ + 'jej własnego folderu pobierania. Chrome i Edge pozwalają wybrać folder.',
+ 'settings.profile': 'Profil',
+ 'settings.username': 'Nazwa użytkownika',
+ 'settings.node_pins': 'Tożsamości nodes',
+ 'settings.node_pins_hint': 'Klucz tożsamości każdego node jest zapamiętywany przy '
+ + 'pierwszym połączeniu. Jeśli się zmieni, połączenie zostanie odrzucone — czego '
+ + 'należy się spodziewać wyłącznie wtedy, gdy operator przeinstalował node. Przed '
+ + 'wyczyszczeniem warto to z nim potwierdzić.',
+ 'settings.node_pins_count': {
+ one: '{n} przypięta',
+ few: '{n} przypięte',
+ many: '{n} przypiętych',
+ other: '{n} przypiętej',
+ },
+ 'settings.node_pins_clear': 'Wyczyść przypięte tożsamości',
+ 'settings.danger': 'Usunięcie konta',
+ 'settings.delete_hint': 'Usuwa z huba konto, członkostwa w grupach i powiadomienia '
+ + 'oraz zwalnia nazwę użytkownika. Nie sięga tego, co znajduje się na nodes: '
+ + 'wysłane pliki pozostają tam, gdzie przechowuje je ich operator, a każdy node '
+ + 'zachowuje przypiętą tożsamość do czasu, aż jego operator ją odepnie. Dzienniki '
+ + 'połączeń są przechowywane przez rok, zgodnie z wymogami prawa.',
+ 'settings.delete_account': 'Usuń moje konto',
+ 'settings.delete_confirm': 'Proszę wpisać hasło-frazę, aby potwierdzić. Tej operacji '
+ + 'nie da się cofnąć.',
+ 'settings.delete_confirm_btn': 'Usuń bezpowrotnie',
+ 'settings.cancel': 'Anuluj',
+ 'settings.appearance': 'Wygląd',
+ 'settings.theme': 'Motyw',
+ 'settings.theme_light': 'Jasny',
+ 'settings.theme_dark': 'Ciemny',
+ 'settings.theme_system': 'Systemowy',
+ 'settings.language': 'Język',
+ 'settings.about': 'O programie',
+ 'settings.version': 'Wersja',
+ 'settings.protocol': 'Protokół',
+ 'settings.role': 'Rola',
+ 'settings.groups': 'Powiadomienia z grup',
+ 'settings.notifications': 'Powiadomienia',
+ 'settings.node_key': 'Powiąż node',
+ 'settings.node_key_desc': 'Osoby prowadzące własny node MeshBay mogą wkleić tutaj '
+ + 'jego klucz publiczny Ed25519, aby powiązać go ze swoim kontem. Klucz ten znajduje '
+ + 'się w panelu administracyjnym node.',
+ 'settings.node_key_placeholder': 'Wklej klucz publiczny Ed25519 node (base64)',
+ 'settings.node_key_submit': 'Powiąż node',
+ 'settings.node_key_success': 'Node został powiązany',
+ 'settings.node_key_current': 'Klucz powiązanego node',
+
+ // Sidebar
+ 'sidebar.create_group': 'Utwórz grupę',
+ 'sidebar.settings': 'Ustawienia',
+ 'sidebar.admin': 'Administracja',
+
+ // User menu
+ 'usermenu.language': 'Język',
+ 'usermenu.settings': 'Ustawienia',
+ 'usermenu.theme_light': 'Motyw jasny',
+ 'usermenu.theme_dark': 'Motyw ciemny',
+ 'usermenu.logout': 'Wyloguj',
+
+ // Admin panel
+ 'admin.title': 'Administracja',
+ 'admin.tab_stats': 'Statystyki',
+ 'admin.tab_users': 'Użytkownicy',
+ 'admin.tab_groups': 'Grupy',
+ 'admin.tab_nodes': 'Nodes',
+ 'admin.tab_logs': 'Dzienniki',
+ 'admin.col_observed_ip': 'Widziany z',
+ 'admin.col_hint': 'Zgłoszony adres',
+ 'admin.col_last_seen': 'Ostatnie zgłoszenie',
+ 'admin.node_online': 'dostępny',
+ 'admin.node_offline': 'niedostępny',
+ 'admin.no_nodes': 'Brak zarejestrowanych nodes',
+ 'admin.nodes_hint': '„Widziany z” to adres, z którego zgłoszenie rzeczywiście '
+ + 'dotarło, w żądaniu podpisanym kluczem node — i to na nim należy się opierać, '
+ + 'odpowiadając na zapytanie. Zgłoszony adres to ten, który node uważa za własny, '
+ + 'poznany od serwera STUN i przesłany do nas; przydaje się do nawiązania '
+ + 'połączenia i niczego nie dowodzi.',
+ 'admin.tab_blocklist': 'Lista blokad',
+
+ // Admin stats
+ 'admin.stat_users': 'Użytkownicy',
+ 'admin.stat_groups': 'Grupy',
+ 'admin.stat_nodes': 'Nodes',
+ 'admin.stat_online': 'Dostępne nodes',
+
+ // Admin users
+ 'admin.users_search': 'Szukaj użytkowników...',
+ 'admin.col_username': 'Nazwa użytkownika',
+ 'admin.col_role': 'Rola',
+ 'admin.col_status': 'Status',
+ 'admin.col_created': 'Utworzono',
+ 'admin.col_actions': 'Działania',
+ 'admin.col_email': 'E-mail',
+ 'admin.col_groups': 'Grupy',
+ 'admin.no_users': 'Nie znaleziono użytkowników',
+ 'admin.btn_suspend': 'Zawieś',
+ 'admin.btn_unsuspend': 'Przywróć',
+ 'admin.btn_details': 'Szczegóły',
+ 'admin.btn_delete': 'Usuń',
+ 'admin.delete_confirm': 'Usunąć konto „{user}”? Tej operacji nie da się cofnąć. '
+ + 'Wysłane przez tę osobę pliki pozostaną na hostujących je nodes, a każdy node '
+ + 'zachowa przypiętą tożsamość do czasu, aż jego operator ją odepnie. Zawieszenie '
+ + 'jest opcją odwracalną.',
+ 'admin.user_detail': 'Szczegóły użytkownika',
+ 'admin.btn_close': 'Zamknij',
+ 'admin.self_note': '(to Pan/Pani)',
+
+ // Admin groups
+ 'admin.col_name': 'Nazwa',
+ 'admin.col_visibility': 'Widoczność',
+ 'admin.col_members': 'Członkowie',
+ 'admin.no_groups': 'Nie znaleziono grup',
+
+ // Admin logs
+ 'admin.col_time': 'Czas',
+ 'admin.col_event': 'Zdarzenie',
+ 'admin.col_user': 'Użytkownik',
+ 'admin.col_ip': 'IP',
+ 'admin.col_detail': 'Szczegóły',
+ 'admin.filter_all': 'Wszystkie zdarzenia',
+ 'admin.no_logs': 'Nie znaleziono wpisów',
+ 'admin.btn_load_more': 'Wczytaj więcej',
+
+ // Admin blocklist
+ 'admin.col_hash': 'Skrót',
+ 'admin.col_reason': 'Powód',
+ 'admin.col_date': 'Data',
+ 'admin.col_added_by': 'Dodane przez',
+ 'admin.no_blocked': 'Brak zablokowanych skrótów',
+ 'admin.add_hash': 'Dodaj skrót do listy blokad',
+ 'admin.hash_placeholder': 'skrót blake3 (64 znaki szesnastkowe)',
+ 'admin.reason_placeholder': 'Powód',
+ 'admin.btn_block': 'Zablokuj',
+ 'admin.btn_unblock': 'Odblokuj',
+
+ // Notifications
+ // Create group
+ 'create_group.title': 'Tworzenie grupy',
+ 'create_group.name': 'Nazwa grupy',
+ 'create_group.name_placeholder': 'np. Zdjęcia rodzinne, Pliki projektu...',
+ 'create_group.visibility': 'Widoczność',
+ 'create_group.private': 'Prywatna',
+ 'create_group.private_desc': 'Tylko zaproszeni członkowie widzą pliki i mają do nich dostęp',
+ 'create_group.public': 'Publiczna',
+ 'create_group.public_desc': 'Każdy może odnaleźć i przeglądać tę grupę',
+ 'create_group.join_policy': 'Zasady dołączania',
+ 'create_group.invite': 'Tylko z zaproszeniem',
+ 'create_group.invite_desc': 'Członków musi zaprosić administrator',
+ 'create_group.open': 'Otwarta (każdy może dołączyć)',
+ 'create_group.open_desc': 'Każdy może dołączyć bez zatwierdzenia',
+ 'create_group.description': 'Opis',
+ 'create_group.description_hint': 'Czego dotyczy ta grupa? (opcjonalnie)',
+ 'create_group.submit': 'Utwórz',
+ 'create_group.hint': 'Grupa potrzebuje node, który będzie hostował pliki. Można '
+ + 'utworzyć grupę teraz, a node podłączyć później.',
+ 'create_group.creating': 'Tworzenie...',
+
+ // Members
+ 'members.col_role': 'Rola',
+ 'members.group_role': 'Rola w grupie',
+ 'members.admin': 'Administrator',
+ 'members.group_admin': 'Administrator grupy',
+ 'members.owner': 'Właściciel',
+ 'members.member': 'Członek',
+ 'members.invite_title': 'Zaproszenie członka',
+ 'members.username_placeholder': 'Nazwa użytkownika',
+ 'members.invite_btn': 'Zaproś',
+ 'members.invite_needs_pairing': 'Zaproszenia są podpisywane kluczem, który ten node '
+ + 'przypiął dla tej przeglądarki — proszę najpierw sparować ją poniżej, zanim '
+ + 'kogokolwiek Pan(i) zaprosi.',
+ 'members.invite_ask_operator': 'Zapraszać może wyłącznie operator node hostującego tę '
+ + 'grupę, z przeglądarki sparowanej z tym node.',
+ 'members.remove': 'Usuń',
+ 'members.remove_confirm': 'Usunąć użytkownika {user} z tej grupy? Jego konto, '
+ + 'pozostałe grupy i wysłane przez niego pliki pozostaną nienaruszone.',
+ 'members.remove_hint': 'Usunięcie kogoś odbiera mu członkostwo i sprawia, że node '
+ + 'przestaje wydawać mu klucz grupy. Nie usuwa to jego konta ani nie odbiera '
+ + 'klucza, który już posiada — jeśli to istotne, należy zmienić klucz na node '
+ + '(meshbay-node gek-init).',
+ 'members.pair_title': 'Sparowanie tej przeglądarki z Pana/Pani node',
+ 'members.pair_hint': 'Node przyjmuje działania operatora — zaproszenia, usuwanie '
+ + 'plików — wyłącznie z przeglądarki, z którą został sparowany. Proszę uruchomić na '
+ + 'node polecenie `meshbay-node operator pair` i wpisać tutaj kod. Kod nigdy nie '
+ + 'przechodzi przez hub.',
+ 'members.pair_btn': 'Sparuj',
+ 'members.pair_success': 'Ta przeglądarka jest już sparowana z node.',
+ 'members.invite_code_ready': 'Kod zaproszenia dla użytkownika {user} — proszę '
+ + 'przekazać go tak, jak zwykle się Państwo kontaktują. Działa jednorazowo i nigdy '
+ + 'nie przechodzi przez hub.',
+ 'members.invite_code_hint': 'Osoba zaproszona wpisze go, otwierając tę grupę po raz '
+ + 'pierwszy. Nie trzeba być wtedy dostępnym.',
+ 'transfers.title': 'Transfery',
+ 'transfers.clear': 'Wyczyść zakończone',
+ 'transfers.cancel': 'Anuluj',
+ 'transfers.open': 'Otwórz',
+ 'transfers.done': 'Zakończony',
+ 'transfers.cancelled': 'Anulowany',
+ 'transfers.failed': 'Nie powiódł się',
+ 'group.select': 'Zaznacz',
+ 'group.select_done': 'Gotowe',
+ 'group.actions': 'Działania ({n})',
+ 'group.download_n': 'Pobierz ({n})',
+ 'group.download_zip_n': 'Pobierz foldery jako zip ({n})',
+ 'group.delete_n': 'Usuń ({n})',
+ 'group.delete_n_confirm': {
+ one: 'Usunąć {n} element? {names}',
+ few: 'Usunąć {n} elementy? {names}',
+ many: 'Usunąć {n} elementów? {names}',
+ other: 'Usunąć {n} elementu? {names}',
+ },
+ 'group.download_zip': {
+ one: 'Pobierz jako zip ({n} plik)',
+ few: 'Pobierz jako zip ({n} pliki)',
+ many: 'Pobierz jako zip ({n} plików)',
+ other: 'Pobierz jako zip ({n} pliku)',
+ },
+ 'group.zip_empty': 'W tym folderze nie ma nic do pobrania.',
+ 'group.zip_no_stream': 'Ta przeglądarka nie potrafi zapisywać pobieranych danych '
+ + 'wprost na dysk, więc {name} ({size}) trzeba najpierw zbudować w pamięci. Przy '
+ + 'dużym archiwum może się to nie udać. Kontynuować?',
+ 'group.rmdir': 'Usuń folder',
+ 'group.rmdir_confirm': 'Usunąć folder „{name}”? Musi być pusty.',
+ 'group.rmdir_not_empty': 'Ten folder nie jest pusty. Proszę najpierw usunąć jego '
+ + 'zawartość — nic tutaj nie kasuje plików, których nie widać.',
+ 'group.desc_edit': 'Edytuj opis',
+ 'group.desc_add': 'Dodaj opis',
+ 'group.desc_save': 'Zapisz',
+ 'group.desc_cancel': 'Anuluj',
+ 'group.desc_placeholder': 'Do czego służy ta grupa — członkowie zobaczą to na swojej '
+ + 'stronie głównej.',
+ 'group.join_code_title': 'Ten node musi Pana/Panią rozpoznać',
+ 'group.join_code_hint': 'Proszę poprosić osobę zapraszającą o kod jednorazowy i '
+ + 'wpisać go tutaj. Od tej pory przeglądarka jest rozpoznawana i pytanie się nie '
+ + 'powtórzy.',
+ 'group.join_code_btn': 'Dołącz',
+
+ 'notif.purge': 'Wyczyść wszystko',
+ 'notif.title': 'Powiadomienia',
+ 'notif.empty': 'Brak powiadomień',
+ 'notif.mark_all_read': 'Oznacz wszystkie jako przeczytane',
+
+ // Sidebar
+ 'sidebar.search': 'Szukaj plików',
+
+ // Status
+
+ // Search
+ 'search.title': 'Wyszukiwanie plików',
+ 'search.placeholder': 'Szukaj we wszystkich grupach...',
+ 'search.no_results': 'Żaden plik nie pasuje do zapytania.',
+ 'search.synced': 'zsynchronizowano {ago}',
+ 'search.cache_note': 'Wyszukiwanie opiera się na stanie każdej grupy z chwili jej '
+ + 'ostatniego otwarcia. Otwarcie grupy odświeża tutaj jej pliki.',
+ 'search.col_group': 'Grupa',
+ 'search.result_count': {
+ one: 'Znaleziono {n} plik',
+ few: 'Znaleziono {n} pliki',
+ many: 'Znaleziono {n} plików',
+ other: 'Znaleziono {n} pliku',
+ },
+ 'search.hint': 'Przeszukuje nazwy plików we wszystkich zbuforowanych indeksach grup.',
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
new file mode 100644
index 0000000..9d00ece
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -0,0 +1,403 @@
+/**
+ * Brazilian Portuguese — translated from locales/en.js.
+ *
+ * Register: formal. In Brazilian Portuguese that is "você" with polite phrasing,
+ * not "o senhor / a senhora" — the latter reads as stiff in software and is
+ * reserved for addressing someone markedly senior.
+ * `hub`, `node` and `GEK` are left in English: they name the CLI
+ * (`meshbay-node`), node.toml and the docs, which are English throughout.
+ */
+
+export default {
+ // Nav
+ 'nav.toggle_menu': 'Exibir o menu',
+ 'nav.light_mode': 'Tema claro',
+ 'nav.dark_mode': 'Tema escuro',
+ 'nav.logout': 'Sair',
+ 'nav.login': 'Entrar',
+
+ // Sidebar
+ 'sidebar.my_groups': 'Meus grupos',
+ 'sidebar.no_groups': 'Nenhum grupo ainda',
+ 'sidebar.discover': 'Descobrir',
+ 'sidebar.public_groups': 'Grupos públicos',
+
+ // Login
+ 'login.title': 'Entrar',
+ 'login.username': 'Nome de usuário',
+ 'login.password': 'Senha',
+ 'login.submit': 'Entrar',
+ 'login.loading': 'Entrando...',
+ 'login.no_account': 'Ainda não tem conta?',
+ 'login.register_link': 'Criar uma conta',
+
+ // Register
+ 'register.title': 'Criar uma conta',
+ 'register.username': 'Nome de usuário',
+ 'register.email': 'E-mail',
+ 'register.password': 'Frase secreta (mínimo de 12 caracteres)',
+ 'register.confirm': 'Confirmar a senha',
+ 'register.submit': 'Criar a conta',
+ 'register.loading': 'Criando a conta...',
+ 'register.has_account': 'Já tem uma conta?',
+ 'register.login_link': 'Entrar',
+ 'register.success_title': 'Conta criada',
+ 'register.success_msg': 'Agora você pode entrar com suas credenciais.',
+ 'register.go_login': 'Ir para o login',
+ 'register.err_mismatch': 'As senhas não coincidem',
+ 'register.err_min_len': {
+ one: 'Use pelo menos {n} caractere',
+ other: 'Use pelo menos {n} caracteres',
+ },
+ 'register.err_too_weak': 'Fácil demais de adivinhar. Sua frase secreta é o que '
+ + 'protege suas chaves onde elas ficam guardadas — algumas palavras sem relação '
+ + 'entre si funcionam muito bem.',
+ 'register.strength': 'Robustez: cerca de {bits} bits. Ela protege a cópia das suas '
+ + 'chaves mantida nos nodes dos quais você participa, então vale a pena caprichar.',
+
+ // Home
+ 'home.welcome': 'Bem-vindo ao MeshBay',
+ 'home.no_groups': 'Você ainda não participa de nenhum grupo.',
+ 'home.browse_prefix': 'Explore os ',
+ 'home.browse_link': 'grupos públicos',
+ 'home.browse_suffix': ' ou peça a um administrador de grupo que o convide.',
+ 'home.my_groups': 'Meus grupos',
+
+ // Explore
+ 'explore.title': 'Grupos públicos',
+ 'explore.search': 'Pesquisar grupos...',
+ 'explore.loading': 'Carregando...',
+ 'explore.empty': 'Nenhum grupo público disponível.',
+ 'explore.create_group': 'Criar um grupo',
+ 'explore.join': 'Participar',
+ 'explore.member': 'Participando',
+
+ // Group page
+ 'group.default_name': 'Grupo',
+ 'group.tab_files': 'Arquivos',
+ 'group.tab_chat': 'Conversa',
+ 'group.tab_members': 'Membros',
+ 'group.filter': 'Filtrar arquivos...',
+ 'group.col_name': 'Nome',
+ 'group.col_size': 'Tamanho',
+ 'group.col_type': 'Tipo',
+ 'group.col_date': 'Data',
+ 'group.empty_filter': 'Nenhum arquivo corresponde ao filtro',
+ 'group.empty_dir': 'Esta pasta está vazia',
+ 'group.download': 'Baixar',
+ 'group.play': 'Reproduzir',
+ 'group.offline_title': 'Nenhum node está on-line para este grupo no momento.',
+ 'group.offline_hint': 'Os arquivos aparecerão quando um node que hospeda este grupo '
+ + 'se conectar.',
+ 'group.upload': 'Enviar',
+ 'group.mkdir': 'Nova pasta',
+ 'group.mkdir_prompt': 'Nome da nova pasta:',
+ 'group.view': 'Visualizar',
+ 'group.delete': 'Excluir',
+ 'group.delete_confirm': 'Excluir {name}?',
+ 'group.delete_group': 'Excluir o grupo',
+ 'group.delete_group_confirm': 'Excluir "{name}" definitivamente e remover todos os '
+ + 'membros? Os arquivos que estão no node são preservados.',
+ 'group.err_transport': 'Módulo de transporte não carregado',
+
+ // Status
+ 'status.idle': 'Ocioso',
+ 'status.discovering': 'Procurando nodes...',
+ 'status.connecting': 'Conectando via WebRTC...',
+ 'status.fetching': 'Obtendo o índice...',
+ 'status.files': {
+ one: '{n} arquivo',
+ other: '{n} arquivos',
+ },
+ 'status.offline': 'Nenhum node on-line',
+ 'status.error': 'Falha na conexão',
+
+ // Chat
+ 'chat.empty': 'Ainda não há mensagens. Comece a conversa!',
+ 'chat.placeholder': 'Escreva uma mensagem...',
+ 'chat.send': 'Enviar',
+ 'chat.attach': 'Anexar arquivo',
+
+ // Video player
+ 'video.loading': 'Carregando {name}...',
+ 'video.buffering': 'Armazenando em buffer...',
+ 'video.close': 'Fechar (Esc)',
+ 'preview.pdf_fallback': 'Este navegador não exibirá o PDF na própria página. Baixe '
+ + 'o arquivo — de todo modo ele foi descriptografado aqui.',
+ 'group.upload_indexing': 'indexando…',
+ 'video.err_transport': 'Transporte não conectado',
+ 'video.err_mse': 'Codec sem suporte para transmissão: {codec}',
+
+ // Settings
+ 'settings.title': 'Configurações',
+ 'settings.coming_soon': 'Em breve.',
+ 'settings.downloads': 'Downloads',
+ 'settings.dl_auto': 'Salvar automaticamente',
+ 'settings.dl_auto_hint': 'Os arquivos vão direto para a pasta escolhida abaixo, sem '
+ + 'nenhuma caixa de diálogo. Baixar vinte arquivos coloca vinte arquivos lá.',
+ 'settings.dl_ask': 'Perguntar sempre',
+ 'settings.dl_ask_hint': 'Uma caixa "Salvar como" por arquivo — uma para cada '
+ + 'arquivo, inclusive quando você baixa uma seleção.',
+ 'settings.dl_folder': 'Pasta: {name}',
+ 'settings.dl_no_folder': 'Nenhuma pasta escolhida — os downloads vão para onde o '
+ + 'seu navegador os colocar',
+ 'settings.dl_choose': 'Escolher pasta',
+ 'settings.dl_change': 'Alterar',
+ 'settings.dl_forget': 'Esquecer',
+ 'settings.dl_path_note': 'Não é possível informar um caminho a uma página web, '
+ + 'portanto não há nada a digitar aqui: o seu navegador concede acesso à pasta que '
+ + 'você indicar, e o MeshBay só escreve dentro dela. Talvez seja pedida uma '
+ + 'confirmação uma vez por sessão.',
+ 'settings.dl_unsupported': 'Este navegador não consegue escrever em uma pasta de sua '
+ + 'escolha (não tem a File System Access API), então os downloads vão para a pasta '
+ + 'de downloads dele. Chrome e Edge permitem escolher uma.',
+ 'settings.profile': 'Perfil',
+ 'settings.username': 'Nome de usuário',
+ 'settings.node_pins': 'Identidades dos nodes',
+ 'settings.node_pins_hint': 'A chave de identidade de cada node é memorizada na '
+ + 'primeira conexão. Se ela mudar, a conexão é recusada — o que só é esperado '
+ + 'quando um operador reinstala um node. Confirme com ele antes de limpar.',
+ 'settings.node_pins_count': {
+ one: '{n} fixada',
+ other: '{n} fixadas',
+ },
+ 'settings.node_pins_clear': 'Limpar as identidades fixadas',
+ 'settings.danger': 'Excluir a conta',
+ 'settings.delete_hint': 'Remove sua conta, suas participações em grupos e suas '
+ + 'notificações do hub, e libera seu nome de usuário. Não alcança o que está nos '
+ + 'nodes: os arquivos que você enviou permanecem onde o operador deles os guarda, e '
+ + 'cada node mantém a identidade que fixou para você até que o operador a remova. '
+ + 'Os registros de conexão são guardados por um ano, como a lei exige.',
+ 'settings.delete_account': 'Excluir minha conta',
+ 'settings.delete_confirm': 'Digite sua frase secreta para confirmar. Esta ação não '
+ + 'pode ser desfeita.',
+ 'settings.delete_confirm_btn': 'Excluir definitivamente',
+ 'settings.cancel': 'Cancelar',
+ 'settings.appearance': 'Aparência',
+ 'settings.theme': 'Tema',
+ 'settings.theme_light': 'Claro',
+ 'settings.theme_dark': 'Escuro',
+ 'settings.theme_system': 'Sistema',
+ 'settings.language': 'Idioma',
+ 'settings.about': 'Sobre',
+ 'settings.version': 'Versão',
+ 'settings.protocol': 'Protocolo',
+ 'settings.role': 'Função',
+ 'settings.groups': 'Notificações de grupo',
+ 'settings.notifications': 'Notificações',
+ 'settings.node_key': 'Vincular um node',
+ 'settings.node_key_desc': 'Se você opera um node MeshBay, cole aqui a chave pública '
+ + 'Ed25519 dele para vinculá-lo à sua conta. Essa chave está no painel de '
+ + 'administração do seu node.',
+ 'settings.node_key_placeholder': 'Cole a chave pública Ed25519 do node (base64)',
+ 'settings.node_key_submit': 'Vincular o node',
+ 'settings.node_key_success': 'Node vinculado com sucesso',
+ 'settings.node_key_current': 'Chave do node vinculado',
+
+ // Sidebar
+ 'sidebar.create_group': 'Criar um grupo',
+ 'sidebar.settings': 'Configurações',
+ 'sidebar.admin': 'Administração',
+
+ // User menu
+ 'usermenu.language': 'Idioma',
+ 'usermenu.settings': 'Configurações',
+ 'usermenu.theme_light': 'Tema claro',
+ 'usermenu.theme_dark': 'Tema escuro',
+ 'usermenu.logout': 'Sair',
+
+ // Admin panel
+ 'admin.title': 'Administração',
+ 'admin.tab_stats': 'Estatísticas',
+ 'admin.tab_users': 'Usuários',
+ 'admin.tab_groups': 'Grupos',
+ 'admin.tab_nodes': 'Nodes',
+ 'admin.tab_logs': 'Registros',
+ 'admin.col_observed_ip': 'Visto de',
+ 'admin.col_hint': 'Endereço anunciado',
+ 'admin.col_last_seen': 'Último anúncio',
+ 'admin.node_online': 'on-line',
+ 'admin.node_offline': 'off-line',
+ 'admin.no_nodes': 'Nenhum node registrado',
+ 'admin.nodes_hint': '"Visto de" é o endereço de onde o anúncio chegou, em uma '
+ + 'requisição assinada com a chave do node — é esse que serve para responder a uma '
+ + 'requisição judicial. O endereço anunciado é o que o node acredita ser o seu, '
+ + 'aprendido de um servidor STUN e enviado a nós; serve para alcançar o node e não '
+ + 'prova coisa alguma.',
+ 'admin.tab_blocklist': 'Lista de bloqueio',
+
+ // Admin stats
+ 'admin.stat_users': 'Usuários',
+ 'admin.stat_groups': 'Grupos',
+ 'admin.stat_nodes': 'Nodes',
+ 'admin.stat_online': 'Nodes on-line',
+
+ // Admin users
+ 'admin.users_search': 'Pesquisar usuários...',
+ 'admin.col_username': 'Nome de usuário',
+ 'admin.col_role': 'Função',
+ 'admin.col_status': 'Status',
+ 'admin.col_created': 'Criação',
+ 'admin.col_actions': 'Ações',
+ 'admin.col_email': 'E-mail',
+ 'admin.col_groups': 'Grupos',
+ 'admin.no_users': 'Nenhum usuário encontrado',
+ 'admin.btn_suspend': 'Suspender',
+ 'admin.btn_unsuspend': 'Reativar',
+ 'admin.btn_details': 'Detalhes',
+ 'admin.btn_delete': 'Excluir',
+ 'admin.delete_confirm': 'Excluir a conta "{user}"? Esta ação não pode ser desfeita. '
+ + 'Os arquivos que essa pessoa enviou permanecem nos nodes que os hospedam, e cada '
+ + 'node mantém a identidade que fixou até que o operador dele a remova. Suspender é '
+ + 'a opção reversível.',
+ 'admin.user_detail': 'Detalhes do usuário',
+ 'admin.btn_close': 'Fechar',
+ 'admin.self_note': '(você)',
+
+ // Admin groups
+ 'admin.col_name': 'Nome',
+ 'admin.col_visibility': 'Visibilidade',
+ 'admin.col_members': 'Membros',
+ 'admin.no_groups': 'Nenhum grupo encontrado',
+
+ // Admin logs
+ 'admin.col_time': 'Horário',
+ 'admin.col_event': 'Evento',
+ 'admin.col_user': 'Usuário',
+ 'admin.col_ip': 'IP',
+ 'admin.col_detail': 'Detalhe',
+ 'admin.filter_all': 'Todos os eventos',
+ 'admin.no_logs': 'Nenhum registro encontrado',
+ 'admin.btn_load_more': 'Carregar mais',
+
+ // Admin blocklist
+ 'admin.col_hash': 'Hash',
+ 'admin.col_reason': 'Motivo',
+ 'admin.col_date': 'Data',
+ 'admin.col_added_by': 'Adicionado por',
+ 'admin.no_blocked': 'Nenhum hash bloqueado',
+ 'admin.add_hash': 'Adicionar um hash à lista de bloqueio',
+ 'admin.hash_placeholder': 'hash blake3 (64 caracteres hexadecimais)',
+ 'admin.reason_placeholder': 'Motivo',
+ 'admin.btn_block': 'Bloquear',
+ 'admin.btn_unblock': 'Desbloquear',
+
+ // Notifications
+ // Create group
+ 'create_group.title': 'Criar um grupo',
+ 'create_group.name': 'Nome do grupo',
+ 'create_group.name_placeholder': 'ex.: Fotos da família, Arquivos do projeto...',
+ 'create_group.visibility': 'Visibilidade',
+ 'create_group.private': 'Privado',
+ 'create_group.private_desc': 'Só os membros convidados veem os arquivos e acessam a eles',
+ 'create_group.public': 'Público',
+ 'create_group.public_desc': 'Qualquer pessoa pode descobrir e explorar este grupo',
+ 'create_group.join_policy': 'Política de entrada',
+ 'create_group.invite': 'Somente por convite',
+ 'create_group.invite_desc': 'Os membros precisam ser convidados por um administrador',
+ 'create_group.open': 'Aberto (qualquer pessoa pode entrar)',
+ 'create_group.open_desc': 'Qualquer pessoa pode entrar sem aprovação',
+ 'create_group.description': 'Descrição',
+ 'create_group.description_hint': 'Sobre o que é este grupo? (opcional)',
+ 'create_group.submit': 'Criar',
+ 'create_group.hint': 'Um grupo precisa de um node para hospedar os arquivos. Você '
+ + 'pode criar o grupo agora e conectar um node depois.',
+ 'create_group.creating': 'Criando...',
+
+ // Members
+ 'members.col_role': 'Função',
+ 'members.group_role': 'Função no grupo',
+ 'members.admin': 'Administrador',
+ 'members.group_admin': 'Administrador do grupo',
+ 'members.owner': 'Proprietário',
+ 'members.member': 'Membro',
+ 'members.invite_title': 'Convidar um membro',
+ 'members.username_placeholder': 'Nome de usuário',
+ 'members.invite_btn': 'Convidar',
+ 'members.invite_needs_pairing': 'Os convites são assinados com a chave que este node '
+ + 'fixou para o seu navegador, então faça o pareamento abaixo antes de convidar '
+ + 'alguém.',
+ 'members.invite_ask_operator': 'Só o operador do node que hospeda este grupo pode '
+ + 'convidar, a partir de um navegador pareado com ele.',
+ 'members.remove': 'Remover',
+ 'members.remove_confirm': 'Remover {user} deste grupo? A conta dessa pessoa, os '
+ + 'outros grupos dela e os arquivos que ela enviou não são afetados.',
+ 'members.remove_hint': 'Remover alguém retira a participação dessa pessoa e faz o '
+ + 'node parar de entregar a ela a chave do grupo. Não exclui a conta dela, e não '
+ + 'recupera a chave que ela já tem em mãos — rotacione a chave no node '
+ + '(meshbay-node gek-init) se isso importar.',
+ 'members.pair_title': 'Parear este navegador com o seu node',
+ 'members.pair_hint': 'O seu node só aceita ações de operador — convites, exclusão de '
+ + 'arquivos — de um navegador com o qual foi pareado. Execute '
+ + '`meshbay-node operator pair` no node e digite o código aqui. O código nunca '
+ + 'passa pelo hub.',
+ 'members.pair_btn': 'Parear',
+ 'members.pair_success': 'Este navegador agora está pareado com o node.',
+ 'members.invite_code_ready': 'Código de convite para {user} — envie a ele pelo meio '
+ + 'que vocês costumam usar. Funciona uma única vez e nunca passa pelo hub.',
+ 'members.invite_code_hint': 'A pessoa digita o código na primeira vez que abrir este '
+ + 'grupo. Você não precisa estar on-line nesse momento.',
+ 'transfers.title': 'Transferências',
+ 'transfers.clear': 'Limpar as concluídas',
+ 'transfers.cancel': 'Cancelar',
+ 'transfers.open': 'Abrir',
+ 'transfers.done': 'Concluída',
+ 'transfers.cancelled': 'Cancelada',
+ 'transfers.failed': 'Falhou',
+ 'group.select': 'Selecionar',
+ 'group.select_done': 'Concluir',
+ 'group.actions': 'Ações ({n})',
+ 'group.download_n': 'Baixar ({n})',
+ 'group.download_zip_n': 'Baixar as pastas em zip ({n})',
+ 'group.delete_n': 'Excluir ({n})',
+ 'group.delete_n_confirm': {
+ one: 'Excluir {n} item? {names}',
+ other: 'Excluir {n} itens? {names}',
+ },
+ 'group.download_zip': {
+ one: 'Baixar em zip ({n} arquivo)',
+ other: 'Baixar em zip ({n} arquivos)',
+ },
+ 'group.zip_empty': 'Essa pasta não tem nada para baixar.',
+ 'group.zip_no_stream': 'Este navegador não consegue escrever um download direto no '
+ + 'disco, então {name} ({size}) precisa ser montado antes na memória. Em um arquivo '
+ + 'grande isso pode falhar. Continuar?',
+ 'group.rmdir': 'Excluir a pasta',
+ 'group.rmdir_confirm': 'Excluir a pasta "{name}"? Ela precisa estar vazia.',
+ 'group.rmdir_not_empty': 'Essa pasta não está vazia. Exclua antes o que está dentro '
+ + 'dela — nada aqui apaga arquivos que você não vê.',
+ 'group.desc_edit': 'Editar a descrição',
+ 'group.desc_add': 'Adicionar uma descrição',
+ 'group.desc_save': 'Salvar',
+ 'group.desc_cancel': 'Cancelar',
+ 'group.desc_placeholder': 'Para que serve este grupo — os membros veem isto na '
+ + 'página inicial deles.',
+ 'group.join_code_title': 'Este node precisa reconhecer você',
+ 'group.join_code_hint': 'Peça o código de uso único a quem convidou você e digite-o '
+ + 'aqui. Depois disso este navegador fica reconhecido e a pergunta não se repete.',
+ 'group.join_code_btn': 'Participar',
+
+ 'notif.purge': 'Limpar tudo',
+ 'notif.title': 'Notificações',
+ 'notif.empty': 'Nenhuma notificação',
+ 'notif.mark_all_read': 'Marcar tudo como lido',
+
+ // Sidebar
+ 'sidebar.search': 'Pesquisar arquivos',
+
+ // Status
+
+ // Search
+ 'search.title': 'Pesquisar arquivos',
+ 'search.placeholder': 'Pesquisar em todos os grupos...',
+ 'search.no_results': 'Nenhum arquivo corresponde à sua pesquisa.',
+ 'search.synced': 'sincronizado {ago}',
+ 'search.cache_note': 'A pesquisa usa o estado de cada grupo na última vez que você o '
+ + 'abriu. Abra um grupo para atualizar os arquivos dele aqui.',
+ 'search.col_group': 'Grupo',
+ 'search.result_count': {
+ one: '{n} arquivo encontrado',
+ other: '{n} arquivos encontrados',
+ },
+ 'search.hint': 'Pesquisa nomes de arquivo em todos os índices de grupo em cache.',
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
new file mode 100644
index 0000000..bfa6d05
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -0,0 +1,376 @@
+/**
+ * Simplified Chinese — translated from locales/en.js.
+ *
+ * Register: formal — the reader is addressed as 您 throughout.
+ * `hub`, `node` and `GEK` are left in English: they name the CLI
+ * (`meshbay-node`), node.toml and the docs, which are English throughout.
+ * Chinese has a single plural category, so counted strings need only `other`.
+ */
+
+export default {
+ // Nav
+ 'nav.toggle_menu': '显示菜单',
+ 'nav.light_mode': '浅色主题',
+ 'nav.dark_mode': '深色主题',
+ 'nav.logout': '退出登录',
+ 'nav.login': '登录',
+
+ // Sidebar
+ 'sidebar.my_groups': '我的群组',
+ 'sidebar.no_groups': '暂无群组',
+ 'sidebar.discover': '发现',
+ 'sidebar.public_groups': '公开群组',
+
+ // Login
+ 'login.title': '登录',
+ 'login.username': '用户名',
+ 'login.password': '密码',
+ 'login.submit': '登录',
+ 'login.loading': '正在登录…',
+ 'login.no_account': '还没有账户?',
+ 'login.register_link': '注册',
+
+ // Register
+ 'register.title': '注册',
+ 'register.username': '用户名',
+ 'register.email': '电子邮箱',
+ 'register.password': '密码短语(至少 12 个字符)',
+ 'register.confirm': '确认密码',
+ 'register.submit': '注册',
+ 'register.loading': '正在创建账户…',
+ 'register.has_account': '已经有账户了?',
+ 'register.login_link': '登录',
+ 'register.success_title': '账户已创建',
+ 'register.success_msg': '您现在可以使用您的凭据登录了。',
+ 'register.go_login': '前往登录',
+ 'register.err_mismatch': '两次输入的密码不一致',
+ 'register.err_min_len': {
+ other: '请至少使用 {n} 个字符',
+ },
+ 'register.err_too_weak': '太容易被猜到了。您的密码短语正是保护密钥存放之处的东西——'
+ + '几个互不相关的词就很好用。',
+ 'register.strength': '强度:约 {bits} 位。它保护的是您所加入的各个 node 上保存的密钥副本,'
+ + '因此值得认真设置。',
+
+ // Home
+ 'home.welcome': '欢迎使用 MeshBay',
+ 'home.no_groups': '您还不是任何群组的成员。',
+ 'home.browse_prefix': '浏览',
+ 'home.browse_link': '公开群组',
+ 'home.browse_suffix': ',或请群组管理员邀请您。',
+ 'home.my_groups': '我的群组',
+
+ // Explore
+ 'explore.title': '公开群组',
+ 'explore.search': '搜索群组…',
+ 'explore.loading': '加载中…',
+ 'explore.empty': '暂无可用的公开群组。',
+ 'explore.create_group': '创建群组',
+ 'explore.join': '加入',
+ 'explore.member': '已加入',
+
+ // Group page
+ 'group.default_name': '群组',
+ 'group.tab_files': '文件',
+ 'group.tab_chat': '聊天',
+ 'group.tab_members': '成员',
+ 'group.filter': '筛选文件…',
+ 'group.col_name': '名称',
+ 'group.col_size': '大小',
+ 'group.col_type': '类型',
+ 'group.col_date': '日期',
+ 'group.empty_filter': '没有文件符合筛选条件',
+ 'group.empty_dir': '此文件夹为空',
+ 'group.download': '下载',
+ 'group.play': '播放',
+ 'group.offline_title': '当前没有为此群组提供服务的 node 在线。',
+ 'group.offline_hint': '当托管此群组的 node 连接后,文件就会出现。',
+ 'group.upload': '上传',
+ 'group.mkdir': '新建文件夹',
+ 'group.mkdir_prompt': '新文件夹的名称:',
+ 'group.view': '查看',
+ 'group.delete': '删除',
+ 'group.delete_confirm': '删除 {name}?',
+ 'group.delete_group': '删除群组',
+ 'group.delete_group_confirm': '永久删除“{name}”并移除所有成员?node 上的文件会被保留。',
+ 'group.err_transport': '传输模块未加载',
+
+ // Status
+ 'status.idle': '空闲',
+ 'status.discovering': '正在查找 node…',
+ 'status.connecting': '正在通过 WebRTC 连接…',
+ 'status.fetching': '正在获取索引…',
+ 'status.files': {
+ other: '{n} 个文件',
+ },
+ 'status.offline': '没有 node 在线',
+ 'status.error': '连接失败',
+
+ // Chat
+ 'chat.empty': '还没有消息。开始聊天吧!',
+ 'chat.placeholder': '输入消息…',
+ 'chat.send': '发送',
+ 'chat.attach': '添加附件',
+
+ // Video player
+ 'video.loading': '正在加载 {name}…',
+ 'video.buffering': '正在缓冲…',
+ 'video.close': '关闭(Esc)',
+ 'preview.pdf_fallback': '此浏览器不会在页面内显示该 PDF。请改为下载——无论如何它都已在本地解密。',
+ 'group.upload_indexing': '建立索引中…',
+ 'video.err_transport': '传输未连接',
+ 'video.err_mse': '该编解码器不支持流式播放:{codec}',
+
+ // Settings
+ 'settings.title': '设置',
+ 'settings.coming_soon': '即将推出。',
+ 'settings.downloads': '下载',
+ 'settings.dl_auto': '自动保存',
+ 'settings.dl_auto_hint': '文件会直接进入您在下方选择的文件夹,不弹出任何对话框。'
+ + '下载二十个文件,那里就会多出二十个文件。',
+ 'settings.dl_ask': '每次都询问',
+ 'settings.dl_ask_hint': '每个文件弹出一次“另存为”对话框——每个文件一次,'
+ + '批量下载时也是如此。',
+ 'settings.dl_folder': '文件夹:{name}',
+ 'settings.dl_no_folder': '未选择文件夹——下载内容会保存到浏览器指定的位置',
+ 'settings.dl_choose': '选择文件夹',
+ 'settings.dl_change': '更改',
+ 'settings.dl_forget': '忘记',
+ 'settings.dl_path_note': '网页无法被指定一个路径,因此这里没有什么需要输入:'
+ + '由您的浏览器授予对所选文件夹的访问权限,而 MeshBay 只会写入该文件夹之内。'
+ + '每个会话可能会要求您确认一次。',
+ 'settings.dl_unsupported': '此浏览器无法写入您指定的文件夹(不支持 File System '
+ + 'Access API),因此下载内容会进入它自己的下载文件夹。Chrome 和 Edge 支持自行选择。',
+ 'settings.profile': '个人资料',
+ 'settings.username': '用户名',
+ 'settings.node_pins': 'node 身份',
+ 'settings.node_pins_hint': '每个 node 的身份密钥都会在您首次连接时被记住。'
+ + '如果它发生变化,连接会被拒绝——只有当运营者重装 node 时才应如此。'
+ + '清除之前请先与对方核实。',
+ 'settings.node_pins_count': {
+ other: '已固定 {n} 个',
+ },
+ 'settings.node_pins_clear': '清除已固定的身份',
+ 'settings.danger': '删除账户',
+ 'settings.delete_hint': '这会从 hub 上删除您的账户、您的群组成员身份和您的通知,'
+ + '并释放您的用户名。它无法触及存放在各个 node 上的内容:您上传的文件仍留在其运营者'
+ + '保管之处,每个 node 也会保留它为您固定的身份,直到其运营者取消固定。'
+ + '连接日志按法律要求保存一年。',
+ 'settings.delete_account': '删除我的账户',
+ 'settings.delete_confirm': '请输入您的密码短语以确认。此操作无法撤销。',
+ 'settings.delete_confirm_btn': '永久删除',
+ 'settings.cancel': '取消',
+ 'settings.appearance': '外观',
+ 'settings.theme': '主题',
+ 'settings.theme_light': '浅色',
+ 'settings.theme_dark': '深色',
+ 'settings.theme_system': '跟随系统',
+ 'settings.language': '语言',
+ 'settings.about': '关于',
+ 'settings.version': '版本',
+ 'settings.protocol': '协议',
+ 'settings.role': '角色',
+ 'settings.groups': '群组通知',
+ 'settings.notifications': '通知',
+ 'settings.node_key': '关联 node',
+ 'settings.node_key_desc': '如果您运营着一个 MeshBay node,请在此粘贴它的 Ed25519 '
+ + '公钥,以将其关联到您的账户。该密钥可在您 node 的管理面板中找到。',
+ 'settings.node_key_placeholder': '粘贴 node 的 Ed25519 公钥(base64)',
+ 'settings.node_key_submit': '关联 node',
+ 'settings.node_key_success': 'node 关联成功',
+ 'settings.node_key_current': '已关联的 node 密钥',
+
+ // Sidebar
+ 'sidebar.create_group': '创建群组',
+ 'sidebar.settings': '设置',
+ 'sidebar.admin': '管理',
+
+ // User menu
+ 'usermenu.language': '语言',
+ 'usermenu.settings': '设置',
+ 'usermenu.theme_light': '浅色主题',
+ 'usermenu.theme_dark': '深色主题',
+ 'usermenu.logout': '退出登录',
+
+ // Admin panel
+ 'admin.title': '管理',
+ 'admin.tab_stats': '统计',
+ 'admin.tab_users': '用户',
+ 'admin.tab_groups': '群组',
+ 'admin.tab_nodes': 'node',
+ 'admin.tab_logs': '日志',
+ 'admin.col_observed_ip': '来源地址',
+ 'admin.col_hint': '自报地址',
+ 'admin.col_last_seen': '最近通告',
+ 'admin.node_online': '在线',
+ 'admin.node_offline': '离线',
+ 'admin.no_nodes': '没有已注册的 node',
+ 'admin.nodes_hint': '“来源地址”是该通告实际到达时的地址,请求由 node 密钥签名——'
+ + '需要答复查询时应以此为准。自报地址是 node 自认为的地址,从 STUN 服务器获知后发给我们;'
+ + '它有助于连上该 node,但不能证明任何事情。',
+ 'admin.tab_blocklist': '屏蔽列表',
+
+ // Admin stats
+ 'admin.stat_users': '用户',
+ 'admin.stat_groups': '群组',
+ 'admin.stat_nodes': 'node',
+ 'admin.stat_online': '在线 node',
+
+ // Admin users
+ 'admin.users_search': '搜索用户…',
+ 'admin.col_username': '用户名',
+ 'admin.col_role': '角色',
+ 'admin.col_status': '状态',
+ 'admin.col_created': '创建时间',
+ 'admin.col_actions': '操作',
+ 'admin.col_email': '电子邮箱',
+ 'admin.col_groups': '群组',
+ 'admin.no_users': '未找到用户',
+ 'admin.btn_suspend': '停用',
+ 'admin.btn_unsuspend': '恢复',
+ 'admin.btn_details': '详情',
+ 'admin.btn_delete': '删除',
+ 'admin.delete_confirm': '删除账户“{user}”?此操作无法撤销。'
+ + '该用户上传的文件仍留在托管它们的 node 上,每个 node 也会保留它固定的身份,'
+ + '直到其运营者取消固定。停用是可撤销的选项。',
+ 'admin.user_detail': '用户详情',
+ 'admin.btn_close': '关闭',
+ 'admin.self_note': '(您)',
+
+ // Admin groups
+ 'admin.col_name': '名称',
+ 'admin.col_visibility': '可见性',
+ 'admin.col_members': '成员',
+ 'admin.no_groups': '未找到群组',
+
+ // Admin logs
+ 'admin.col_time': '时间',
+ 'admin.col_event': '事件',
+ 'admin.col_user': '用户',
+ 'admin.col_ip': 'IP',
+ 'admin.col_detail': '详情',
+ 'admin.filter_all': '全部事件',
+ 'admin.no_logs': '未找到日志',
+ 'admin.btn_load_more': '加载更多',
+
+ // Admin blocklist
+ 'admin.col_hash': '哈希',
+ 'admin.col_reason': '原因',
+ 'admin.col_date': '日期',
+ 'admin.col_added_by': '添加者',
+ 'admin.no_blocked': '没有被屏蔽的哈希',
+ 'admin.add_hash': '将哈希添加到屏蔽列表',
+ 'admin.hash_placeholder': 'blake3 哈希(64 个十六进制字符)',
+ 'admin.reason_placeholder': '原因',
+ 'admin.btn_block': '屏蔽',
+ 'admin.btn_unblock': '解除屏蔽',
+
+ // Notifications
+ // Create group
+ 'create_group.title': '创建群组',
+ 'create_group.name': '群组名称',
+ 'create_group.name_placeholder': '例如:家庭照片、项目文件…',
+ 'create_group.visibility': '可见性',
+ 'create_group.private': '私密',
+ 'create_group.private_desc': '只有受邀成员才能看到并访问文件',
+ 'create_group.public': '公开',
+ 'create_group.public_desc': '任何人都可以发现并浏览此群组',
+ 'create_group.join_policy': '加入方式',
+ 'create_group.invite': '仅限邀请',
+ 'create_group.invite_desc': '成员必须由管理员邀请',
+ 'create_group.open': '开放(任何人都可加入)',
+ 'create_group.open_desc': '任何人都可以加入,无需批准',
+ 'create_group.description': '简介',
+ 'create_group.description_hint': '这个群组是做什么的?(可选)',
+ 'create_group.submit': '创建',
+ 'create_group.hint': '群组需要一个 node 来托管文件。您可以现在创建群组,稍后再接入 node。',
+ 'create_group.creating': '正在创建…',
+
+ // Members
+ 'members.col_role': '角色',
+ 'members.group_role': '群组角色',
+ 'members.admin': '管理员',
+ 'members.group_admin': '群组管理员',
+ 'members.owner': '所有者',
+ 'members.member': '成员',
+ 'members.invite_title': '邀请成员',
+ 'members.username_placeholder': '用户名',
+ 'members.invite_btn': '邀请',
+ 'members.invite_needs_pairing': '邀请是用此 node 为您的浏览器固定的密钥签名的,'
+ + '因此请先在下方完成配对,再邀请任何人。',
+ 'members.invite_ask_operator': '只有托管此群组的 node 的运营者才能邀请,'
+ + '且须从与该 node 配对过的浏览器发起。',
+ 'members.remove': '移除',
+ 'members.remove_confirm': '将 {user} 从此群组移除?该用户的账户、其他群组'
+ + '以及其上传的文件都不受影响。',
+ 'members.remove_hint': '移除某人会取消其成员身份,并使 node 不再向其提供群组密钥。'
+ + '这不会删除其账户,也无法收回其已经持有的密钥——如果这一点很重要,'
+ + '请在 node 上轮换密钥(meshbay-node gek-init)。',
+ 'members.pair_title': '将此浏览器与您的 node 配对',
+ 'members.pair_hint': '您的 node 只接受来自已配对浏览器的运营者操作——邀请、删除文件。'
+ + '请在 node 上运行 `meshbay-node operator pair`,并在此输入配对码。'
+ + '该配对码绝不会经过 hub。',
+ 'members.pair_btn': '配对',
+ 'members.pair_success': '此浏览器现已与该 node 配对。',
+ 'members.invite_code_ready': '给 {user} 的邀请码——用您平时联系的方式发给对方。'
+ + '它只能使用一次,并且绝不会经过 hub。',
+ 'members.invite_code_hint': '对方第一次打开此群组时输入即可。那时您不需要在线。',
+ 'transfers.title': '传输',
+ 'transfers.clear': '清除已完成',
+ 'transfers.cancel': '取消',
+ 'transfers.open': '打开',
+ 'transfers.done': '已完成',
+ 'transfers.cancelled': '已取消',
+ 'transfers.failed': '失败',
+ 'group.select': '选择',
+ 'group.select_done': '完成',
+ 'group.actions': '操作({n})',
+ 'group.download_n': '下载({n})',
+ 'group.download_zip_n': '将文件夹打包为 zip 下载({n})',
+ 'group.delete_n': '删除({n})',
+ 'group.delete_n_confirm': {
+ other: '删除 {n} 个项目?{names}',
+ },
+ 'group.download_zip': {
+ other: '打包为 zip 下载({n} 个文件)',
+ },
+ 'group.zip_empty': '该文件夹里没有可下载的内容。',
+ 'group.zip_no_stream': '此浏览器无法把下载内容直接写入磁盘,'
+ + '因此 {name}({size})必须先在内存中构建。若压缩包很大,这可能会失败。是否继续?',
+ 'group.rmdir': '删除文件夹',
+ 'group.rmdir_confirm': '删除文件夹“{name}”?它必须是空的。',
+ 'group.rmdir_not_empty': '该文件夹不是空的。请先删除其中的内容——'
+ + '这里的任何操作都不会删除您看不到的文件。',
+ 'group.desc_edit': '编辑简介',
+ 'group.desc_add': '添加简介',
+ 'group.desc_save': '保存',
+ 'group.desc_cancel': '取消',
+ 'group.desc_placeholder': '这个群组的用途——成员会在他们的主页上看到。',
+ 'group.join_code_title': '此 node 需要认识您',
+ 'group.join_code_hint': '向邀请您的人索取一次性代码,并在此输入。'
+ + '之后此浏览器即被认可,不会再次询问。',
+ 'group.join_code_btn': '加入',
+
+ 'notif.purge': '全部清除',
+ 'notif.title': '通知',
+ 'notif.empty': '没有通知',
+ 'notif.mark_all_read': '全部标为已读',
+
+ // Sidebar
+ 'sidebar.search': '搜索文件',
+
+ // Status
+
+ // Search
+ 'search.title': '搜索文件',
+ 'search.placeholder': '在所有群组中搜索…',
+ 'search.no_results': '没有文件符合您的搜索。',
+ 'search.synced': '{ago}同步',
+ 'search.cache_note': '搜索依据的是您上次打开每个群组时的状态。'
+ + '打开某个群组,即可在此更新它的文件。',
+ 'search.col_group': '群组',
+ 'search.result_count': {
+ other: '找到 {n} 个文件',
+ },
+ 'search.hint': '在所有已缓存的群组索引中搜索文件名。',
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 851c07e..11c108b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -152,7 +152,12 @@ a:hover { text-decoration: underline; }
box-shadow: var(--shadow-lg);
min-width: 220px;
z-index: 120;
- overflow: hidden;
+ /* The language submenu expands to ten entries inside this dropdown, which
+ overruns a short viewport. Clip horizontally as before, scroll vertically
+ rather than hiding the last languages where nothing can reach them. */
+ max-height: calc(100vh - 72px);
+ overflow-x: hidden;
+ overflow-y: auto;
padding: 4px 0;
}
diff --git a/packages/meshbay-hub/tests/test_locales.py b/packages/meshbay-hub/tests/test_locales.py
new file mode 100644
index 0000000..4035f1f
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_locales.py
@@ -0,0 +1,214 @@
+"""
+The ten SPA catalogues, held to the shape of the English one.
+
+A missing key is not an error anywhere — `t()` falls back to English and the
+interface simply shows one line in the wrong language, which nobody reports.
+The same is true of a plural entry that lacks a category a language actually
+uses: it degrades to `other` and reads as broken grammar to a native speaker
+and as nothing at all to everyone else. Both are cheap to check and invisible
+to find by hand across ten files, so they are checked here.
+
+The placeholder assertion is the one with teeth: a translation that drops
+`{name}` renders a confirmation dialog naming nothing, and one that invents a
+placeholder renders a literal `{foo}`.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+LOCALES = STATIC / "locales"
+I18N = STATIC / "i18n.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not LOCALES.exists(),
+ reason="node or the SPA sources are not available")
+
+# Kept in step with LOCALES in i18n.js — the registry the language menu offers.
+EXPECTED = ["en", "fr", "es", "pt-BR", "zh-CN", "ja", "de", "it", "nl", "pl"]
+
+
+def _sandbox(tmp_path):
+ """A directory node will treat as ESM, holding the real sources."""
+ (tmp_path / "package.json").write_text('{"type":"module"}')
+ (tmp_path / "locales").mkdir(exist_ok=True)
+ for src in LOCALES.glob("*.js"):
+ (tmp_path / "locales" / src.name).write_text(src.read_text())
+ (tmp_path / "i18n.js").write_text(I18N.read_text())
+ return tmp_path
+
+
+def _node(tmp_path, body):
+ script = tmp_path / "case.js"
+ script.write_text(body)
+ proc = subprocess.run(
+ ["node", str(script)], capture_output=True, text=True, cwd=str(tmp_path))
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+@pytest.fixture(scope="module")
+def catalogues(tmp_path_factory):
+ tmp = _sandbox(tmp_path_factory.mktemp("locales"))
+ codes = json.dumps(EXPECTED)
+ return _node(tmp, f"""
+ const out = {{}};
+ for (const code of {codes}) {{
+ out[code] = (await import(`./locales/${{code}}.js`)).default;
+ }}
+ console.log(JSON.stringify(out));
+ """)
+
+
+@pytest.fixture(scope="module")
+def plural_categories(tmp_path_factory):
+ """The categories Intl really produces per locale, not a table copied from CLDR."""
+ tmp = _sandbox(tmp_path_factory.mktemp("plurals"))
+ codes = json.dumps(EXPECTED)
+ return _node(tmp, f"""
+ const out = {{}};
+ for (const code of {codes}) {{
+ const pr = new Intl.PluralRules(code);
+ const cats = new Set();
+ for (let i = 0; i <= 200; i++) cats.add(pr.select(i));
+ cats.add(pr.select(1.5));
+ out[code] = [...cats];
+ }}
+ console.log(JSON.stringify(out));
+ """)
+
+
+def _placeholders(value):
+ """The `{name}` slots in an entry, plural forms flattened together."""
+ text = value if isinstance(value, str) else " ".join(value.values())
+ return set(re.findall(r"\{[a-z_]+\}", text))
+
+
+def test_every_registered_locale_has_a_catalogue():
+ on_disk = {p.stem for p in LOCALES.glob("*.js")}
+ assert on_disk == set(EXPECTED), (
+ "i18n.js offers a language in its menu that has no catalogue, or a "
+ "catalogue exists that the menu never offers")
+
+
+def test_key_sets_match_english(catalogues):
+ reference = set(catalogues["en"])
+ for code, catalogue in catalogues.items():
+ assert set(catalogue) == reference, (
+ f"{code} differs from en: "
+ f"missing {sorted(reference - set(catalogue))}, "
+ f"extra {sorted(set(catalogue) - reference)}")
+
+
+def test_plural_entries_stay_plural(catalogues):
+ for code, catalogue in catalogues.items():
+ for key, en_value in catalogues["en"].items():
+ if key not in catalogue:
+ continue # reported by test_key_sets_match_english
+ assert isinstance(catalogue[key], type(en_value)), (
+ f"{code}:{key} is a {type(catalogue[key]).__name__} where en has a "
+ f"{type(en_value).__name__} — a counted string must stay counted, "
+ "or t() cannot select a form")
+
+
+def test_plural_entries_cover_every_category_the_language_uses(
+ catalogues, plural_categories):
+ for code, catalogue in catalogues.items():
+ for key, value in catalogue.items():
+ if not isinstance(value, dict):
+ continue
+ missing = set(plural_categories[code]) - set(value)
+ assert not missing, (
+ f"{code}:{key} has no form for {sorted(missing)} — Polish needs "
+ "one/few/many, and a missing form silently falls back to 'other'")
+
+
+def test_placeholders_survive_translation(catalogues):
+ for code, catalogue in catalogues.items():
+ for key, en_value in catalogues["en"].items():
+ if key not in catalogue:
+ continue # reported by test_key_sets_match_english
+ assert _placeholders(catalogue[key]) == _placeholders(en_value), (
+ f"{code}:{key} interpolates "
+ f"{sorted(_placeholders(catalogue[key]))} where en interpolates "
+ f"{sorted(_placeholders(en_value))}")
+
+
+def test_locale_resolution_is_region_aware(tmp_path):
+ """`navigator.language` is pt-BR and zh-CN on the machines those files are for.
+
+ Trimming a tag to its base before matching — which this used to do — sent a
+ Brazilian browser looking for a `pt` catalogue that does not exist, and it
+ fell back to English.
+ """
+ tmp = _sandbox(tmp_path)
+ resolved = _node(tmp, """
+ const store = {};
+ globalThis.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = v; },
+ };
+ globalThis.document = { documentElement: {} };
+ const i18n = await import('./i18n.js');
+ const out = {};
+ for (const tags of [['pt-BR'], ['pt'], ['zh-CN'], ['zh'], ['fr-CA'],
+ ['de-AT'], ['ru', 'it'], ['ko']]) {
+ globalThis.navigator = { languages: tags, language: tags[0] };
+ delete store.mb_lang;
+ out[tags.join(',')] = await i18n.initLocale();
+ }
+ console.log(JSON.stringify(out));
+ """)
+ assert resolved == {
+ "pt-BR": "pt-BR",
+ "pt": "pt-BR",
+ "zh-CN": "zh-CN",
+ "zh": "zh-CN",
+ "fr-CA": "fr",
+ "de-AT": "de",
+ "ru,it": "it", # first supported language in the list wins
+ "ko": "en", # nothing matches, English answers
+ }
+
+
+def test_counted_string_picks_the_right_polish_form(tmp_path):
+ """1 plik / 2 pliki / 5 plików — the case a two-form catalogue cannot express."""
+ tmp = _sandbox(tmp_path)
+ rendered = _node(tmp, """
+ const store = {};
+ globalThis.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = v; },
+ };
+ globalThis.document = { documentElement: {} };
+ globalThis.navigator = { languages: ['pl'], language: 'pl' };
+ const i18n = await import('./i18n.js');
+ await i18n.initLocale();
+ console.log(JSON.stringify(
+ [0, 1, 2, 5, 22].map(n => i18n.t('status.files', { n }))));
+ """)
+ assert rendered == ["0 plików", "1 plik", "2 pliki", "5 plików", "22 pliki"]
+
+
+def test_interpolated_value_is_not_read_as_a_replacement_pattern(tmp_path):
+ """A filename may contain `$&`, which String.replace would expand."""
+ tmp = _sandbox(tmp_path)
+ rendered = _node(tmp, """
+ const store = {};
+ globalThis.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = v; },
+ };
+ globalThis.document = { documentElement: {} };
+ globalThis.navigator = { languages: ['en'], language: 'en' };
+ const i18n = await import('./i18n.js');
+ await i18n.initLocale();
+ console.log(JSON.stringify(
+ [i18n.t('group.delete_confirm', { name: 'rap$&sody.mp3' })]));
+ """)
+ assert rendered == ["Delete rap$&sody.mp3?"]