summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/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
13 files changed, 4155 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;
}