diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
29 files changed, 819 insertions, 304 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 6b20a63..3101be7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -924,8 +924,31 @@ function App() { .catch(() => {}); }, [user, notifDisabled]); + // Whether this account has a node linked, which is what shows the sidebar's + // Node section. Asked again whenever a node may just have been linked (the + // Create Group wizard), not only when the session changes -- the section + // stayed hidden until a reload after the first group -- and retried rather + // than left false by one failed request after a session blip. + const nodeKeyAskedForRef = useRef(null); + const refreshNodeKey = useCallback(() => { + nodeKeyAskedForRef.current = user; + if (!user || !platform.capabilities.nodeAdmin) return; + // A late answer, or a retry, must not land on a session that has changed. + const current = () => nodeKeyAskedForRef.current === user; + const ask = (attempt) => hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token }) + .then(data => { if (current()) setHasNodeKey(Boolean(data.pk_node_ed25519)); }) + .catch(() => { + if (attempt < 3 && current()) setTimeout(() => ask(attempt + 1), 2000 * attempt); + }); + ask(1); + }, [user]); + useEffect(() => { - if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false); return; } + if (!user) { + nodeKeyAskedForRef.current = null; + setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false); + return; + } hubFetch('/v1/groups/mine', { token: user.token }) .then(data => setGroups(data.groups || [])) .catch(() => setGroups([])); @@ -935,11 +958,7 @@ function App() { if (prefs.notifications_disabled === 'true') setNotifDisabled(true); }) .catch(() => {}); - if (platform.capabilities.nodeAdmin) { - hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token }) - .then(data => setHasNodeKey(Boolean(data.pk_node_ed25519))) - .catch(() => {}); - } + refreshNodeKey(); fetchNotifications(); }, [user]); @@ -1176,10 +1195,12 @@ function App() { } else if (route === '/create-group') { page = html`<${LazyCreateGroupPage} token=${user.token} username=${user.username} allowPublicGroups=${allowPublicGroups} + onNodeLinked=${refreshNodeKey} onCreated=${() => { hubFetch('/v1/groups/mine', { token: user.token }) .then(data => setGroups(data.groups || [])) .catch(() => {}); + refreshNodeKey(); }} />`; } else if (route === '/node' && platform.capabilities.nodeAdmin && hasNodeKey) { page = html`<${LazyNodePage} groups=${groups} token=${user.token} username=${user.username} />`; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apple-touch-icon.png b/packages/meshbay-hub/src/meshbay_hub/static/apple-touch-icon.png Binary files differnew file mode 100644 index 0000000..aacd0fa --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/apple-touch-icon.png diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js index 70c48f2..09c4acf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -207,7 +207,9 @@ export function LoginPage({ onLogin }) { return html` <div class="page-center"> + <div class="welcome-backdrop" aria-hidden="true"></div> <div class="welcome"> + <div class="welcome-side"> <div class="card login-card"> <h2>${t('login.title')}</h2> <form onSubmit=${onSubmit}> @@ -234,6 +236,8 @@ export function LoginPage({ onLogin }) { <a href="#/reset">${t('login.forgot')}</a> </div> </div> + ${!platform.isNative && html`<${WelcomeLinks} />`} + </div> ${!platform.isNative && html`<${WelcomePitch} />`} </div> </div> @@ -244,20 +248,49 @@ export function LoginPage({ onLogin }) { // application the reader has already downloaded it, and the links point at the // project's own site rather than at whichever hub the application is set to. // -// Every sentence here is held to MESHBAY_DESIGN.md §2.3. "Data never transits -// the hub" is a claim the design makes; "the hub cannot read anything" is one it -// forbids (T3), which is why the list below says what never *reaches* the hub -// and stops there. "End-to-end" means device to node, as §2.3 defines it. +// Written for somebody who is not in IT: what it does for them first, how it +// works in three steps, and privacy said once, plainly. Every sentence is held +// to MESHBAY_DESIGN.md §2.3 — "your content never passes through meshbay.org" +// is a claim the design makes; "meshbay.org cannot read anything" is one it +// forbids (T3). "Encrypted all the way" means device to node, as §2.3 defines. const WELCOME_APPS = [ ['chat', 'welcome.app_chat'], ['image', 'welcome.app_photos'], ['video', 'welcome.app_media'], ['play', 'welcome.app_video'], ['music', 'welcome.app_music'], ]; +const WELCOME_STEPS = [ + ['home', 'welcome.step_home'], ['globe', 'welcome.step_anywhere'], + ['user', 'welcome.step_share'], +]; const WELCOME_USES = [ ['chat', 'welcome.use_chat'], ['image', 'welcome.use_photos'], ['cast', 'welcome.use_media'], ['pencil', 'welcome.use_apps'], ]; -const WELCOME_NEVER = ['welcome.never_transit', 'welcome.never_stored', 'welcome.never_e2e']; +const WELCOME_BADGES = ['welcome.badge_free', 'welcome.badge_open', + 'welcome.badge_no_ads', 'welcome.badge_no_tracking']; + +const REPO = 'https://git.meshbay.org/meshbay.git/about/'; +const WELCOME_DOCS = [ + ['folder', 'welcome.docs_source', [['welcome.docs_repo', REPO]]], + ['user', 'welcome.docs_user', [ + ['welcome.docs_quickstart', `${REPO}docs/QUICKSTART.md`], + ['welcome.docs_userguide', `${REPO}docs/USERGUIDE.md`]]], + ['gear', 'welcome.docs_devel', [ + ['welcome.docs_design', `${REPO}docs/MESHBAY_DESIGN.md`], + ['welcome.docs_protocol', `${REPO}docs/MESHBAY_NODE_PROTOCOL.md`]]], +]; + +// Under the sign-in form rather than at the foot of the text: on a desktop the +// text column runs far below the form, and these were the last thing on it. +function WelcomeLinks() { + return html` + <div class="welcome-links"> + <a class="welcome-cta" href="https://meshbay.org/downloads/"> + <${Icon} name="download" />${t('welcome.download')}</a> + <a class="welcome-legal" href="https://meshbay.org/legal/">${t('welcome.legal')}</a> + </div> + `; +} function WelcomePitch() { return html` @@ -268,8 +301,13 @@ function WelcomePitch() { ${WELCOME_APPS.map(([icon, key]) => html` <li key=${key}><${Icon} name=${icon} />${t(key)}</li>`)} </ul> - <p>${t('welcome.groups')}</p> - <p class="welcome-e2e"><${Icon} name="lock" /><span>${t('welcome.e2e')}</span></p> + + <h2 class="welcome-h">${t('welcome.how_title')}</h2> + <ol class="welcome-steps"> + ${WELCOME_STEPS.map(([icon, key]) => html` + <li key=${key}><span class="welcome-step-icon"><${Icon} name=${icon} /></span> + <span>${t(key)}</span></li>`)} + </ol> <h2 class="welcome-h">${t('welcome.uses_title')}</h2> <ul class="welcome-uses"> @@ -278,26 +316,51 @@ function WelcomePitch() { <span>${t(key)}</span></li>`)} </ul> - <div class="welcome-hub"> - <h2 class="welcome-h">${t('welcome.hub_title')}</h2> - <p>${t('welcome.hub_body')}</p> - <p class="welcome-hub-never">${t('welcome.hub_never')}</p> - <ul class="welcome-never"> - ${WELCOME_NEVER.map(key => html` - <li key=${key}><${Icon} name="check" /><span>${t(key)}</span></li>`)} - </ul> - <p class="welcome-hub-free">${t('welcome.hub_free')}</p> + <div class="welcome-private"> + <span class="welcome-private-icon"><${Icon} name="shield" /></span> + <div> + <h2 class="welcome-private-title">${t('welcome.private_title')}</h2> + <p>${t('welcome.private_body')}</p> + <ul class="welcome-badges"> + ${WELCOME_BADGES.map(key => html` + <li key=${key}><${Icon} name="check" />${t(key)}</li>`)} + </ul> + </div> </div> - <div class="welcome-links"> - <a class="welcome-cta" href="https://meshbay.org/downloads/"> - <${Icon} name="download" />${t('welcome.download')}</a> - <a class="welcome-legal" href="https://meshbay.org/legal/">${t('welcome.legal')}</a> + <div class="welcome-docs"> + <h2 class="welcome-h">${t('welcome.docs_title')}</h2> + <ul> + ${WELCOME_DOCS.map(([icon, label, links]) => html` + <li key=${label}><${Icon} name=${icon} /> + <span class="welcome-docs-label">${t(label)}</span> + <span class="welcome-docs-links">${links.map(([key, href], i) => html` + ${i > 0 && ' · '}<a key=${key} href=${href} target="_blank" + rel="noopener noreferrer">${t(key)}</a>`)}</span></li>`)} + </ul> </div> + </section> `; } +// The sign-in page's dark gradient backdrop and frosted card, without the +// pitch beside it — so Register (and its verify/recovery/done steps) sits on +// the same background and reads in the same dark theme as Login. A lone +// `.welcome-side` is centred by `.welcome`'s `justify-content`. +function AuthShell({ children }) { + return html` + <div class="page-center"> + <div class="welcome-backdrop" aria-hidden="true"></div> + <div class="welcome"> + <div class="welcome-side"> + ${children} + </div> + </div> + </div> + `; +} + export function RegisterPage() { const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); @@ -312,7 +375,9 @@ export function RegisterPage() { const [recoveryMnemonic, setRecoveryMnemonic] = useState(''); const [recoverySaved, setRecoverySaved] = useState(false); const [recoveryCopied, setRecoveryCopied] = useState(false); - const [emailRecovery, setEmailRecovery] = useState(true); + // Off by default: mailing the recovery key is opt-in — the key is shown on + // screen to save, and sending a copy is the user's own choice to make. + const [emailRecovery, setEmailRecovery] = useState(false); const captcha = useCaptcha(); const onSubmit = async (e) => { @@ -411,7 +476,7 @@ export function RegisterPage() { if (phase === 'done') { return html` - <div class="page-center"> + <${AuthShell}> <div class="card login-card"> <h2>${t('register.verified_title')}</h2> <p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)"> @@ -421,7 +486,7 @@ export function RegisterPage() { <p style="text-align:center; margin-bottom:16px">${t('invite.after_register')}</p>`} <a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a> </div> - </div> + </${AuthShell}> `; } @@ -434,7 +499,7 @@ export function RegisterPage() { } catch { /* clipboard blocked — the text is on screen to copy by hand */ } }; return html` - <div class="page-center"> + <${AuthShell}> <div class="card login-card"> <h2>${t('register.recovery_title')}</h2> <p style="margin-bottom:12px; color:var(--text-secondary)"> @@ -463,13 +528,13 @@ export function RegisterPage() { ${t('register.recovery_continue')} </button> </div> - </div> + </${AuthShell}> `; } if (phase === 'verify') { return html` - <div class="page-center"> + <${AuthShell}> <div class="card login-card"> <h2>${t('register.success_title')}</h2> <p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)"> @@ -492,12 +557,12 @@ export function RegisterPage() { ${t('register.resend_sent')}</span>`} </div> </div> - </div> + </${AuthShell}> `; } return html` - <div class="page-center"> + <${AuthShell}> <div class="card login-card"> <h2>${t('register.title')}</h2> <form onSubmit=${onSubmit}> @@ -539,7 +604,7 @@ export function RegisterPage() { ${t('register.has_account')} <a href="#/login">${t('register.login_link')}</a> </div> </div> - </div> + </${AuthShell}> `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js index aac8225..15d352a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js @@ -78,7 +78,7 @@ async function connectToGroup(hubBase, groupId, token, bundleKey, username, user const ack = await Promise.race([ transport.connect( n.node_id, live, groupId, null, null, bundleKey, - username, userId, null), + username, userId, null, undefined, undefined, n.pk_node), new Promise((_, reject) => { const giveUp = () => reject(new Error('Connection timeout')); stallTimer = setTimeout(giveUp, SEARCH_STALL_MS); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js index f81247d..7556010 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js @@ -124,7 +124,7 @@ function CreateGroupFormSimple({ token, onCreated, allowPublicGroups = true }) { `; } -function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = true }) { +function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPublicGroups = true }) { const [step, setStep] = useState(0); const [nodeStatus, setNodeStatus] = useState(null); const [nodeStarting, setNodeStarting] = useState(false); @@ -142,12 +142,19 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru const linkNodeKey = useCallback(async (pk) => { if (!pk) return; - try { - await hubFetch('/v1/users/me/node_key', { - method: 'PUT', token, body: { pk_node_ed25519: pk }, - }); - } catch { /* already linked or same key */ } - }, [token]); + // `PUT /me/node_key` is idempotent — linking the same key again returns 200, + // so there is no "already linked" case to swallow here. A failure means the + // hub did not record this node's key (a rejected session, a malformed key), + // and the node then fails to authenticate and never comes up. It must + // surface — `detectNode`'s catch shows it — rather than let the wizard + // proceed against a node that looks linked but is not. + await hubFetch('/v1/users/me/node_key', { + method: 'PUT', token, body: { pk_node_ed25519: pk }, + }); + // The sidebar's Node section depends on this link; it only re-read it at + // sign-in, so the first node set up here stayed out of it until a reload. + if (onNodeLinked) onNodeLinked(); + }, [token, onNodeLinked]); const detectNode = useCallback(async () => { setNodeStatus(null); @@ -173,6 +180,8 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru setError(''); try { const result = await platform.node.start({ hubUrl: HUB, username, token }); + // node.start links the key from the main process, out of this page's sight. + if (onNodeLinked) onNodeLinked(); setNodeStatus({ detected: true, ...result }); setNodeStarting(false); setStep(1); @@ -333,6 +342,7 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru && !provisionAttempted.current && (nodeStatus.status === 'waiting_for_account' || nodeStatus.status === 'waiting_for_node_key' + || nodeStatus.status === 'waiting_for_hub' || nodeStatus.status === 'starting')) { provisionAttempted.current = true; startNode(); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/favicon.ico b/packages/meshbay-hub/src/meshbay_hub/static/favicon.ico Binary files differnew file mode 100644 index 0000000..c24a117 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/favicon.ico diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 2ee6e05..7c9b2f0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -16,8 +16,9 @@ import { FilePreview } from './files-app.js'; import { lazy } from './lazy.js'; // Fetched the first time a video is played / the Settings tab is opened. +const playerFrame = (content) => html`<div class="video-overlay video-player-overlay">${content}</div>`; const VideoPlayer = lazy(() => import('./video-player.js'), 'VideoPlayer', - html`<div class="video-overlay"><p class="page-message"><span class="spinner"></span></p></div>`); + playerFrame(html`<p class="page-message"><span class="spinner"></span></p>`), playerFrame); const GroupSettingsPanel = lazy(() => import('./group-settings.js'), 'GroupSettingsPanel'); import { reportIndexPush } from './index-dock.js'; import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js'; @@ -433,7 +434,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, try { ack = await transport.connect( n.node_id, live, groupId, null, sessionKeys, session.bundleKey, - username, userId, joinCode, session.recoveryKey, joinNodePk); + username, userId, joinCode, session.recoveryKey, joinNodePk, n.pk_node); break; } catch (e) { lastErr = e; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index 529a33d..fba8a0e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -417,7 +417,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // Whether the hub mails the invitation. Checked, the hub is handed the code // to write it into the mail — so it is the inviter's choice, remembered per // account (docs/MESHBAY_DESIGN.md §3.4). Unchecked, the hub never sees it. - const [inviteByEmail, setInviteByEmail] = useState(true); + // Off by default: mailing the code is opt-in, not something to do unasked. + const [inviteByEmail, setInviteByEmail] = useState(false); const [error, setError] = useState(''); // Node loopback state (Electron-only) @@ -811,7 +812,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, useEffect(() => { hubFetch('/v1/users/me/preferences', { token }) - .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] !== 'false')) + .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] === 'true')) .catch(() => {}); }, [token]); @@ -897,10 +898,11 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // ── Invitation links (docs/MESHBAY_DESIGN.md §3.4) ────────────────── // // Two halves, in the order that leaves nothing half-made: the node's code - // first, then the hub's ticket bound to the address; a ticket the hub then - // refuses takes the code back with it, since a code nobody can reach the node - // with only occupies one of the group's twenty places. The code reaches the - // hub only when the box asks the hub to write the mail. + // first, then the hub's ticket; a ticket the hub then refuses takes the code + // back with it, since a code nobody can reach the node with only occupies one + // of the group's twenty places. The address is optional and binds nothing: + // the link is for whoever opens it first, so it can go by any messaging app. + // The code reaches the hub only when the box asks the hub to write the mail. const [linkEmail, setLinkEmail] = useState(''); const [linking, setLinking] = useState(false); const [linkError, setLinkError] = useState(''); @@ -932,7 +934,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, const doCreateLink = useCallback(async (e) => { e.preventDefault(); const email = linkEmail.trim(); - if (!email) return; + const mailIt = inviteByEmail && Boolean(email); setLinking(true); setLinkError(''); setNewLink(null); @@ -951,8 +953,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, method: 'POST', token, body: { email, expires_at: node.expires_at, node_invite_id: node.invite_id, - send_email: inviteByEmail, - ...(inviteByEmail ? { node_pk: n, code: node.code } : {}), + send_email: mailIt, + ...(mailIt ? { node_pk: n, code: node.code } : {}), }, }); } catch (err) { @@ -960,7 +962,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, throw err; } setNewLink({ - email, link: inviteLinkHere({ g: groupId, t: ticket.ticket, n, c: node.code }), emailStatus: ticket.email_status, }); @@ -1004,6 +1005,16 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } catch { /* the field is selectable */ } }, [newLink]); + // The system share sheet, where there is one (phones mostly): the way a link + // reaches a messaging app without a round trip through the clipboard. + const canShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function'; + const shareLink = useCallback(async () => { + if (!newLink) return; + try { + await navigator.share({ title: t('members.link_share_title'), url: newLink.link }); + } catch { /* dismissed; the field and Copy are still there */ } + }, [newLink]); + if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`; const isOwner = Boolean(isAdmin); @@ -1049,7 +1060,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, ${inviting ? '...' : t('members.invite_btn')} </button> </div> - <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0; + <label style="display:flex; gap:8px; align-items:center; margin:6px 0 0; font-size:0.88em; color:var(--text-secondary)"> <input type="checkbox" checked=${inviteByEmail} onChange=${e => toggleInviteByEmail(e.target.checked)} /> @@ -1067,13 +1078,17 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, <form onSubmit=${doCreateLink}> ${newLink && html` <div class="success-msg" style="margin-bottom:8px"> - <p>${t('members.link_ready', { email: newLink.email })}</p> + <p>${t('members.link_ready')}</p> <div class="form-row"> <input type="text" readonly value=${newLink.link} onFocus=${e => e.target.select()} /> <button class="admin-btn" type="button" onClick=${copyLink}> ${linkCopied ? t('members.link_copied') : t('members.link_copy')} </button> + ${canShare && html` + <button class="admin-btn" type="button" onClick=${shareLink}> + ${t('members.link_share')} + </button>`} </div> ${newLink.emailStatus === 'sent' ? html`<p style="color:var(--success)">${t('members.link_email_sent')}</p>` @@ -1085,13 +1100,13 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, <div class="form-row"> <input type="email" placeholder=${t('members.link_email_placeholder')} value=${linkEmail} onInput=${e => setLinkEmail(e.target.value)} - disabled=${!connected || !operatorPaired} required /> + disabled=${!connected || !operatorPaired} /> <button class="admin-btn" type="submit" disabled=${linking || !connected || !operatorPaired}> ${linking ? '...' : t('members.link_btn')} </button> </div> - <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0; + <label style="display:flex; gap:8px; align-items:center; margin:6px 0 0; font-size:0.88em; color:var(--text-secondary)"> <input type="checkbox" checked=${inviteByEmail} onChange=${e => toggleInviteByEmail(e.target.checked)} /> @@ -1104,7 +1119,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, ${links.map(l => html` <li key=${l.link_id} style="display:flex;gap:8px;align-items:center; flex-wrap:wrap;word-break:break-word;margin:4px 0"> - <span>${l.email}</span> + <span>${l.email || t('members.link_unlabelled', + { date: new Date(l.created_at).toLocaleDateString() })}</span> <span style="color:var(--text-dim)"> ${l.status === 'expired' ? t('members.link_status_expired') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js index dd678aa..41b4c4c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js @@ -25,10 +25,7 @@ export function InvitePage({ user, onJoined }) { const [error, setError] = useState(''); const refused = useCallback((err) => { - if (err.message === 'invite_other_account') { - // Kept: signing out and in again as the right account is the fix. - setPhase('other'); - } else if (err.message === 'invite_not_valid') { + if (err.message === 'invite_not_valid') { clearPending(); setPhase('invalid'); } else { @@ -97,12 +94,6 @@ export function InvitePage({ user, onJoined }) { <button class="btn btn-primary" onClick=${join}>${t('invite.join')}</button> <button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button> </div>`; - } else if (phase === 'other') { - body = html` - <p class="error-msg">${t('invite.other_account')}</p> - <div style="margin-top:16px"> - <button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button> - </div>`; } else if (phase === 'invalid') { body = html`<p class="error-msg">${t('invite.invalid')}</p>`; } else { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/lazy.js b/packages/meshbay-hub/src/meshbay_hub/static/lazy.js index 30a564c..fda73c0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/lazy.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/lazy.js @@ -9,17 +9,45 @@ // // A failed fetch (a network drop on a phone) is not remembered: the next time // the component is shown it asks again, rather than failing for the session. +// +// A failed fetch is also *said*, never left as the placeholder's spinner. The +// commonest failure is not the network: the hub serves the module graph under +// `/a/<hash>/` for the current hash only, so a tab opened before a hub deploy +// asks for every module it has not loaded yet under a prefix that now answers +// 404. Such a tab kept Files and Chat (already loaded) and spun for ever on +// Search, Videos, Music, Photos and the player — with nothing in the console, +// because the rejection was swallowed here. Asking again cannot help that tab; +// reloading the page is the one thing that does, so that is what is offered. +// Not done automatically: a reload that fails the same way would loop, and it +// would throw away whatever the reader had in progress. import { html, useState, useEffect } from './vendor/htm-preact.js'; +import { t } from './i18n.js'; const SPINNER = html`<p class="page-message"><span class="spinner"></span></p>`; -function lazy(load, name, placeholder = SPINNER) { +function LoadFailed({ onClose }) { + return html` + <div class="page-message lazy-failed" role="alert"> + <p>${t('lazy.load_failed')}</p> + <button class="admin-btn" onClick=${() => location.reload()}>${t('lazy.reload')}</button> + ${onClose && html` + ${' '}<button class="admin-btn" onClick=${onClose}>${t('admin.btn_close')}</button> + `} + </div> + `; +} + +// `frame` wraps the failure notice, so a component that is an overlay (the +// video player) can put it where the overlay would have been rather than +// somewhere at the bottom of the page, under nothing anyone is looking at. +function lazy(load, name, placeholder = SPINNER, frame = (content) => content) { let Loaded = null; let pending = null; function Lazy(props) { const [, setReady] = useState(Loaded !== null); + const [failed, setFailed] = useState(false); useEffect(() => { if (Loaded) return undefined; let alive = true; @@ -28,11 +56,15 @@ function lazy(load, name, placeholder = SPINNER) { (m) => { Loaded = m[name]; }, (e) => { pending = null; throw e; }); } - pending.then(() => { if (alive) setReady(true); }, () => {}); + pending.then(() => { if (alive) setReady(true); }, (e) => { + console.error(`[MeshBay] could not load ${name}:`, e); + if (alive) setFailed(true); + }); return () => { alive = false; }; }, []); - if (!Loaded) return placeholder; - return html`<${Loaded} ...${props} />`; + if (Loaded) return html`<${Loaded} ...${props} />`; + if (failed) return frame(html`<${LoadFailed} onClose=${props.onClose} />`); + return placeholder; } Lazy.displayName = `Lazy(${name})`; return Lazy; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 7008f79..36a9423 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -93,26 +93,36 @@ export default { 'login.locked': "Zu viele falsche Passphrasen für dieses Konto. Versuchen Sie es in {minutes} Min. erneut oder setzen Sie Ihre Passphrase zurück.", // Sign-in page: what MeshBay is (browser only) 'welcome.title': 'Das Internet, wie es gedacht war.', - 'welcome.lead': 'MeshBay ist Open-Source-Software, mit der Sie aus der Ferne auf Ihre persönlichen Dateien zugreifen und Anwendungen direkt auf dem Speicher Ihres eigenen Computers betreiben:', + 'welcome.lead': 'Ihre Fotos, Musik, Videos und Unterhaltungen bleiben zu Hause, auf Ihrem eigenen Computer. Mit MeshBay genießen Sie sie von überall und teilen sie mit den Menschen Ihrer Wahl.', 'welcome.app_chat': 'Chat', 'welcome.app_photos': 'Fotos', 'welcome.app_media': 'Mediacenter', 'welcome.app_video': 'Videoplayer', 'welcome.app_music': 'Musikplayer', - 'welcome.groups': 'Erstellen Sie Gruppen, um Ihren Liebsten Zugriff auf einige dieser Anwendungen zu geben.', - 'welcome.e2e': 'Ihre Daten sind Ende-zu-Ende-verschlüsselt und gehen Peer-to-Peer direkt von jedem Gerät zu dem Computer, auf dem sie liegen (Ihrem Node). Sie laufen niemals über den Hub meshbay.org.', + 'welcome.how_title': 'So funktioniert es', + 'welcome.step_home': 'Installieren Sie MeshBay auf einem Computer zu Hause. Ihre Dateien bleiben, wo sie sind.', + 'welcome.step_anywhere': 'Melden Sie sich von Ihrem Handy, Laptop oder jedem Browser aus an, wo immer Sie sind.', + 'welcome.step_share': 'Erstellen Sie Gruppen und laden Sie Familie und Freunde ein.', 'welcome.uses_title': 'Was Sie damit tun können', 'welcome.use_chat': 'Sofortnachrichten innerhalb einer Gruppe', 'welcome.use_photos': 'Fotoalben mit Familie und Freunden teilen', 'welcome.use_media': 'Von überall auf Ihr Mediacenter zu Hause zugreifen, für Sie und Ihren Haushalt (mit Streaming und Chromecast über die Desktop-App)', 'welcome.use_apps': 'Eigene Anwendungen auf dem Node entwickeln, den Sie betreiben', - 'welcome.hub_title': 'Was meshbay.org tut', - 'welcome.hub_body': 'meshbay.org ist lediglich ein Signalisierungsdienst: Er meldet Sie an und verbindet die Beteiligten miteinander (Plug-and-Play, über Heimnetzwerke hinweg).', - 'welcome.hub_never': 'Ihre Inhalte erreichen ihn nie:', - 'welcome.never_transit': 'Nichts läuft über ihn: keine Inhalte, keine Indexierung', - 'welcome.never_stored': 'Keine Ihrer Dateien oder Nachrichten wird dort gespeichert', - 'welcome.never_e2e': 'Durchgehende Verschlüsselung, von jedem Gerät bis zu Ihrem Node', - 'welcome.hub_free': 'Der Dienst ist kostenlos. Es gibt bewusst kein Geschäftsmodell, kein Tracking und keine Werbung. Viel Spaß!', + 'welcome.private_title': 'Privat von Grund auf', + 'welcome.private_body': 'Ihre Dateien und Nachrichten reisen verschlüsselt, direkt von Ihren Geräten zu Ihrem eigenen Computer. meshbay.org hilft Ihren Geräten nur, sich zu finden: Ihre Inhalte laufen nie darüber.', + 'welcome.badge_free': 'Kostenlos', + 'welcome.badge_open': 'Open Source', + 'welcome.badge_no_ads': 'Keine Werbung', + 'welcome.badge_no_tracking': 'Kein Tracking', + 'welcome.docs_title': 'Dokumentation', + 'welcome.docs_source': 'Quellcode', + 'welcome.docs_repo': 'git.meshbay.org', + 'welcome.docs_user': 'Benutzerdoku', + 'welcome.docs_quickstart': 'Schnellstart', + 'welcome.docs_userguide': 'Benutzerhandbuch', + 'welcome.docs_devel': 'Entwicklerdoku', + 'welcome.docs_design': 'Architektur', + 'welcome.docs_protocol': 'Protokoll', 'welcome.download': 'Herunterladen (Beta)', 'welcome.legal': 'Rechtliche Hinweise', 'register.err_mismatch': 'Die Passwörter stimmen nicht überein', @@ -688,10 +698,10 @@ export default { 'members.invite_email_failed': 'Die E-Mail konnte nicht gesendet werden — bitte teilen Sie den Code manuell mit.', 'members.invite_email_opt': 'Einladung per E-Mail senden (kann im Spam landen)', 'members.link_title': "Per Link einladen", - 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Der Link funktioniert einmal und nur für ein Konto mit dieser Adresse.", - 'members.link_email_placeholder': "E-Mail-Adresse", + 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Verschicken Sie ihn, wie Sie möchten — Messenger, SMS. Der Link funktioniert einmal, sieben Tage lang, für die Person, die ihn zuerst öffnet.", + 'members.link_email_placeholder': "E-Mail-Adresse (optional)", 'members.link_btn': "Link erstellen", - 'members.link_ready': "Einladungslink für {email}:", + 'members.link_ready': "Einladungslink — 7 Tage gültig, einmalig verwendbar:", 'members.link_copy': "Kopieren", 'members.link_copied': "Kopiert", 'members.link_email_sent': "Der Link wurde per E-Mail gesendet.", @@ -700,9 +710,12 @@ export default { 'members.link_status_expired': "abgelaufen", 'members.link_expires': "läuft ab am {date}", 'members.link_cancel': "Abbrechen", + 'members.link_share': "Teilen", + 'members.link_share_title': "Einladung", + 'members.link_unlabelled': "Link vom {date}", 'invite.title': "Sie wurden eingeladen", 'invite.none': "In diesem Tab wartet keine Einladung. Öffnen Sie den erhaltenen Link erneut.", - 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto mit der E-Mail-Adresse, an die die Einladung ging, oder melden Sie sich an, falls Sie bereits eines haben.", + 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto, oder melden Sie sich an, falls Sie bereits eines haben.", 'invite.register': "Konto erstellen", 'invite.signin': "Anmelden", 'invite.confirm': "{inviter} lädt Sie in {group} ein.", @@ -710,7 +723,6 @@ export default { 'invite.ignore': "Ignorieren", 'invite.open': "Gruppe öffnen", 'invite.already_member': "Sie sind bereits Mitglied von {group}.", - 'invite.other_account': "Diese Einladung wurde an eine andere E-Mail-Adresse gesendet. Melden Sie sich mit dem Konto dieser Adresse an — Aliasse und Punkte müssen genau übereinstimmen.", 'invite.invalid': "Diese Einladung ist nicht mehr gültig: Sie wurde verwendet, widerrufen oder ist abgelaufen. Bitten Sie um eine neue.", 'invite.joining': "Beitritt…", 'invite.after_register': "Melden Sie sich an, um der Gruppe beizutreten, in die Sie eingeladen wurden.", @@ -876,6 +888,8 @@ export default { // Group misc 'group.retry': 'Erneut versuchen', + 'lazy.load_failed': 'Dieser Teil der Oberfläche konnte nicht geladen werden. Der Hub wurde möglicherweise aktualisiert, seit diese Seite geöffnet wurde.', + 'lazy.reload': 'Seite neu laden', // Sidebar 'sidebar.node': 'Node', @@ -898,7 +912,7 @@ export default { 'node.service_restarting': 'Wird neu gestartet…', 'node.service_mode_hint': 'Läuft als Hintergrunddienst — startet beim Booten, vor der Anmeldung.', 'node.startup_mode_label': 'Automatisch starten:', - 'node.startup_mode_off': 'Aus (manuell starten)', + 'node.startup_mode_off': 'Nur solange MeshBay geöffnet ist', 'node.startup_mode_signin': 'Bei der Anmeldung', 'node.startup_mode_service': 'Als Hintergrunddienst (startet beim Booten)', 'node.startup_mode_updating': 'Modus wird gewechselt — achten Sie auf eine Administrator-Eingabeaufforderung…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 1ea6eda..67cf5f4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -96,26 +96,36 @@ export default { 'login.locked': "Too many wrong passphrases for this account. Try again in {minutes} min, or reset your passphrase.", // Sign-in page: what MeshBay is (browser only) 'welcome.title': 'The Internet as it was meant to be.', - 'welcome.lead': 'MeshBay is open-source software that gives you remote access to your personal files, and runs applications on top of the storage on your own computer:', + 'welcome.lead': 'Your photos, music, videos and conversations stay at home, on your own computer. MeshBay lets you enjoy them from anywhere, and share them with the people you choose.', 'welcome.app_chat': 'Chat', 'welcome.app_photos': 'Photos', 'welcome.app_media': 'Media center', 'welcome.app_video': 'Video player', 'welcome.app_music': 'Music player', - 'welcome.groups': 'Create groups to give the people close to you access to some of these applications.', - 'welcome.e2e': 'Your data is end-to-end encrypted and travels peer to peer, from each device to the computer that hosts it (your node). It never passes through the meshbay.org hub.', + 'welcome.how_title': 'How it works', + 'welcome.step_home': 'Install MeshBay on a computer at home. Your files stay right where they are.', + 'welcome.step_anywhere': 'Sign in from your phone, your laptop or any browser, wherever you are.', + 'welcome.step_share': 'Create groups and invite your family and friends to join them.', 'welcome.uses_title': 'What you can do with it', 'welcome.use_chat': 'Instant messaging within a group', 'welcome.use_photos': 'Share photo albums with your family and friends', 'welcome.use_media': 'Reach your home media center from anywhere, for the whole household (streaming, plus Chromecast from the desktop app)', 'welcome.use_apps': 'Build your own applications on the node you host', - 'welcome.hub_title': 'What meshbay.org does', - 'welcome.hub_body': 'meshbay.org is only a signaling service: it signs you in and connects the parties to one another, plug-and-play, across home networks.', - 'welcome.hub_never': 'Your content never reaches it:', - 'welcome.never_transit': 'Nothing passes through it: no content, no indexing', - 'welcome.never_stored': 'None of your files or messages are stored on it', - 'welcome.never_e2e': 'Encryption runs end to end, from each device to your node', - 'welcome.hub_free': 'The service is free. There is no business model by design, no tracking and no ads. Enjoy!', + 'welcome.private_title': 'Private by design', + 'welcome.private_body': 'Your files and messages travel encrypted, straight from your devices to your own computer. meshbay.org simply helps your devices find each other: your content never passes through it.', + 'welcome.badge_free': 'Free', + 'welcome.badge_open': 'Open source', + 'welcome.badge_no_ads': 'No ads', + 'welcome.badge_no_tracking': 'No tracking', + 'welcome.docs_title': 'Documentation', + 'welcome.docs_source': 'Source code', + 'welcome.docs_repo': 'git.meshbay.org', + 'welcome.docs_user': 'User docs', + 'welcome.docs_quickstart': 'Quick start', + 'welcome.docs_userguide': 'User guide', + 'welcome.docs_devel': 'Developer docs', + 'welcome.docs_design': 'Design', + 'welcome.docs_protocol': 'Protocol', 'welcome.download': 'Download (beta)', 'welcome.legal': 'Legal information', 'register.err_mismatch': 'Passwords do not match', @@ -804,10 +814,10 @@ export default { 'members.invite_email_failed': 'Could not send the email — share the code manually.', 'members.invite_email_opt': 'Send the invitation by e-mail (may land in spam)', 'members.link_title': "Invite by link", - 'members.link_hint': "For someone who may not have an account yet. The link works once, and only for an account registered with this address.", - 'members.link_email_placeholder': "E-mail address", + 'members.link_hint': "For someone who may not have an account yet. Send it however you like — a messaging app, a text. It works once, for seven days, for whoever opens it first.", + 'members.link_email_placeholder': "E-mail address (optional)", 'members.link_btn': "Create link", - 'members.link_ready': "Invitation link for {email}:", + 'members.link_ready': "Invitation link — valid 7 days, single use:", 'members.link_copy': "Copy", 'members.link_copied': "Copied", 'members.link_email_sent': "The link has been sent by e-mail.", @@ -816,9 +826,12 @@ export default { 'members.link_status_expired': "expired", 'members.link_expires': "expires {date}", 'members.link_cancel': "Cancel", + 'members.link_share': "Share", + 'members.link_share_title': "Invitation", + 'members.link_unlabelled': "link created {date}", 'invite.title': "You have been invited", 'invite.none': "There is no invitation waiting in this tab. Open the link you received again.", - 'invite.signed_out': "Someone invited you to a group on this hub. Create an account with the e-mail address the invitation was sent to, or sign in if you already have one.", + 'invite.signed_out': "Someone invited you to a group on this hub. Create an account, or sign in if you already have one.", 'invite.register': "Create an account", 'invite.signin': "Sign in", 'invite.confirm': "{inviter} invites you to join {group}.", @@ -826,7 +839,6 @@ export default { 'invite.ignore': "Ignore", 'invite.open': "Open the group", 'invite.already_member': "You are already a member of {group}.", - 'invite.other_account': "This invitation was sent to another e-mail address. Sign in with the account registered with that address — aliases and dots must match exactly.", 'invite.invalid': "This invitation is no longer valid: it has been used, cancelled or has expired. Ask for a new one.", 'invite.joining': "Joining…", 'invite.after_register': "Sign in to join the group you were invited to.", @@ -972,6 +984,8 @@ export default { 'group.mute': 'Mute notifications', 'group.unmute': 'Unmute notifications', 'group.retry': 'Retry', + 'lazy.load_failed': 'This part of the interface could not be loaded. The hub may have been updated since this page was opened.', + 'lazy.reload': 'Reload the page', 'profile.title': 'Profile', 'usermenu.profile': 'Profile', @@ -994,7 +1008,7 @@ export default { 'node.service_restarting': 'Restarting…', 'node.service_mode_hint': 'Running as a background service — it starts at boot, before sign-in.', 'node.startup_mode_label': 'Start automatically:', - 'node.startup_mode_off': 'Off (start manually)', + 'node.startup_mode_off': 'Only while MeshBay is open', 'node.startup_mode_signin': 'At sign-in', 'node.startup_mode_service': 'As a background service (starts at boot)', 'node.startup_mode_updating': 'Switching mode — check for an administrator prompt…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index df65367..1e5f32f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -92,26 +92,36 @@ export default { 'login.locked': "Demasiadas frases de contraseña incorrectas para esta cuenta. Vuelva a intentarlo en {minutes} min o restablezca su frase de contraseña.", // Sign-in page: what MeshBay is (browser only) 'welcome.title': 'Internet como debió ser.', - 'welcome.lead': 'MeshBay es un software de código abierto que le da acceso remoto a sus archivos personales y ejecuta aplicaciones sobre el almacenamiento de su propio ordenador:', + 'welcome.lead': 'Sus fotos, su música, sus vídeos y sus conversaciones se quedan en casa, en su propio ordenador. MeshBay le permite disfrutarlos desde cualquier lugar y compartirlos con las personas que usted elija.', 'welcome.app_chat': 'Chat', 'welcome.app_photos': 'Fotos', 'welcome.app_media': 'Centro multimedia', 'welcome.app_video': 'Reproductor de vídeo', 'welcome.app_music': 'Reproductor de música', - 'welcome.groups': 'Cree grupos para dar a sus allegados acceso a algunas de estas aplicaciones.', - 'welcome.e2e': 'Sus datos están cifrados de extremo a extremo y viajan entre pares, directamente de cada dispositivo al ordenador que los aloja: su nodo. Nunca pasan por el hub meshbay.org.', + 'welcome.how_title': 'Cómo funciona', + 'welcome.step_home': 'Instale MeshBay en un ordenador de casa. Sus archivos se quedan donde están.', + 'welcome.step_anywhere': 'Inicie sesión desde su móvil, su portátil o cualquier navegador, esté donde esté.', + 'welcome.step_share': 'Cree grupos e invite a su familia y a sus amigos a unirse.', 'welcome.uses_title': 'Qué puede hacer con él', 'welcome.use_chat': 'Mensajería instantánea dentro de un grupo', 'welcome.use_photos': 'Compartir álbumes de fotos con su familia y amigos', 'welcome.use_media': 'Acceder desde cualquier lugar a su centro multimedia, para usted y su hogar, con streaming y Chromecast desde la aplicación de escritorio', 'welcome.use_apps': 'Crear sus propias aplicaciones en el nodo que aloja', - 'welcome.hub_title': 'Qué hace meshbay.org', - 'welcome.hub_body': 'meshbay.org es solo un servicio de señalización: le autentica y conecta a las partes entre sí, sin configuración, a través de redes domésticas.', - 'welcome.hub_never': 'Su contenido nunca llega a él:', - 'welcome.never_transit': 'Nada pasa por él: ni contenido ni indexación', - 'welcome.never_stored': 'Ninguno de sus archivos o mensajes se almacena en él', - 'welcome.never_e2e': 'Cifrado de extremo a extremo, de cada dispositivo a su nodo', - 'welcome.hub_free': 'El servicio es gratuito. Deliberadamente no hay modelo de negocio, ni rastreo, ni publicidad. ¡Disfrútelo!', + 'welcome.private_title': 'Privado desde el diseño', + 'welcome.private_body': 'Sus archivos y mensajes viajan cifrados, directamente de sus dispositivos a su propio ordenador. meshbay.org solo ayuda a que sus dispositivos se encuentren: su contenido nunca pasa por él.', + 'welcome.badge_free': 'Gratis', + 'welcome.badge_open': 'Código abierto', + 'welcome.badge_no_ads': 'Sin publicidad', + 'welcome.badge_no_tracking': 'Sin rastreo', + 'welcome.docs_title': 'Documentación', + 'welcome.docs_source': 'Código fuente', + 'welcome.docs_repo': 'git.meshbay.org', + 'welcome.docs_user': 'Docs de usuario', + 'welcome.docs_quickstart': 'Inicio rápido', + 'welcome.docs_userguide': 'Guía de usuario', + 'welcome.docs_devel': 'Docs de desarrollo', + 'welcome.docs_design': 'Diseño', + 'welcome.docs_protocol': 'Protocolo', 'welcome.download': 'Descargar (beta)', 'welcome.legal': 'Información legal', 'register.err_mismatch': 'Las contraseñas no coinciden', @@ -683,10 +693,10 @@ export default { 'members.invite_email_failed': 'No se pudo enviar el correo — comparta el código manualmente.', 'members.invite_email_opt': 'Enviar la invitación por correo (puede llegar a spam)', 'members.link_title': "Invitar con un enlace", - 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. El enlace sirve una vez, y solo para una cuenta registrada con esta dirección.", - 'members.link_email_placeholder': "Dirección de correo", + 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. Envíelo como prefiera — mensajería, SMS. El enlace sirve una sola vez, durante siete días, para quien lo abra primero.", + 'members.link_email_placeholder': "Dirección de correo (opcional)", 'members.link_btn': "Crear enlace", - 'members.link_ready': "Enlace de invitación para {email}:", + 'members.link_ready': "Enlace de invitación — válido 7 días, un solo uso:", 'members.link_copy': "Copiar", 'members.link_copied': "Copiado", 'members.link_email_sent': "El enlace se ha enviado por correo.", @@ -695,9 +705,12 @@ export default { 'members.link_status_expired': "caducado", 'members.link_expires': "caduca el {date}", 'members.link_cancel': "Cancelar", + 'members.link_share': "Compartir", + 'members.link_share_title': "Invitación", + 'members.link_unlabelled': "enlace creado el {date}", 'invite.title': "Le han invitado", 'invite.none': "No hay ninguna invitación esperando en esta pestaña. Vuelva a abrir el enlace que recibió.", - 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta con la dirección de correo a la que se envió la invitación, o inicie sesión si ya tiene una.", + 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta, o inicie sesión si ya tiene una.", 'invite.register': "Crear una cuenta", 'invite.signin': "Iniciar sesión", 'invite.confirm': "{inviter} le invita a unirse a {group}.", @@ -705,7 +718,6 @@ export default { 'invite.ignore': "Ignorar", 'invite.open': "Abrir el grupo", 'invite.already_member': "Ya es miembro de {group}.", - 'invite.other_account': "Esta invitación se envió a otra dirección de correo. Inicie sesión con la cuenta registrada con esa dirección — los alias y los puntos deben coincidir exactamente.", 'invite.invalid': "Esta invitación ya no es válida: se ha usado, cancelado o ha caducado. Pida una nueva.", 'invite.joining': "Uniéndose…", 'invite.after_register': "Inicie sesión para unirse al grupo al que le invitaron.", @@ -870,6 +882,8 @@ export default { // Group 'group.retry': 'Reintentar', + 'lazy.load_failed': 'No se pudo cargar esta parte de la interfaz. Es posible que el hub se haya actualizado desde que se abrió esta página.', + 'lazy.reload': 'Recargar la página', // Sidebar 'sidebar.node': 'Node', @@ -892,7 +906,7 @@ export default { 'node.service_restarting': 'Reiniciando…', 'node.service_mode_hint': 'Se ejecuta como servicio en segundo plano — se inicia al arrancar, antes de iniciar sesión.', 'node.startup_mode_label': 'Iniciar automáticamente:', - 'node.startup_mode_off': 'Desactivado (iniciar manualmente)', + 'node.startup_mode_off': 'Solo mientras MeshBay está abierto', 'node.startup_mode_signin': 'Al iniciar sesión', 'node.startup_mode_service': 'Como servicio en segundo plano (se inicia al arrancar)', 'node.startup_mode_updating': 'Cambiando de modo — compruebe si aparece un aviso de administrador…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index e5d542a..f1b3da3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -92,26 +92,36 @@ export default { 'login.locked': "Trop de phrases secrètes erronées pour ce compte. Réessayez dans {minutes} min, ou réinitialisez votre phrase secrète.", // Sign-in page: what MeshBay is (browser only) 'welcome.title': 'L’Internet tel qu’il aurait dû être.', - 'welcome.lead': 'MeshBay est un logiciel open source qui vous donne accès à distance à vos fichiers personnels et fait tourner des applications sur le stockage de votre ordinateur :', + 'welcome.lead': 'Vos photos, votre musique, vos vidéos et vos conversations restent chez vous, sur votre propre ordinateur. MeshBay vous permet d’en profiter de partout, et de les partager avec les personnes de votre choix.', 'welcome.app_chat': 'Messagerie', 'welcome.app_photos': 'Photos', 'welcome.app_media': 'Médiathèque', 'welcome.app_video': 'Lecteur vidéo', 'welcome.app_music': 'Lecteur de musique', - 'welcome.groups': 'Créez des groupes pour donner accès à certaines de ces applications à vos proches.', - 'welcome.e2e': 'Vos données sont chiffrées de bout en bout et circulent en pair à pair, de chaque appareil jusqu’à l’ordinateur qui les héberge (votre nœud). Elles ne passent jamais par le hub meshbay.org.', + 'welcome.how_title': 'Comment ça marche', + 'welcome.step_home': 'Installez MeshBay sur un ordinateur à la maison. Vos fichiers restent là où ils sont.', + 'welcome.step_anywhere': 'Connectez-vous depuis votre téléphone, votre portable ou n’importe quel navigateur, où que vous soyez.', + 'welcome.step_share': 'Créez des groupes et invitez-y votre famille et vos amis.', 'welcome.uses_title': 'Ce que vous pouvez en faire', 'welcome.use_chat': 'Messagerie instantanée au sein d’un groupe', 'welcome.use_photos': 'Partager des albums photo avec votre famille et vos amis', 'welcome.use_media': 'Accéder à distance à votre médiathèque personnelle, pour vous et votre foyer (en streaming, et avec Chromecast depuis l’application de bureau)', 'welcome.use_apps': 'Créer vos propres applications sur le nœud que vous hébergez', - 'welcome.hub_title': 'Le rôle de meshbay.org', - 'welcome.hub_body': 'meshbay.org est un simple service de signalisation : il vous authentifie et met les participants en relation, sans configuration, à travers les réseaux domestiques.', - 'welcome.hub_never': 'Vos contenus ne l’atteignent jamais :', - 'welcome.never_transit': 'Rien ne transite par lui : aucun contenu, aucune indexation', - 'welcome.never_stored': 'Aucun de vos fichiers ou messages n’y est stocké', - 'welcome.never_e2e': 'Chiffrement de bout en bout, de chaque appareil jusqu’à votre nœud', - 'welcome.hub_free': 'Le service est gratuit : aucun modèle économique (c’est voulu), aucun traçage, aucune publicité. Profitez-en !', + 'welcome.private_title': 'Privé par conception', + 'welcome.private_body': 'Vos fichiers et vos messages circulent chiffrés, directement de vos appareils jusqu’à votre propre ordinateur. meshbay.org aide simplement vos appareils à se trouver : vos contenus ne passent jamais par lui.', + 'welcome.badge_free': 'Gratuit', + 'welcome.badge_open': 'Open source', + 'welcome.badge_no_ads': 'Sans publicité', + 'welcome.badge_no_tracking': 'Sans traçage', + 'welcome.docs_title': 'Documentation', + 'welcome.docs_source': 'Code source', + 'welcome.docs_repo': 'git.meshbay.org', + 'welcome.docs_user': 'Docs utilisateur', + 'welcome.docs_quickstart': 'Démarrage rapide', + 'welcome.docs_userguide': 'Guide utilisateur', + 'welcome.docs_devel': 'Docs développeur', + 'welcome.docs_design': 'Conception', + 'welcome.docs_protocol': 'Protocole', 'welcome.download': 'Télécharger (bêta)', 'welcome.legal': 'Informations légales', 'register.err_mismatch': 'Les mots de passe ne correspondent pas', @@ -686,10 +696,10 @@ export default { 'members.invite_email_failed': "Impossible d’envoyer l’e-mail — partagez le code manuellement.", 'members.invite_email_opt': 'Envoyer l’invitation par e-mail (risque d’arriver dans les spams)', 'members.link_title': "Inviter par lien", - 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Le lien ne sert qu’une fois, et seulement pour un compte créé avec cette adresse.", - 'members.link_email_placeholder': "Adresse e-mail", + 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Envoyez-le comme vous voulez — messagerie, SMS. Le lien ne sert qu’une fois, pendant sept jours, à la première personne qui l’ouvre.", + 'members.link_email_placeholder': "Adresse e-mail (facultative)", 'members.link_btn': "Créer le lien", - 'members.link_ready': "Lien d’invitation pour {email} :", + 'members.link_ready': "Lien d’invitation — valable 7 jours, usage unique :", 'members.link_copy': "Copier", 'members.link_copied': "Copié", 'members.link_email_sent': "Le lien a été envoyé par e-mail.", @@ -698,9 +708,12 @@ export default { 'members.link_status_expired': "expiré", 'members.link_expires': "expire le {date}", 'members.link_cancel': "Annuler", + 'members.link_share': "Partager", + 'members.link_share_title': "Invitation", + 'members.link_unlabelled': "lien créé le {date}", 'invite.title': "Vous êtes invité", 'invite.none': "Aucune invitation n’attend dans cet onglet. Rouvrez le lien que vous avez reçu.", - 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte avec l’adresse e-mail à laquelle l’invitation a été envoyée, ou connectez-vous si vous en avez déjà un.", + 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte, ou connectez-vous si vous en avez déjà un.", 'invite.register': "Créer un compte", 'invite.signin': "Se connecter", 'invite.confirm': "{inviter} vous invite à rejoindre {group}.", @@ -708,7 +721,6 @@ export default { 'invite.ignore': "Ignorer", 'invite.open': "Ouvrir le groupe", 'invite.already_member': "Vous êtes déjà membre de {group}.", - 'invite.other_account': "Cette invitation a été envoyée à une autre adresse e-mail. Connectez-vous avec le compte créé avec cette adresse — les alias et les points doivent correspondre exactement.", 'invite.invalid': "Cette invitation n’est plus valable : elle a été utilisée, annulée ou a expiré. Demandez-en une nouvelle.", 'invite.joining': "Adhésion…", 'invite.after_register': "Connectez-vous pour rejoindre le groupe auquel vous avez été invité.", @@ -873,6 +885,8 @@ export default { // Group 'group.retry': 'Réessayer', + 'lazy.load_failed': 'Cette partie de l\'interface n\'a pas pu être chargée. Le hub a peut-être été mis à jour depuis l\'ouverture de cette page.', + 'lazy.reload': 'Recharger la page', // Sidebar 'sidebar.node': 'Node', @@ -895,7 +909,7 @@ export default { 'node.service_restarting': 'Redémarrage…', 'node.service_mode_hint': 'Fonctionne comme service en arrière-plan — démarre au boot, avant l\'ouverture de session.', 'node.startup_mode_label': 'Démarrer automatiquement :', - 'node.startup_mode_off': 'Désactivé (démarrage manuel)', + 'node.startup_mode_off': 'Uniquement quand MeshBay est ouvert', 'node.startup_mode_signin': 'À l\'ouverture de session', 'node.startup_mode_service': 'Comme service en arrière-plan (démarre au boot)', 'node.startup_mode_updating': 'Changement de mode — vérifiez une invite d\'administrateur…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index e376550..4120b1d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -93,26 +93,36 @@ export default { 'login.locked': "Troppe passphrase errate per questo account. Riprovi tra {minutes} min oppure reimposti la passphrase.", // Sign-in page: what MeshBay is (browser only) 'welcome.title': 'Internet come doveva essere.', - 'welcome.lead': 'MeshBay è un software open source che le permette di accedere da remoto ai suoi file personali e di usare applicazioni basate sullo spazio di archiviazione del suo computer:', + 'welcome.lead': 'Le sue foto, la sua musica, i suoi video e le sue conversazioni restano a casa, sul suo computer. MeshBay le permette di goderseli ovunque e di condividerli con le persone che sceglie.', 'welcome.app_chat': 'Chat', 'welcome.app_photos': 'Foto', 'welcome.app_media': 'Media center', 'welcome.app_video': 'Lettore video', 'welcome.app_music': 'Lettore musicale', - 'welcome.groups': 'Crei gruppi per dare alle persone a lei care l’accesso ad alcune di queste applicazioni.', - 'welcome.e2e': 'I suoi dati sono cifrati end-to-end e viaggiano peer-to-peer, direttamente da ogni dispositivo al computer che li ospita: il suo nodo. Non passano mai dall’hub meshbay.org.', + 'welcome.how_title': 'Come funziona', + 'welcome.step_home': 'Installi MeshBay su un computer di casa. I suoi file restano dove sono.', + 'welcome.step_anywhere': 'Acceda dal telefono, dal portatile o da qualsiasi browser, ovunque si trovi.', + 'welcome.step_share': 'Crei dei gruppi e inviti la sua famiglia e i suoi amici a unirsi.', 'welcome.uses_title': 'Cosa può farci', 'welcome.use_chat': 'Messaggistica istantanea all’interno di un gruppo', 'welcome.use_photos': 'Condividere album fotografici con famiglia e amici', 'welcome.use_media': 'Accedere ovunque al media center di casa, per lei e la sua famiglia, con streaming e Chromecast dall’app desktop', 'welcome.use_apps': 'Creare le proprie applicazioni sul nodo che ospita', - 'welcome.hub_title': 'Cosa fa meshbay.org', - 'welcome.hub_body': 'meshbay.org è solo un servizio di segnalazione: la autentica e mette in contatto le parti, plug-and-play, attraverso le reti domestiche.', - 'welcome.hub_never': 'I suoi contenuti non lo raggiungono mai:', - 'welcome.never_transit': 'Nulla passa da lì: nessun contenuto, nessuna indicizzazione', - 'welcome.never_stored': 'Nessun suo file o messaggio vi è archiviato', - 'welcome.never_e2e': 'Crittografia end-to-end, da ogni dispositivo al suo nodo', - 'welcome.hub_free': 'Il servizio è gratuito. Nessun modello di business (per scelta), nessun tracciamento, nessuna pubblicità. Buon divertimento!', + 'welcome.private_title': 'Privato per natura', + 'welcome.private_body': 'I suoi file e i suoi messaggi viaggiano cifrati, direttamente dai suoi dispositivi al suo computer. meshbay.org aiuta solo i dispositivi a trovarsi: i suoi contenuti non vi passano mai.', + 'welcome.badge_free': 'Gratuito', + 'welcome.badge_open': 'Open source', + 'welcome.badge_no_ads': 'Niente pubblicità', + 'welcome.badge_no_tracking': 'Nessun tracciamento', + 'welcome.docs_title': 'Documentazione', + 'welcome.docs_source': 'Codice sorgente', + 'welcome.docs_repo': 'git.meshbay.org', + 'welcome.docs_user': 'Documentazione utente', + 'welcome.docs_quickstart': 'Avvio rapido', + 'welcome.docs_userguide': 'Guida utente', + 'welcome.docs_devel': 'Documentazione tecnica', + 'welcome.docs_design': 'Architettura', + 'welcome.docs_protocol': 'Protocollo', 'welcome.download': 'Scarica (beta)', 'welcome.legal': 'Note legali', 'register.err_mismatch': 'Le password non coincidono', @@ -686,10 +696,10 @@ export default { 'members.invite_email_failed': "Impossibile inviare l'e-mail — condivida il codice manualmente.", 'members.invite_email_opt': 'Invia l’invito per e-mail (potrebbe finire nello spam)', 'members.link_title': "Invita tramite link", - 'members.link_hint': "Per chi forse non ha ancora un account. Il link funziona una volta, e solo per un account registrato con questo indirizzo.", - 'members.link_email_placeholder': "Indirizzo e-mail", + 'members.link_hint': "Per chi forse non ha ancora un account. Lo invii come preferisce — messaggistica, SMS. Il link funziona una sola volta, per sette giorni, per chi lo apre per primo.", + 'members.link_email_placeholder': "Indirizzo e-mail (facoltativo)", 'members.link_btn': "Crea link", - 'members.link_ready': "Link d’invito per {email}:", + 'members.link_ready': "Link di invito — valido 7 giorni, uso singolo:", 'members.link_copy': "Copia", 'members.link_copied': "Copiato", 'members.link_email_sent': "Il link è stato inviato per e-mail.", @@ -698,9 +708,12 @@ export default { 'members.link_status_expired': "scaduto", 'members.link_expires': "scade il {date}", 'members.link_cancel': "Annulla", + 'members.link_share': "Condividi", + 'members.link_share_title': "Invito", + 'members.link_unlabelled': "link creato il {date}", 'invite.title': "È stato invitato", 'invite.none': "Nessun invito in attesa in questa scheda. Riapra il link ricevuto.", - 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account con l’indirizzo e-mail a cui è stato inviato l’invito, o acceda se ne ha già uno.", + 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account, o acceda se ne ha già uno.", 'invite.register': "Crea un account", 'invite.signin': "Accedi", 'invite.confirm': "{inviter} la invita a unirsi a {group}.", @@ -708,7 +721,6 @@ export default { 'invite.ignore': "Ignora", 'invite.open': "Apri il gruppo", 'invite.already_member': "È già membro di {group}.", - 'invite.other_account': "Questo invito è stato inviato a un altro indirizzo e-mail. Acceda con l’account registrato con quell’indirizzo — alias e punti devono corrispondere esattamente.", 'invite.invalid': "Questo invito non è più valido: è stato usato, annullato o è scaduto. Ne chieda uno nuovo.", 'invite.joining': "Adesione…", 'invite.after_register': "Acceda per unirsi al gruppo a cui è stato invitato.", @@ -872,6 +884,8 @@ export default { // Group misc 'group.retry': 'Riprova', + 'lazy.load_failed': 'Impossibile caricare questa parte dell\'interfaccia. L\'hub potrebbe essere stato aggiornato da quando questa pagina è stata aperta.', + 'lazy.reload': 'Ricarica la pagina', // Sidebar 'sidebar.node': 'Node', @@ -894,7 +908,7 @@ export default { 'node.service_restarting': 'Riavvio…', 'node.service_mode_hint': 'In esecuzione come servizio in background — si avvia all\'avvio del sistema, prima dell\'accesso.', 'node.startup_mode_label': 'Avvia automaticamente:', - 'node.startup_mode_off': 'Disattivato (avvio manuale)', + 'node.startup_mode_off': 'Solo mentre MeshBay è aperto', 'node.startup_mode_signin': 'All\'accesso', 'node.startup_mode_service': 'Come servizio in background (si avvia all\'avvio del sistema)', 'node.startup_mode_updating': 'Cambio modalità — controlli se compare una richiesta di amministratore…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 622fa5a..b37a027 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -93,26 +93,36 @@ export default { 'login.locked': "このアカウントでパスフレーズの誤りが多すぎます。{minutes} 分後にもう一度お試しいただくか、パスフレーズをリセットしてください。", // Sign-in page: what MeshBay is (browser only) 'welcome.title': '本来あるべき姿のインターネット。', - 'welcome.lead': 'MeshBay は、個人のファイルにどこからでもアクセスでき、自分のパソコンのストレージ上でアプリを動かせるオープンソースソフトウェアです。', + 'welcome.lead': '写真、音楽、動画、会話は、ご自宅の自分のパソコンに置いたまま。MeshBay なら、どこからでも楽しめて、選んだ相手と共有できます。', 'welcome.app_chat': 'チャット', 'welcome.app_photos': '写真', 'welcome.app_media': 'メディアセンター', 'welcome.app_video': '動画プレーヤー', 'welcome.app_music': '音楽プレーヤー', - 'welcome.groups': 'グループを作成して、家族や友人に一部のアプリへのアクセスを許可できます。', - 'welcome.e2e': 'データはエンドツーエンドで暗号化され、各デバイスからデータを保管するパソコン(あなたのノード)へピアツーピアで直接届きます。meshbay.org のハブを経由することは一切ありません。', + 'welcome.how_title': 'しくみ', + 'welcome.step_home': '自宅のパソコンに MeshBay をインストール。ファイルはその場所に置いたままです。', + 'welcome.step_anywhere': 'スマートフォン、ノートパソコン、どのブラウザからでも、どこにいてもログインできます。', + 'welcome.step_share': 'グループを作って、家族や友人を招待しましょう。', 'welcome.uses_title': 'できること', 'welcome.use_chat': 'グループ内でのインスタントメッセージ', 'welcome.use_photos': '家族や友人とのフォトアルバム共有', 'welcome.use_media': '自宅のメディアセンターに外出先からアクセス。ご家族みんなで使え、ストリーミングに対応し、デスクトップアプリからは Chromecast も利用できます', 'welcome.use_apps': 'ホストしているノード上で独自のアプリを開発', - 'welcome.hub_title': 'meshbay.org の役割', - 'welcome.hub_body': 'meshbay.org はシグナリング専用のサービスです。ログインを処理し、家庭のネットワーク越しに参加者同士を設定不要でつなぎます。', - 'welcome.hub_never': 'あなたのコンテンツが届くことはありません:', - 'welcome.never_transit': '何も経由しません(コンテンツもインデックスもなし)', - 'welcome.never_stored': 'ファイルやメッセージは一切保存されません', - 'welcome.never_e2e': '各デバイスからノードまでエンドツーエンドで暗号化', - 'welcome.hub_free': 'サービスは無料です。ビジネスモデルは意図的に持たず、トラッキングも広告もありません。どうぞお楽しみください!', + 'welcome.private_title': '最初からプライベート', + 'welcome.private_body': 'ファイルやメッセージは暗号化され、あなたの端末から自分のパソコンへ直接届きます。meshbay.org は端末どうしをつなぐ手助けをするだけで、あなたのコンテンツが経由することはありません。', + 'welcome.badge_free': '無料', + 'welcome.badge_open': 'オープンソース', + 'welcome.badge_no_ads': '広告なし', + 'welcome.badge_no_tracking': 'トラッキングなし', + 'welcome.docs_title': 'ドキュメント', + 'welcome.docs_source': 'ソースコード', + 'welcome.docs_repo': 'git.meshbay.org', + 'welcome.docs_user': 'ユーザー向け', + 'welcome.docs_quickstart': 'クイックスタート', + 'welcome.docs_userguide': 'ユーザーガイド', + 'welcome.docs_devel': '開発者向け', + 'welcome.docs_design': '設計', + 'welcome.docs_protocol': 'プロトコル', 'welcome.download': 'ダウンロード(ベータ版)', 'welcome.legal': '法的情報', 'register.err_mismatch': 'パスワードが一致しません', @@ -678,10 +688,10 @@ export default { 'members.invite_email_failed': 'メールを送信できませんでした。コードを手動で共有してください。', 'members.invite_email_opt': '招待をメールで送信(迷惑メールに入る場合があります)', 'members.link_title': "リンクで招待", - 'members.link_hint': "まだアカウントを持っていない人向けです。リンクは1回だけ、このアドレスで登録したアカウントでのみ使えます。", - 'members.link_email_placeholder': "メールアドレス", + 'members.link_hint': "まだアカウントを持っていないかもしれない人向けです。メッセージアプリやSMSなど、お好きな方法で送ってください。リンクは7日間有効で、最初に開いた人が一度だけ使えます。", + 'members.link_email_placeholder': "メールアドレス(任意)", 'members.link_btn': "リンクを作成", - 'members.link_ready': "{email} への招待リンク:", + 'members.link_ready': "招待リンク — 7日間有効、1回限り:", 'members.link_copy': "コピー", 'members.link_copied': "コピーしました", 'members.link_email_sent': "リンクをメールで送信しました。", @@ -690,9 +700,12 @@ export default { 'members.link_status_expired': "期限切れ", 'members.link_expires': "{date} に期限切れ", 'members.link_cancel': "取り消す", + 'members.link_share': "共有", + 'members.link_share_title': "招待", + 'members.link_unlabelled': "{date} に作成したリンク", 'invite.title': "招待されています", 'invite.none': "このタブで待機中の招待はありません。受け取ったリンクをもう一度開いてください。", - 'invite.signed_out': "このハブのグループに招待されています。招待が送られたメールアドレスでアカウントを作成するか、すでにお持ちならサインインしてください。", + 'invite.signed_out': "このハブのグループに招待されています。アカウントを作成するか、すでにお持ちならサインインしてください。", 'invite.register': "アカウントを作成", 'invite.signin': "サインイン", 'invite.confirm': "{inviter} さんが {group} への参加に招待しています。", @@ -700,7 +713,6 @@ export default { 'invite.ignore': "無視", 'invite.open': "グループを開く", 'invite.already_member': "すでに {group} のメンバーです。", - 'invite.other_account': "この招待は別のメールアドレスに送られました。そのアドレスで登録したアカウントでサインインしてください。エイリアスやドットも完全に一致する必要があります。", 'invite.invalid': "この招待は無効です。使用済み、取り消し済み、または期限切れです。新しい招待を依頼してください。", 'invite.joining': "参加しています…", 'invite.after_register': "招待されたグループに参加するにはサインインしてください。", @@ -860,6 +872,8 @@ export default { // Group 'group.retry': '再試行', + 'lazy.load_failed': 'インターフェースのこの部分を読み込めませんでした。このページを開いた後に hub が更新された可能性があります。', + 'lazy.reload': 'ページを再読み込み', // Sidebar 'sidebar.node': 'Node', @@ -882,7 +896,7 @@ export default { 'node.service_restarting': '再起動中…', 'node.service_mode_hint': 'バックグラウンドサービスとして実行中 — サインインより前、起動時に開始します。', 'node.startup_mode_label': '自動的に開始:', - 'node.startup_mode_off': 'オフ(手動で開始)', + 'node.startup_mode_off': 'MeshBay が開いている間のみ', 'node.startup_mode_signin': 'サインイン時', 'node.startup_mode_service': 'バックグラウンドサービスとして(起動時に開始)', 'node.startup_mode_updating': 'モードを切り替え中 — 管理者の確認ダイアログをご確認ください…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index be0b43b..eeebaf8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -93,26 +93,36 @@ export default { 'login.locked': "Te veel onjuiste wachtwoordzinnen voor dit account. Probeer het over {minutes} min opnieuw of stel uw wachtwoordzin opnieuw in.", // Sign-in page: what MeshBay is (browser only) 'welcome.title': 'Het internet zoals het bedoeld was.', - 'welcome.lead': 'MeshBay is opensourcesoftware waarmee u op afstand bij uw persoonlijke bestanden kunt, en die toepassingen draait bovenop de opslag van uw eigen computer:', + 'welcome.lead': 'Uw foto’s, muziek, video’s en gesprekken blijven thuis, op uw eigen computer. Met MeshBay geniet u er overal van en deelt u ze met wie u maar wilt.', 'welcome.app_chat': 'Chat', 'welcome.app_photos': 'Foto’s', 'welcome.app_media': 'Mediacenter', 'welcome.app_video': 'Videospeler', 'welcome.app_music': 'Muziekspeler', - 'welcome.groups': 'Maak groepen aan om uw naasten toegang te geven tot een aantal van deze toepassingen.', - 'welcome.e2e': 'Uw gegevens zijn end-to-end versleuteld en gaan peer-to-peer, rechtstreeks van elk apparaat naar de computer waarop ze staan: uw node. Ze gaan nooit via de hub meshbay.org.', + 'welcome.how_title': 'Zo werkt het', + 'welcome.step_home': 'Installeer MeshBay op een computer thuis. Uw bestanden blijven waar ze zijn.', + 'welcome.step_anywhere': 'Meld u aan vanaf uw telefoon, laptop of elke browser, waar u ook bent.', + 'welcome.step_share': 'Maak groepen en nodig familie en vrienden uit om mee te doen.', 'welcome.uses_title': 'Wat u ermee kunt doen', 'welcome.use_chat': 'Chatten binnen een groep', 'welcome.use_photos': 'Fotoalbums delen met familie en vrienden', 'welcome.use_media': 'Overal bij uw mediacenter thuis, voor u en uw huishouden, met streaming en Chromecast via de desktop-app', 'welcome.use_apps': 'Uw eigen toepassingen bouwen op de node die u host', - 'welcome.hub_title': 'Wat meshbay.org doet', - 'welcome.hub_body': 'meshbay.org is alleen een signaleringsdienst: het meldt u aan en brengt de partijen met elkaar in contact, plug-and-play, over thuisnetwerken heen.', - 'welcome.hub_never': 'Uw inhoud komt er nooit terecht:', - 'welcome.never_transit': 'Er gaat niets doorheen: geen inhoud, geen indexering', - 'welcome.never_stored': 'Geen van uw bestanden of berichten wordt er opgeslagen', - 'welcome.never_e2e': 'End-to-end versleuteling, van elk apparaat tot uw node', - 'welcome.hub_free': 'De dienst is gratis. Er is bewust geen verdienmodel, geen tracking en geen advertenties. Veel plezier!', + 'welcome.private_title': 'Privé van nature', + 'welcome.private_body': 'Uw bestanden en berichten reizen versleuteld, rechtstreeks van uw apparaten naar uw eigen computer. meshbay.org helpt uw apparaten alleen elkaar te vinden: uw inhoud komt er nooit langs.', + 'welcome.badge_free': 'Gratis', + 'welcome.badge_open': 'Open source', + 'welcome.badge_no_ads': 'Geen advertenties', + 'welcome.badge_no_tracking': 'Geen tracking', + 'welcome.docs_title': 'Documentatie', + 'welcome.docs_source': 'Broncode', + 'welcome.docs_repo': 'git.meshbay.org', + 'welcome.docs_user': 'Gebruikersdocs', + 'welcome.docs_quickstart': 'Snelstart', + 'welcome.docs_userguide': 'Gebruikershandleiding', + 'welcome.docs_devel': 'Ontwikkelaarsdocs', + 'welcome.docs_design': 'Ontwerp', + 'welcome.docs_protocol': 'Protocol', 'welcome.download': 'Downloaden (bèta)', 'welcome.legal': 'Juridische informatie', 'register.err_mismatch': 'De wachtwoorden komen niet overeen', @@ -687,10 +697,10 @@ export default { 'members.invite_email_failed': 'Kon de e-mail niet verzenden — deel de code handmatig.', 'members.invite_email_opt': 'Uitnodiging per e-mail versturen (kan in spam belanden)', 'members.link_title': "Uitnodigen via link", - 'members.link_hint': "Voor iemand die misschien nog geen account heeft. De link werkt één keer, en alleen voor een account met dit adres.", - 'members.link_email_placeholder': "E-mailadres", + 'members.link_hint': "Voor iemand die misschien nog geen account heeft. Verstuur hem zoals u wilt — berichtenapp, sms. De link werkt één keer, zeven dagen lang, voor wie hem als eerste opent.", + 'members.link_email_placeholder': "E-mailadres (optioneel)", 'members.link_btn': "Link maken", - 'members.link_ready': "Uitnodigingslink voor {email}:", + 'members.link_ready': "Uitnodigingslink — 7 dagen geldig, eenmalig:", 'members.link_copy': "Kopiëren", 'members.link_copied': "Gekopieerd", 'members.link_email_sent': "De link is per e-mail verstuurd.", @@ -699,9 +709,12 @@ export default { 'members.link_status_expired': "verlopen", 'members.link_expires': "verloopt op {date}", 'members.link_cancel': "Annuleren", + 'members.link_share': "Delen", + 'members.link_share_title': "Uitnodiging", + 'members.link_unlabelled': "link gemaakt op {date}", 'invite.title': "U bent uitgenodigd", 'invite.none': "Er wacht geen uitnodiging in dit tabblad. Open de ontvangen link opnieuw.", - 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan met het e-mailadres waarnaar de uitnodiging is gestuurd, of meld u aan als u er al een hebt.", + 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan, of meld u aan als u er al een hebt.", 'invite.register': "Account maken", 'invite.signin': "Aanmelden", 'invite.confirm': "{inviter} nodigt u uit voor {group}.", @@ -709,7 +722,6 @@ export default { 'invite.ignore': "Negeren", 'invite.open': "Groep openen", 'invite.already_member': "U bent al lid van {group}.", - 'invite.other_account': "Deze uitnodiging is naar een ander e-mailadres gestuurd. Meld u aan met het account van dat adres — aliassen en punten moeten exact overeenkomen.", 'invite.invalid': "Deze uitnodiging is niet meer geldig: ze is gebruikt, geannuleerd of verlopen. Vraag een nieuwe.", 'invite.joining': "Deelnemen…", 'invite.after_register': "Meld u aan om deel te nemen aan de groep waarvoor u bent uitgenodigd.", @@ -874,6 +886,8 @@ export default { // Group 'group.retry': 'Opnieuw proberen', + 'lazy.load_failed': 'Dit deel van de interface kon niet worden geladen. De hub is mogelijk bijgewerkt sinds deze pagina werd geopend.', + 'lazy.reload': 'Pagina opnieuw laden', // Sidebar 'sidebar.node': 'Node', @@ -896,7 +910,7 @@ export default { 'node.service_restarting': 'Herstarten…', 'node.service_mode_hint': 'Actief als achtergrondservice — start bij het opstarten, vóór het aanmelden.', 'node.startup_mode_label': 'Automatisch starten:', - 'node.startup_mode_off': 'Uit (handmatig starten)', + 'node.startup_mode_off': 'Alleen zolang MeshBay open is', 'node.startup_mode_signin': 'Bij aanmelden', 'node.startup_mode_service': 'Als achtergrondservice (start bij het opstarten)', 'node.startup_mode_updating': 'Modus wijzigen — let op een beheerdersprompt…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 6e88a65..9be95db 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -96,26 +96,36 @@ export default { 'login.locked': "Zbyt wiele błędnych haseł-fraz dla tego konta. Spróbuj ponownie za {minutes} min lub zresetuj hasło-frazę.", // Sign-in page: what MeshBay is (browser only) 'welcome.title': 'Internet taki, jaki miał być.', - 'welcome.lead': 'MeshBay to oprogramowanie open source, które daje zdalny dostęp do Twoich plików osobistych i uruchamia aplikacje korzystające z pamięci Twojego własnego komputera:', + 'welcome.lead': 'Twoje zdjęcia, muzyka, filmy i rozmowy zostają w domu, na Twoim własnym komputerze. MeshBay pozwala korzystać z nich z dowolnego miejsca i dzielić się nimi z wybranymi osobami.', 'welcome.app_chat': 'Czat', 'welcome.app_photos': 'Zdjęcia', 'welcome.app_media': 'Centrum multimedialne', 'welcome.app_video': 'Odtwarzacz wideo', 'welcome.app_music': 'Odtwarzacz muzyki', - 'welcome.groups': 'Twórz grupy, aby dać bliskim dostęp do wybranych aplikacji.', - 'welcome.e2e': 'Twoje dane są szyfrowane end-to-end i przesyłane bezpośrednio (peer-to-peer) z każdego urządzenia do komputera, który je przechowuje (Twojego węzła). Nigdy nie przechodzą przez hub meshbay.org.', + 'welcome.how_title': 'Jak to działa', + 'welcome.step_home': 'Zainstaluj MeshBay na komputerze w domu. Twoje pliki zostają tam, gdzie są.', + 'welcome.step_anywhere': 'Zaloguj się z telefonu, laptopa lub dowolnej przeglądarki, gdziekolwiek jesteś.', + 'welcome.step_share': 'Twórz grupy i zapraszaj do nich rodzinę i przyjaciół.', 'welcome.uses_title': 'Co możesz z nim robić', 'welcome.use_chat': 'Komunikator w obrębie grupy', 'welcome.use_photos': 'Udostępnianie albumów ze zdjęciami rodzinie i znajomym', 'welcome.use_media': 'Zdalny dostęp do domowego centrum multimedialnego dla Ciebie i domowników (ze streamingiem i Chromecastem w aplikacji desktopowej)', 'welcome.use_apps': 'Tworzenie własnych aplikacji na węźle, który hostujesz', - 'welcome.hub_title': 'Czym zajmuje się meshbay.org', - 'welcome.hub_body': 'meshbay.org to wyłącznie usługa sygnalizacyjna: uwierzytelnia Cię i łączy uczestników ze sobą, bez konfiguracji, także przez sieci domowe.', - 'welcome.hub_never': 'Twoje treści nigdy do niego nie trafiają:', - 'welcome.never_transit': 'Nic przez niego nie przechodzi: żadnych treści, żadnego indeksowania', - 'welcome.never_stored': 'Żadne Twoje pliki ani wiadomości nie są na nim przechowywane', - 'welcome.never_e2e': 'Szyfrowanie end-to-end, od każdego urządzenia do Twojego węzła', - 'welcome.hub_free': 'Usługa jest bezpłatna. Celowo nie ma modelu biznesowego, śledzenia ani reklam. Miłego korzystania!', + 'welcome.private_title': 'Prywatność w standardzie', + 'welcome.private_body': 'Twoje pliki i wiadomości podróżują zaszyfrowane, prosto z Twoich urządzeń do Twojego komputera. meshbay.org jedynie pomaga urządzeniom się odnaleźć: Twoje treści nigdy przez niego nie przechodzą.', + 'welcome.badge_free': 'Za darmo', + 'welcome.badge_open': 'Open source', + 'welcome.badge_no_ads': 'Bez reklam', + 'welcome.badge_no_tracking': 'Bez śledzenia', + 'welcome.docs_title': 'Dokumentacja', + 'welcome.docs_source': 'Kod źródłowy', + 'welcome.docs_repo': 'git.meshbay.org', + 'welcome.docs_user': 'Dla użytkowników', + 'welcome.docs_quickstart': 'Szybki start', + 'welcome.docs_userguide': 'Przewodnik', + 'welcome.docs_devel': 'Dla programistów', + 'welcome.docs_design': 'Architektura', + 'welcome.docs_protocol': 'Protokół', 'welcome.download': 'Pobierz (beta)', 'welcome.legal': 'Informacje prawne', 'register.err_mismatch': 'Hasła nie są zgodne', @@ -699,10 +709,10 @@ export default { 'members.invite_email_failed': 'Nie udało się wysłać e-maila — przekaż kod ręcznie.', 'members.invite_email_opt': 'Wyślij zaproszenie e-mailem (może trafić do spamu)', 'members.link_title': "Zaproś linkiem", - 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Link działa raz i tylko dla konta założonego na ten adres.", - 'members.link_email_placeholder': "Adres e-mail", + 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Wyślij go, jak chcesz — komunikatorem, SMS-em. Link działa raz, przez siedem dni, dla osoby, która otworzy go pierwsza.", + 'members.link_email_placeholder': "Adres e-mail (opcjonalnie)", 'members.link_btn': "Utwórz link", - 'members.link_ready': "Link zaproszenia dla {email}:", + 'members.link_ready': "Link z zaproszeniem — ważny 7 dni, jednorazowy:", 'members.link_copy': "Kopiuj", 'members.link_copied': "Skopiowano", 'members.link_email_sent': "Link został wysłany e-mailem.", @@ -711,9 +721,12 @@ export default { 'members.link_status_expired': "wygasł", 'members.link_expires': "wygasa {date}", 'members.link_cancel': "Anuluj", + 'members.link_share': "Udostępnij", + 'members.link_share_title': "Zaproszenie", + 'members.link_unlabelled': "link utworzony {date}", 'invite.title': "Otrzymano zaproszenie", 'invite.none': "W tej karcie nie czeka żadne zaproszenie. Otwórz ponownie otrzymany link.", - 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto na adres e-mail, na który wysłano zaproszenie, albo zaloguj się, jeśli już je masz.", + 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto albo zaloguj się, jeśli już je masz.", 'invite.register': "Załóż konto", 'invite.signin': "Zaloguj się", 'invite.confirm': "{inviter} zaprasza cię do {group}.", @@ -721,7 +734,6 @@ export default { 'invite.ignore': "Ignoruj", 'invite.open': "Otwórz grupę", 'invite.already_member': "Jesteś już członkiem {group}.", - 'invite.other_account': "To zaproszenie wysłano na inny adres e-mail. Zaloguj się na konto założone na ten adres — aliasy i kropki muszą się dokładnie zgadzać.", 'invite.invalid': "To zaproszenie jest już nieważne: zostało użyte, anulowane lub wygasło. Poproś o nowe.", 'invite.joining': "Dołączanie…", 'invite.after_register': "Zaloguj się, aby dołączyć do grupy, do której cię zaproszono.", @@ -892,6 +904,8 @@ export default { // Group (supplementary) 'group.retry': 'Ponów', + 'lazy.load_failed': 'Nie udało się wczytać tej części interfejsu. Hub mógł zostać zaktualizowany od czasu otwarcia tej strony.', + 'lazy.reload': 'Wczytaj stronę ponownie', // Sidebar (supplementary) 'sidebar.node': 'Node', @@ -914,7 +928,7 @@ export default { 'node.service_restarting': 'Ponowne uruchamianie…', 'node.service_mode_hint': 'Działa jako usługa w tle — uruchamia się przy starcie systemu, przed zalogowaniem.', 'node.startup_mode_label': 'Uruchamiaj automatycznie:', - 'node.startup_mode_off': 'Wyłączone (uruchamianie ręczne)', + 'node.startup_mode_off': 'Tylko gdy MeshBay jest otwarty', 'node.startup_mode_signin': 'Przy logowaniu', 'node.startup_mode_service': 'Jako usługa w tle (uruchamia się przy starcie systemu)', 'node.startup_mode_updating': 'Zmiana trybu — proszę sprawdzić, czy pojawiło się okno uprawnień administratora…', 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 index edb9146..cea304e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -94,26 +94,36 @@ export default { 'login.locked': "Muitas frases secretas incorretas para esta conta. Tente novamente em {minutes} min ou redefina sua frase secreta.", // Sign-in page: what MeshBay is (browser only) 'welcome.title': 'A internet como deveria ser.', - 'welcome.lead': 'O MeshBay é um software de código aberto que dá acesso remoto aos seus arquivos pessoais e executa aplicativos sobre o armazenamento do seu próprio computador:', + 'welcome.lead': 'Suas fotos, músicas, vídeos e conversas ficam em casa, no seu próprio computador. O MeshBay permite que você aproveite tudo de qualquer lugar e compartilhe com as pessoas que escolher.', 'welcome.app_chat': 'Chat', 'welcome.app_photos': 'Fotos', 'welcome.app_media': 'Central de mídia', 'welcome.app_video': 'Player de vídeo', 'welcome.app_music': 'Player de música', - 'welcome.groups': 'Crie grupos para dar às pessoas próximas acesso a alguns desses aplicativos.', - 'welcome.e2e': 'Seus dados são criptografados de ponta a ponta e trafegam ponto a ponto, direto de cada dispositivo para o computador que os hospeda: o seu nó. Eles nunca passam pelo hub meshbay.org.', + 'welcome.how_title': 'Como funciona', + 'welcome.step_home': 'Instale o MeshBay em um computador de casa. Seus arquivos ficam onde estão.', + 'welcome.step_anywhere': 'Entre pelo celular, pelo notebook ou por qualquer navegador, onde você estiver.', + 'welcome.step_share': 'Crie grupos e convide sua família e seus amigos para participar.', 'welcome.uses_title': 'O que você pode fazer com ele', 'welcome.use_chat': 'Mensagens instantâneas dentro de um grupo', 'welcome.use_photos': 'Compartilhar álbuns de fotos com a família e os amigos', 'welcome.use_media': 'Acessar de qualquer lugar a sua central de mídia, para você e sua casa, com streaming e Chromecast pelo app para desktop', 'welcome.use_apps': 'Criar seus próprios aplicativos no nó que você hospeda', - 'welcome.hub_title': 'O que o meshbay.org faz', - 'welcome.hub_body': 'O meshbay.org é apenas um serviço de sinalização: ele autentica você e conecta as partes entre si, plug-and-play, através de redes domésticas.', - 'welcome.hub_never': 'Seu conteúdo nunca chega até ele:', - 'welcome.never_transit': 'Nada passa por ele: nenhum conteúdo, nenhuma indexação', - 'welcome.never_stored': 'Nenhum dos seus arquivos ou mensagens fica armazenado nele', - 'welcome.never_e2e': 'Criptografia de ponta a ponta, de cada dispositivo até o seu nó', - 'welcome.hub_free': 'O serviço é gratuito. Não há modelo de negócio (de propósito), rastreamento nem anúncios. Aproveite!', + 'welcome.private_title': 'Privado por natureza', + 'welcome.private_body': 'Seus arquivos e mensagens viajam criptografados, direto dos seus dispositivos para o seu próprio computador. O meshbay.org só ajuda seus dispositivos a se encontrarem: seu conteúdo nunca passa por ele.', + 'welcome.badge_free': 'Gratuito', + 'welcome.badge_open': 'Código aberto', + 'welcome.badge_no_ads': 'Sem anúncios', + 'welcome.badge_no_tracking': 'Sem rastreamento', + 'welcome.docs_title': 'Documentação', + 'welcome.docs_source': 'Código-fonte', + 'welcome.docs_repo': 'git.meshbay.org', + 'welcome.docs_user': 'Docs do usuário', + 'welcome.docs_quickstart': 'Início rápido', + 'welcome.docs_userguide': 'Guia do usuário', + 'welcome.docs_devel': 'Docs de desenvolvimento', + 'welcome.docs_design': 'Arquitetura', + 'welcome.docs_protocol': 'Protocolo', 'welcome.download': 'Baixar (beta)', 'welcome.legal': 'Informações legais', 'register.err_mismatch': 'As senhas não coincidem', @@ -685,10 +695,10 @@ export default { 'members.invite_email_failed': 'Não foi possível enviar o e-mail — compartilhe o código manualmente.', 'members.invite_email_opt': 'Enviar o convite por e-mail (pode cair no spam)', 'members.link_title': "Convidar por link", - 'members.link_hint': "Para alguém que talvez ainda não tenha conta. O link funciona uma vez, e só para uma conta registrada com este endereço.", - 'members.link_email_placeholder': "Endereço de e-mail", + 'members.link_hint': "Para alguém que talvez ainda não tenha conta. Envie como preferir — aplicativo de mensagens, SMS. O link funciona uma vez, por sete dias, para quem abrir primeiro.", + 'members.link_email_placeholder': "Endereço de e-mail (opcional)", 'members.link_btn': "Criar link", - 'members.link_ready': "Link de convite para {email}:", + 'members.link_ready': "Link de convite — válido por 7 dias, uso único:", 'members.link_copy': "Copiar", 'members.link_copied': "Copiado", 'members.link_email_sent': "O link foi enviado por e-mail.", @@ -697,9 +707,12 @@ export default { 'members.link_status_expired': "expirado", 'members.link_expires': "expira em {date}", 'members.link_cancel': "Cancelar", + 'members.link_share': "Compartilhar", + 'members.link_share_title': "Convite", + 'members.link_unlabelled': "link criado em {date}", 'invite.title': "Você foi convidado", 'invite.none': "Não há convite aguardando nesta aba. Abra novamente o link que recebeu.", - 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta com o endereço de e-mail para o qual o convite foi enviado, ou entre se já tiver uma.", + 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta, ou entre se já tiver uma.", 'invite.register': "Criar uma conta", 'invite.signin': "Entrar", 'invite.confirm': "{inviter} convida você para participar de {group}.", @@ -707,7 +720,6 @@ export default { 'invite.ignore': "Ignorar", 'invite.open': "Abrir o grupo", 'invite.already_member': "Você já é membro de {group}.", - 'invite.other_account': "Este convite foi enviado para outro endereço de e-mail. Entre com a conta registrada com esse endereço — aliases e pontos devem coincidir exatamente.", 'invite.invalid': "Este convite não é mais válido: foi usado, cancelado ou expirou. Peça um novo.", 'invite.joining': "Entrando…", 'invite.after_register': "Entre para participar do grupo para o qual foi convidado.", @@ -871,6 +883,8 @@ export default { // Group misc 'group.retry': 'Tentar novamente', + 'lazy.load_failed': 'Não foi possível carregar esta parte da interface. O hub pode ter sido atualizado desde que esta página foi aberta.', + 'lazy.reload': 'Recarregar a página', // Sidebar 'sidebar.node': 'Node', @@ -893,7 +907,7 @@ export default { 'node.service_restarting': 'Reiniciando…', 'node.service_mode_hint': 'Em execução como serviço em segundo plano — inicia na inicialização, antes do login.', 'node.startup_mode_label': 'Iniciar automaticamente:', - 'node.startup_mode_off': 'Desativado (iniciar manualmente)', + 'node.startup_mode_off': 'Somente enquanto o MeshBay estiver aberto', 'node.startup_mode_signin': 'Ao entrar na sessão', 'node.startup_mode_service': 'Como serviço em segundo plano (inicia na inicialização)', 'node.startup_mode_updating': 'Alternando modo — verifique se aparece um aviso de administrador…', 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 index 4f5034d..92952f4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -93,26 +93,36 @@ export default { 'login.locked': "此账户输错密码短语的次数过多。请在 {minutes} 分钟后重试,或重置密码短语。", // Sign-in page: what MeshBay is (browser only) 'welcome.title': '互联网本该有的样子。', - 'welcome.lead': 'MeshBay 是一款开源软件,让您远程访问个人文件,并在您自己电脑的存储之上运行应用:', + 'welcome.lead': '您的照片、音乐、视频和聊天都留在家里,保存在您自己的电脑上。MeshBay 让您随时随地享用它们,并与您选择的人分享。', 'welcome.app_chat': '聊天', 'welcome.app_photos': '照片', 'welcome.app_media': '媒体中心', 'welcome.app_video': '视频播放器', 'welcome.app_music': '音乐播放器', - 'welcome.groups': '创建群组,让亲友使用其中部分应用。', - 'welcome.e2e': '您的数据经过端到端加密,以点对点方式从每台设备直接传输到存放数据的电脑(也就是您的节点)。数据从不经过 meshbay.org 中心服务器。', + 'welcome.how_title': '使用方式', + 'welcome.step_home': '在家里的电脑上安装 MeshBay。您的文件原地不动。', + 'welcome.step_anywhere': '无论身在何处,都可以用手机、笔记本或任意浏览器登录。', + 'welcome.step_share': '创建群组,邀请家人和朋友加入。', 'welcome.uses_title': '您可以用它做什么', 'welcome.use_chat': '群组内即时通讯', 'welcome.use_photos': '与家人朋友分享相册', 'welcome.use_media': '随时随地访问家中的媒体中心,供您和家人使用(支持流媒体播放,桌面应用还支持 Chromecast)', 'welcome.use_apps': '在您托管的节点上开发自己的应用', - 'welcome.hub_title': 'meshbay.org 的作用', - 'welcome.hub_body': 'meshbay.org 仅是一个信令服务:它负责登录验证,并以即插即用的方式跨家庭网络连接各方。', - 'welcome.hub_never': '您的内容永远不会到达这里:', - 'welcome.never_transit': '没有任何数据经过它:没有内容,也没有索引', - 'welcome.never_stored': '不存储您的任何文件或消息', - 'welcome.never_e2e': '从每台设备到您的节点全程端到端加密', - 'welcome.hub_free': '本服务免费。我们刻意不设商业模式,没有跟踪,也没有广告。尽情享用吧!', + 'welcome.private_title': '生来私密', + 'welcome.private_body': '您的文件和消息经过加密,从您的设备直接传到您自己的电脑。meshbay.org 只负责帮您的设备找到彼此:您的内容从不经过它。', + 'welcome.badge_free': '免费', + 'welcome.badge_open': '开源', + 'welcome.badge_no_ads': '无广告', + 'welcome.badge_no_tracking': '无追踪', + 'welcome.docs_title': '文档', + 'welcome.docs_source': '源代码', + 'welcome.docs_repo': 'git.meshbay.org', + 'welcome.docs_user': '用户文档', + 'welcome.docs_quickstart': '快速入门', + 'welcome.docs_userguide': '用户指南', + 'welcome.docs_devel': '开发文档', + 'welcome.docs_design': '设计', + 'welcome.docs_protocol': '协议', 'welcome.download': '下载(测试版)', 'welcome.legal': '法律信息', 'register.err_mismatch': '两次输入的密码不一致', @@ -667,10 +677,10 @@ export default { 'members.invite_email_failed': '无法发送邮件——请手动分享验证码。', 'members.invite_email_opt': '通过电子邮件发送邀请(可能进入垃圾邮件)', 'members.link_title': "通过链接邀请", - 'members.link_hint': "适用于可能还没有账户的人。该链接只能使用一次,且仅限使用此地址注册的账户。", - 'members.link_email_placeholder': "电子邮件地址", + 'members.link_hint': "适用于可能还没有账户的人。可以用任何方式发送——即时通讯应用、短信。链接仅可使用一次,有效期七天,归最先打开的人。", + 'members.link_email_placeholder': "电子邮件地址(可选)", 'members.link_btn': "创建链接", - 'members.link_ready': "发给 {email} 的邀请链接:", + 'members.link_ready': "邀请链接——有效期 7 天,仅限一次:", 'members.link_copy': "复制", 'members.link_copied': "已复制", 'members.link_email_sent': "链接已通过电子邮件发送。", @@ -679,9 +689,12 @@ export default { 'members.link_status_expired': "已过期", 'members.link_expires': "{date} 过期", 'members.link_cancel': "取消", + 'members.link_share': "分享", + 'members.link_share_title': "邀请", + 'members.link_unlabelled': "{date} 创建的链接", 'invite.title': "您收到了邀请", 'invite.none': "此标签页中没有待处理的邀请。请重新打开您收到的链接。", - 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请使用收到邀请的电子邮件地址创建账户,如果已有账户请登录。", + 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请创建账户,如果已有账户请登录。", 'invite.register': "创建账户", 'invite.signin': "登录", 'invite.confirm': "{inviter} 邀请您加入 {group}。", @@ -689,7 +702,6 @@ export default { 'invite.ignore': "忽略", 'invite.open': "打开群组", 'invite.already_member': "您已经是 {group} 的成员。", - 'invite.other_account': "此邀请发送到了另一个电子邮件地址。请使用该地址注册的账户登录——别名和点号必须完全一致。", 'invite.invalid': "此邀请已失效:已被使用、取消或已过期。请索取新的邀请。", 'invite.joining': "正在加入…", 'invite.after_register': "登录以加入您受邀的群组。", @@ -848,6 +860,8 @@ export default { // Group 'group.retry': '重试', + 'lazy.load_failed': '无法加载界面的这一部分。自打开此页面以来,hub 可能已更新。', + 'lazy.reload': '重新加载页面', // Sidebar 'sidebar.node': 'Node', @@ -870,7 +884,7 @@ export default { 'node.service_restarting': '正在重启…', 'node.service_mode_hint': '以后台服务方式运行 — 在开机时启动,早于登录。', 'node.startup_mode_label': '自动启动:', - 'node.startup_mode_off': '关闭(手动启动)', + 'node.startup_mode_off': '仅在 MeshBay 打开时', 'node.startup_mode_signin': '登录时', 'node.startup_mode_service': '作为后台服务(开机时启动)', 'node.startup_mode_updating': '正在切换模式 — 请留意管理员权限提示…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index a59c100..7fd7ab1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -13,6 +13,14 @@ import { HUB } from './hub-client.js'; // (platform.node.call), not over MNP/WebRTC. The MNP protocol types remain // for potential future browser-side use. +// true / false from a node that has read its roster, null from one that has +// not yet (it publishes the roster only once it has signed in to the hub) -- +// which is not the same as having no operator. +export function pairedFrom(result) { + const v = result && result.operator_paired; + return v === true || v === false ? v : null; +} + function NodeServicePanel({ onChanged, token, username }) { const [info, setInfo] = useState(null); const [busy, setBusy] = useState(''); @@ -202,7 +210,7 @@ export function NodePage({ groups, token, username }) { const [editIce, setEditIce] = useState(null); const [savingIce, setSavingIce] = useState(false); const [iceInput, setIceInput] = useState(''); - const [operatorPaired, setOperatorPaired] = useState(false); + const [operatorPaired, setOperatorPaired] = useState(null); const [pairBusy, setPairBusy] = useState(false); const [pairStatus, setPairStatus] = useState(''); const [nodeInfo, setNodeInfo] = useState(null); @@ -231,7 +239,7 @@ export function NodePage({ groups, token, username }) { } const result = await nodeCall('GET', '/api/groups'); setNodeGroups(result.groups || []); - setOperatorPaired(!!result.operator_paired); + setOperatorPaired(pairedFrom(result)); setNodeSettings(result.settings || null); try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {} setStatus('connected'); @@ -253,7 +261,7 @@ export function NodePage({ groups, token, username }) { try { const result = await nodeCall('GET', '/api/groups'); setNodeGroups(result.groups || []); - setOperatorPaired(!!result.operator_paired); + setOperatorPaired(pairedFrom(result)); setNodeSettings(result.settings || null); try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {} } catch {} @@ -711,7 +719,7 @@ export function NodePage({ groups, token, username }) { </div> <${NodeServicePanel} onChanged=${fetchStatus} token=${token} username=${username} /> ${actionMsg && html`<div class="node-message">${actionMsg}</div>`} - ${!operatorPaired && html` + ${operatorPaired === false && html` <div class="node-pair-banner"> <p>${t('node.pair_needed')}</p> <button class="btn btn-primary btn-small" diff --git a/packages/meshbay-hub/src/meshbay_hub/static/og-image.jpg b/packages/meshbay-hub/src/meshbay_hub/static/og-image.jpg Binary files differnew file mode 100644 index 0000000..99fd7e1 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/og-image.jpg diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js index ed7a2fa..d3d06d7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -37,6 +37,10 @@ export function ProfilePage({ user, onLogout }) { const [emailVerifyPending, setEmailVerifyPending] = useState(false); const [emailCode, setEmailCode] = useState(''); const [emailVerifying, setEmailVerifying] = useState(false); + // Changing the address on file now needs the passphrase (the hub verifies the + // derived auth_key): a bare access token — which every node this account + // connects to is handed — must not be able to start an account takeover. + const [emailPass, setEmailPass] = useState(''); const [pinCount, setPinCount] = useState( () => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0)); const [delOpen, setDelOpen] = useState(false); @@ -232,12 +236,18 @@ export function ProfilePage({ user, onLogout }) { const saveEmail = useCallback(async () => { const val = emailDraft.trim(); if (!val || val === email) { setEmailEditing(false); return; } + if (!emailPass) return; // the Save button is disabled until it is entered setEmailSaving(true); setEmailStatus(''); try { + // The passphrase is the second factor here, exactly as for a passphrase + // change or an account deletion: the hub gets the derived auth_key, never + // the passphrase itself. + const authKey = await window.MeshBayKeys.deriveAuthKey(emailPass, user.username); const resp = await hubFetch('/v1/users/me', { - method: 'PATCH', token: user.token, body: { email: val }, + method: 'PATCH', token: user.token, body: { email: val, auth_key: authKey }, }); + setEmailPass(''); if (resp.email_verification_required) { setEmailVerifyPending(true); setEmailStatus(t('settings.email_code_sent')); @@ -248,11 +258,13 @@ export function ProfilePage({ user, onLogout }) { setTimeout(() => setEmailStatus(''), 3000); } } catch (e) { - setEmailStatus(e.message); + const msg = /403|does not match/i.test(e.message) + ? t('settings.pw_wrong_current') : e.message; + setEmailStatus(msg); } finally { setEmailSaving(false); } - }, [emailDraft, email, user.token]); + }, [emailDraft, email, emailPass, user.token, user.username]); const verifyEmailChange = useCallback(async () => { if (!emailCode.trim()) return; @@ -311,14 +323,18 @@ export function ProfilePage({ user, onLogout }) { ? html`<span style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"> <input type="email" value=${emailDraft} onInput=${e => setEmailDraft(e.target.value)} - onKeyDown=${e => e.key === 'Enter' && saveEmail()} disabled=${emailVerifyPending} style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" /> ${!emailVerifyPending && html` + <input type="password" autocomplete="current-password" + placeholder=${t('login.password')} + value=${emailPass} onInput=${e => setEmailPass(e.target.value)} + onKeyDown=${e => e.key === 'Enter' && saveEmail()} + style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" /> <button class="admin-btn" onClick=${saveEmail} - disabled=${emailSaving}>${t('settings.email_save')}</button> + disabled=${emailSaving || !emailPass}>${t('settings.email_save')}</button> <button class="btn-secondary" onClick=${() => { - setEmailEditing(false); setEmailDraft(email); + setEmailEditing(false); setEmailDraft(email); setEmailPass(''); }}>${t('settings.cancel')}</button> `} </span>` diff --git a/packages/meshbay-hub/src/meshbay_hub/static/robots.txt b/packages/meshbay-hub/src/meshbay_hub/static/robots.txt new file mode 100644 index 0000000..a2ef43d --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/robots.txt @@ -0,0 +1,9 @@ +# Served by the hub at the root of its origin, meshbay.org's included (the +# Caddyfile sends every path the public site does not name to the hub). +# +# The sign-in page at /app may be indexed; nothing behind it can be, since it +# is a signed-in view, and /v1/ is the API. /a/ stays open on purpose: a +# crawler that renders /app needs the scripts and the stylesheet. +User-agent: * +Disallow: /app/ +Disallow: /v1/ diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 5f16a6c..29d4181 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -491,6 +491,14 @@ a:hover { text-decoration: underline; } margin-bottom: 24px; } +/* lazy.js: a view whose code could not be fetched says so, in place of the + spinner it would otherwise have kept for good. Inside the player's overlay + the backdrop is near-black whatever the theme, so the text colour cannot + come from the theme there. */ +.lazy-failed { text-align: center; max-width: 520px; margin: 24px auto; } +.lazy-failed p { margin-bottom: 12px; } +.video-overlay .lazy-failed { color: #e2e8f0; } + /* ── Page center (login / register) ───────────────────────────────────────── */ .page-center { @@ -670,7 +678,47 @@ a:hover { text-decoration: underline; } keyboard) and `row-reverse` is what moves it right. Headings are `.welcome-pitch .welcome-h` rather than a bare class so they outrank `.main h2`. */ +/* The whole sign-in page sits on one dark backdrop, in either theme: steel + blue at the top left, night blue pooling at the bottom left, charcoal to near + black at the bottom right. Drawn in CSS alone, a fixed layer under the + page. The theme tokens are redefined for everything on it, so the form, the + cards and the chips keep their own rules and still read. */ +.welcome-backdrop { + position: fixed; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; + background: linear-gradient(155deg, + #809cbc 0%, #6a819b 18%, #3d4d61 42%, #232b36 66%, #0f1113 100%); +} +/* The night-blue pool. */ +.welcome-backdrop::before { + content: ''; + position: absolute; + left: -25%; + bottom: -30%; + width: 85%; + height: 90%; + background: radial-gradient(closest-side, rgba(26, 46, 110, 0.95), transparent); + filter: blur(36px); +} + .welcome { + --text: #f4f6fa; + --text-secondary: rgba(236, 241, 248, 0.80); + --text-dim: rgba(226, 233, 243, 0.60); + --border: rgba(255, 255, 255, 0.14); + --bg-base: rgba(255, 255, 255, 0.06); + --bg-surface: rgba(255, 255, 255, 0.07); + --accent-bg: rgba(255, 255, 255, 0.10); + --accent: #8fd0ff; + --accent-hover: #b5e0ff; + --accent-text: #0b1220; + --border-focus: #8fd0ff; + position: relative; + z-index: 1; + color: var(--text); display: flex; flex-direction: row-reverse; align-items: flex-start; @@ -679,7 +727,16 @@ a:hover { text-decoration: underline; } width: 100%; max-width: 1180px; } -.welcome > .login-card { flex: 0 0 380px; } +.welcome-side { display: flex; flex: 0 0 380px; flex-direction: column; gap: 16px; } +/* Frosted glass over the backdrop rather than a white sheet on it. */ +.welcome .login-card { + border-color: rgba(255, 255, 255, 0.16); + background: rgba(12, 16, 22, 0.45); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); + -webkit-backdrop-filter: blur(14px); + backdrop-filter: blur(14px); +} +.welcome .login-card h2 { color: var(--text); } .welcome-pitch { flex: 1 1 0; @@ -691,13 +748,14 @@ a:hover { text-decoration: underline; } } .welcome-pitch p { margin-bottom: 10px; } -.welcome-apps, .welcome-uses, .welcome-never { list-style: none; } +.welcome-apps, .welcome-steps, .welcome-uses, .welcome-badges, +.welcome-docs ul { list-style: none; } .welcome-title { - margin-bottom: 10px; + margin-bottom: 12px; color: var(--text); - font-size: 1.9em; - font-weight: 700; + font-size: 2.3em; + font-weight: 500; line-height: 1.2; letter-spacing: -0.02em; text-wrap: balance; @@ -718,18 +776,6 @@ a:hover { text-decoration: underline; } } .welcome-apps .icon { color: var(--accent); } -.welcome-e2e { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 8px 14px; - border-left: 3px solid var(--accent); - border-radius: 0 6px 6px 0; - background: var(--accent-bg); - color: var(--text); -} -.welcome-e2e .icon { margin-top: 0.3em; color: var(--accent); } - .welcome-pitch .welcome-h { margin: 18px 0 8px; color: var(--text-dim); @@ -748,7 +794,39 @@ a:hover { text-decoration: underline; } font-size: 0.92em; line-height: 1.45; } -.welcome-use-icon { +/* How it works: three cards side by side, numbered by the counter so the + translation only carries the sentence. */ +.welcome-steps { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + counter-reset: welcome-step; +} +.welcome-steps li { + position: relative; + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 14px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-surface); + color: var(--text); + font-size: 0.9em; + line-height: 1.45; + counter-increment: welcome-step; +} +.welcome-steps li::after { + content: counter(welcome-step); + position: absolute; + top: 10px; + right: 12px; + color: var(--text-dim); + font-size: 1.3em; + font-weight: 700; + opacity: 0.5; +} +.welcome-step-icon, .welcome-use-icon { display: inline-flex; flex: 0 0 auto; align-items: center; @@ -761,57 +839,108 @@ a:hover { text-decoration: underline; } font-size: 17px; } -.welcome-hub { - margin-top: 18px; +/* Privacy, said once: a warm card rather than a list of denials. */ +.welcome-private { + display: flex; + align-items: flex-start; + gap: 14px; + margin-top: 20px; + padding: 16px 18px; + border-radius: 12px; + background: var(--accent-bg); + color: var(--text); +} +.welcome-private-icon { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--accent); + color: var(--accent-text); + font-size: 20px; +} +.welcome-pitch .welcome-private-title { + margin: 2px 0 4px; + color: var(--text); + font-size: 1.05em; + font-weight: 600; +} +.welcome-pitch .welcome-private p { margin-bottom: 10px; } +.welcome-badges { display: flex; flex-wrap: wrap; gap: 6px; } +.welcome-badges li { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 999px; + background: var(--bg-surface); + color: var(--text); + font-size: 0.82em; + font-weight: 500; +} +.welcome-badges .icon { color: var(--success); } + +.welcome-docs { + margin-top: 16px; padding: 12px 18px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: 10px; background: var(--bg-surface); - font-size: 0.92em; + font-size: 0.9em; } -.welcome-pitch .welcome-hub .welcome-h { margin-top: 0; } -.welcome-pitch .welcome-hub-never { margin-bottom: 6px; color: var(--text); font-weight: 600; } -.welcome-never { display: flex; flex-direction: column; gap: 4px; color: var(--text); } -.welcome-never li { display: flex; align-items: flex-start; gap: 8px; } -.welcome-never .icon { margin-top: 0.3em; color: var(--success); } -.welcome-pitch .welcome-hub-free { - margin: 10px 0 0; - padding-top: 8px; - border-top: 1px solid var(--border); - color: var(--text); - font-weight: 500; +.welcome-pitch .welcome-docs .welcome-h { margin-top: 0; } +.welcome-docs ul { display: flex; flex-direction: column; gap: 6px; } +.welcome-docs li { + display: grid; + grid-template-columns: auto 10em 1fr; + align-items: baseline; + gap: 2px 8px; } +.welcome-docs .icon { color: var(--accent); } +.welcome-docs-label { color: var(--text); font-weight: 500; } +.welcome-docs-links { min-width: 0; overflow-wrap: anywhere; } .welcome-links { display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px 20px; - margin-top: 14px; + flex-direction: column; + align-items: stretch; + gap: 10px; + text-align: center; } .welcome-cta { - display: inline-flex; + display: flex; align-items: center; + justify-content: center; gap: 8px; padding: 10px 18px; border-radius: 6px; - background: var(--accent); - color: var(--accent-text); + /* Green, so it does not compete with the blue sign-in button above it. + #16a34a rather than --success: white text is still legible on it. */ + background: #16a34a; + color: #ffffff; font-size: 0.95em; font-weight: 600; } -.welcome-cta:hover { background: var(--accent-hover); text-decoration: none; } +.welcome-cta:hover { background: #15803d; text-decoration: none; } .welcome-legal { color: var(--text-secondary); font-size: 0.9em; } .welcome-legal:hover { color: var(--accent); } @media (max-width: 1000px) { .welcome { flex-direction: column; align-items: center; gap: 36px; } - .welcome > .login-card { flex: none; } + .welcome-side { flex: none; width: 100%; max-width: 380px; } .welcome-pitch { flex: none; width: 100%; max-width: 520px; } } @media (max-width: 520px) { .welcome-title { font-size: 1.6em; } .welcome-uses { grid-template-columns: 1fr; } + .welcome-steps { grid-template-columns: 1fr; } + .welcome-steps li { flex-direction: row; align-items: center; padding-right: 36px; } + .welcome-steps li::after { top: 50%; transform: translateY(-50%); } + .welcome-docs li { grid-template-columns: auto 1fr; } + .welcome-docs-links { grid-column: 2; } } /* ── Form elements ────────────────────────────────────────────────────────── */ @@ -1858,6 +1987,13 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } justify-content: center; } +/* The player opens on top of a show's detail modal, which stays open under it + so the next episode is one click away when the player closes. Both are + `.video-overlay`; equal values would leave the order to where each happens + to sit in the DOM, which is not a decision. Below the operator's match + search (210), which only ever opens from the modal, never over the player. */ +.video-overlay.video-player-overlay { z-index: 205; } + .video-top-bar { position: absolute; top: 0; @@ -4014,6 +4150,13 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } margin-bottom: 4px; } .video-episode-row:hover { border-color: var(--accent); color: var(--accent); } +/* The episode last started from this modal, so the one to watch next is the + row under it. A border and a tint, not a colour change of the text alone, + so it does not read as the hover state left behind. */ +.video-episode-row.last-played { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} .video-episode-row .icon { width: 14px; height: 14px; flex-shrink: 0; } .video-episode-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; } .video-episode-thumb { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js index 6ae51d1..18ad9d4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js @@ -110,7 +110,7 @@ async function rewrapAllNodes(o) { try { await _acWithTimeout( tp.connect(n.node_id, o.token, g.id, null, null, oldKey, - o.username, o.userId, null, recoveryKey), + o.username, o.userId, null, recoveryKey, undefined, n.pk_node), 30000, 'connect'); if (tp.newNodeBundle) { // No identity existed on this node — connect just minted one under diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 202db94..546d01b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -305,16 +305,15 @@ window.addEventListener('hashchange', () => { // The `v: '0.1'` on every other message in this file is the historical value // and is read by nothing; it is left alone deliberately. The range is // negotiated once, at the start, not restated per message. -const MNP_V = '3.1'; -// **Not** raised with it, and that is the whole difference between 3.0 and 3.1. -// 3.0 was a flag day because a node older than it cannot grant the lease this -// client opens for every download and upload, so talking to one would mean -// every transfer failing for a reason the person cannot act on. 3.1 only adds -// `user_blob_*`: a 3.0 node answers "unknown message type" and the client -// stores its playlists on the next node it reaches, keeping its own copy -// meanwhile (docs/playlists.md §6.4). Refusing every 3.0 node over a feature -// that degrades this quietly would be the flag day nobody needed. -const MNP_V_MIN = '3.0'; +const MNP_V = '4.0'; +// Raised with it: 4.0 is a flag day. A member now presents a short-lived +// MNP-audience token in the handshake, not its hub session token — a node +// older than 4.0 expected the session token, and one newer refuses it, so the +// two cannot authenticate across the break. This is the C6 rule: no +// compatibility branch, or the old path (a hub credential handed to a node) +// stays reachable. The desktop client is gated by client.minimum before it +// even connects; the browser picks up this build on reload. +const MNP_V_MIN = '4.0'; // Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's // check_version): `version_too_old` means *we* are too old for it, @@ -651,15 +650,34 @@ class MeshBayTransport { try { this.onConnectProgress(phase); } catch { /* the caller's problem */ } } + // Fetch the short-lived token presented to a node in the handshake. It is a + // different credential from the session token used for hub calls: aud=MNP_AUD, + // useless at the hub API, so a node operator who captures it gains nothing + // there (see meshbay_common/tokens.py). Uses the session token to ask. + async _fetchNodeToken(call) { + const doFetch = call + || (window.MeshBayPlatform && window.MeshBayPlatform.apiFetch) || fetch; + const r = await doFetch(`${this._hubUrl}/v1/nodes/mnp-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this._accessToken}`, + }, + body: JSON.stringify({ node_pk: this._nodePkTarget || '' }), + }); + if (!r.ok) throw new Error(`Could not obtain a node token: ${r.status}`); + return (await r.json()).mnp_token; + } + async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, - userId, joinCode, recoveryKey, joinNodePk) { + userId, joinCode, recoveryKey, joinNodePk, nodePk) { // Remembered for _reconnectLoop, which calls connect() again with these // same values (plus a freshly-fetched token and the identity connect() // itself settles on below) after the WebRTC connection is declared // "failed" — see the pc.onconnectionstatechange handler further down. this._connectArgs = { nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey, - joinNodePk, + joinNodePk, nodePk, }; this._lastToken = jwtToken; // The constructor sets this once from whatever token the caller had at @@ -677,6 +695,10 @@ class MeshBayTransport { this._recoveryKey = recoveryKey || null; this._username = username || null; this._userId = userId || null; + // The key of the node we mean to reach, from the hub's node list. The MNP + // token is bound to it (E10) so it cannot be replayed to another node. It is + // the *expected* key; `this.nodePk` below is the one the node then proves. + this._nodePkTarget = nodePk || ''; // The group this connection is for. Kept on the instance because the // handshake is not the only thing that needs it any more: device_hello and // the chat envelope both bind to it, and both run outside connect()'s scope. @@ -896,11 +918,21 @@ class MeshBayTransport { // recorded handshake_ack could be replayed by an impersonating peer. this._nonceClient = crypto.getRandomValues(new Uint8Array(32)); + // The member authenticates to the node with a short-lived MNP token, never + // its hub session token. A node operator holds whatever is presented here, + // and the session token opens the hub API — so presenting it would hand an + // operator a live credential for the member (audience-bound, see + // meshbay_common/tokens.py). Fetched per connect and per reconnect with the + // session token (`this._accessToken`), so it always carries current group + // membership and a fresh expiry. Signaling above still uses the session + // token, because that is a hub call. + const nodeToken = await this._fetchNodeToken(call); + const reply = await this._sendAndWait({ type: 'handshake', v: MNP_V, v_min: MNP_V_MIN, - token: jwtToken, + token: nodeToken, group_id: groupId || '', nonce: window.MeshBayCrypto.b64encode(this._nonceClient), }); @@ -1293,7 +1325,7 @@ class MeshBayTransport { ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw, this._sessionKeys, args.bundleKey, args.username, args.userId, args.joinCode, undefined, - args.joinNodePk); + args.joinNodePk, args.nodePk); } finally { this._inReconnectAttempt = false; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index 124033e..9e2db1c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -619,6 +619,13 @@ function VideoDetailModal({ const [selectedSeason, setSelectedSeason] = useState(null); useEffect(() => { setSelectedSeason(defaultSeason(show)); }, [show]); + // A show's modal stays open under the player (PosterGrid's onPlay), so + // closing the player lands back here. Marking the episode just started is + // what makes "the next one" the row under the mark, rather than a row the + // reader has to find again by its number. + const [lastPlayedId, setLastPlayedId] = useState(null); + useEffect(() => { setLastPlayedId(null); }, [show]); + const showMultiSeason = Boolean(show && show.seasons.length > 1); const seasonMeta = useSeasonMeta( transportRef, confident ? meta.tmdb_id : null, selectedSeason, @@ -708,7 +715,10 @@ function VideoDetailModal({ </div> `} ${s.episodes.map((ep) => html` - <button class="video-episode-row" key=${ep.id} onClick=${() => onPlay(ep)}> + <button key=${ep.id} + class="video-episode-row ${ep.id === lastPlayedId ? 'last-played' : ''}" + aria-current=${ep.id === lastPlayedId ? 'true' : undefined} + onClick=${() => { setLastPlayedId(ep.id); onPlay(ep); }}> <${LazyTile} cls="video-episode-thumb-slot"> <${MediaThumb} thumbHash=${ep.thumb_hash} alt=${ep.display_title || ep.name} cls="video-episode-thumb" transportRef=${ep._tRef || transportRef} gekRef=${ep._gRef || gekRef} @@ -857,13 +867,18 @@ function PosterGrid({ </${LazyTile}> `; })} </div> + ${/* A show's modal is left open under the player: watching episode after + episode used to mean reopening the show and picking the season again + every time. A film has nothing left to pick, so its modal still + closes. The player stacks above it by its own z-index (style.css, + .video-player-overlay), not by DOM order. */''} ${detail && html` <${VideoDetailModal} title=${detail.title} meta=${detailMeta} repEntry=${detail.repEntry} show=${detail.show} transportRef=${detailTRef} gekRef=${detailGRef} isNodeAdmin=${isNodeAdmin} tmdbEnabled=${tmdbEnabled} onClose=${() => setDetail(null)} - onPlay=${(entry) => { setDetail(null); onPreview(entry); }} /> + onPlay=${(entry) => { if (!detail.show) setDetail(null); onPreview(entry); }} /> `} `; } @@ -976,6 +991,22 @@ function VideoApp({ useEffect(() => { setMode(loadViewMode()); }, [groupId]); useEffect(() => { setFilter(''); setTypeFilter('all'); }, [groupId]); + // When the operator changes the node's TMDB language, the node drops its + // metadata cache and refetches in the new language, and — until a language + // is set — answers nothing at all rather than querying in English (§9.7). + // Tell every mounted tile to redo its media_meta_req and drop the + // show-level meta already merged here, so the grid switches language (or + // fills in for the first time, right after the operator picks one) without + // a page reload. Skip the initial mount: the language is already right then, + // and bumping would restorm TMDB on every open of the Videos tab. + const tmdbLanguage = tmdbConfig ? (tmdbConfig.language || '') : ''; + const firstLangRef = useRef(true); + useEffect(() => { + if (firstLangRef.current) { firstLangRef.current = false; return; } + setMetaByGroup({}); + bumpMediaMetaGeneration(); + }, [tmdbLanguage]); + const setModeAndSave = (m) => { setMode(m); saveViewMode(m); }; const videoEntries = availableEntries || entries; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js index 6eaacc5..2b274b5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -1596,7 +1596,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { }, []); return html` - <div class="video-overlay" onClick=${(e) => { + <div class="video-overlay video-player-overlay" onClick=${(e) => { if (e.target.classList.contains('video-overlay')) onClose(); }}> <div class="video-top-bar"> |